img

一、引言

在"奇妙科学乐园"这款面向6-12岁儿童的纯离线科普教育应用中,页面间的跳转与数据传递是贯穿全局的基础能力。用户从首页点击文章卡片进入详情页、从实验室列表进入实验详情、从趣味问答跳转到答题结果页、底部Tab栏在各功能模块间切换——这些场景都依赖于路由跳转和参数传递。

HarmonyOS提供了@ohos.router模块作为原生路由能力,但在实际业务开发中直接使用系统API会面临以下问题:

(1)异常处理缺失

系统路由API(如router.pushUrl)在目标页面不存在、路由栈溢出等异常情况下会抛出错误。如果每个跳转点都手写try-catch,代码重复度极高,且容易遗漏。

(2)参数类型不安全

router.pushUrlparams参数类型为Record<string, Object>,这意味着传入任何参数都不会在编译期报错。但如果接收方期望topicId: number,而发送方传了topicId: string,问题只能在运行时暴露。

(3)跳转方式选择混乱

HarmonyOS路由提供了pushUrl(压栈)、replaceUrl(替换)、back(返回)、clear(清栈)等多种操作,开发者需要根据业务场景选择正确的跳转方式。错误的选择会导致路由栈堆积、用户无法返回等问题。

(4)缺少统一日志追踪

当用户报告"点击按钮没反应"或"跳转到了错误页面"时,如果没有统一的日志记录,排查问题需要逐个断点调试,效率极低。

针对以上问题,本应用设计了RouterUtil工具类和RouterParams参数接口,在系统路由API之上构建了一层类型安全、异常可控、日志可追踪的跳转封装。上一篇(第65篇)介绍了路由地址的中心化管理,本篇聚焦于跳转逻辑的封装与参数传递的实战。


二、学习目标

通过本章的学习,你将能够:

  • 掌握 RouterParams 接口设计,实现路由参数的类型安全约束
  • 掌握 RouterUtil 工具类封装,统一跳转异常处理与日志追踪
  • 掌握 pushUrlreplaceUrl 的选择策略与实战应用
  • 掌握页面间参数传递的封装方法与接收方的防御性编程
  • 理解日志追踪与异常处理机制的设计思路

三、需求分析

(1)参数接口的设计需求

  • 所有字段可选:每个参数都标记为optional?),因为不同页面需要的参数不同。跳转到设置页面不需要任何参数,跳转到详情页则需要topicId
  • 类型明确topicIdnumber类型,labIdstring类型,tabIndexnumber类型。类型区分来自业务数据模型的设计——文章用数字ID,实验用字符串ID。
  • 集中声明:全应用所有页面间传递的参数都集中在这一个接口中。新增页面需要传递新参数时,在这里添加字段即可。如果两个页面需要传递同名但不同类型的参数,则需要考虑是否应该拆分接口。

RouterParams的基础上,需要进一步定义RouterOptions接口,将params的类型从宽泛的Record<string, Object>收窄为RouterParams。这是类型安全跳转的第一道防线。

通过RouterParams接口,开发者在编写跳转代码时就能获得类型提示和编译期检查,避免了"传错了参数类型但编译通过"的问题。例如:

// 场景一:跳转到科普详情页,需要传递文章ID
const params: RouterParams = { topicId: 1 };  // 正确:number 类型
const params2: RouterParams = { topicId: '1' }; // 编译错误:不能将 string 赋值给 number

// 场景二:跳转到主Tab页,指定切换到第2个Tab(科普知识)
const params3: RouterParams = { tabIndex: 1 };  // 正确:number 类型

// 场景三:跳转到实验详情页,需要传递实验ID
const params4: RouterParams = { labId: 'exp_001' };  // 正确:string 类型

// 场景四:同时传递多个参数(当前业务暂无此需求,但接口支持)
const params5: RouterParams = { topicId: 1, tabIndex: 0 };  // 正确:多参数组合

(2)工具类封装的设计需求

RouterUtil的每个跳转方法需要满足以下设计约束:

  • from参数:第二个参数from是一个字符串标记,用于在日志中标识跳转的发起页面。当排查跳转问题时,通过日志可以立即定位是哪个页面发起的跳转。
  • 异常不抛出catch块中只记录日志,不throw error。这是有意为之的设计——在儿童教育应用中,跳转失败不应该导致应用崩溃或弹出不友好的错误提示。
  • 静态方法:所有路由方法都是静态方法,不需要实例化RouterUtil即可调用,使用简洁。
  • 安全返回:当router.getParams()抛出异常时(比如在Previewer环境中无法获取参数),返回一个空对象而非null,确保调用方使用params.topicId时不会因为null访问而崩溃。

四、核心实现

步骤一:定义 RouterParams 与 RouterOptions 接口

RouterUtil.ets文件中,首先定义了RouterParams接口,统一管理全应用的路由参数:

// 工具文件:entry/src/main/ets/utils/RouterUtil.ets

/*
 * 文件用途:路由工具 - 统一路由跳转封装,处理异常和日志
 * 创建时间:2026-07-14
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:无
 */

import router from '@ohos.router';
import { Logger } from './Logger';

const TAG = 'RouterUtil';

// 路由参数接口——全应用统一的参数类型定义
export interface RouterParams {
  topicId?: number;     // 科普文章ID,用于 TopicDetail 页面
  tabIndex?: number;    // Tab索引,用于 MainTabs 页面切换指定Tab
  categoryId?: string;  // 分类ID,用于 Quiz 页面筛选分类
  labId?: string;       // 实验ID,用于 LabDetail 页面
}

RouterParams的基础上,进一步定义了RouterOptions接口:

// 路由选项接口——封装跳转目标地址和参数
export interface RouterOptions {
  url: string;              // 目标页面路由地址(来自 RouteUrls 常量)
  params?: RouterParams;    // 路由参数(可选)
}

步骤二:封装 RouterUtil 工具类

pushUrl是最常用的跳转方式,将目标页面压入路由栈。用户可以通过返回按钮回到上一个页面:

export class RouterUtil {
  /**
   * 跳转到指定页面(压栈)
   * @param options 路由选项,包含目标地址和可选参数
   * @param from 来源标记,用于日志追踪,标识跳转发起方
   */
  static async pushUrl(options: RouterOptions, from: string = ''): Promise<void> {
    try {
      // 记录跳转日志,包含来源页面标识
      Logger.info(TAG, `${from ? `[${from}] ` : ''}pushUrl: ${options.url}`);
      // 调用系统路由API执行跳转
      await router.pushUrl(options);
    } catch (error) {
      // 捕获异常并记录错误日志,防止应用崩溃
      Logger.error(TAG, `${from ? `[${from}] ` : ''}pushUrl failed: ${options.url}`, error);
    }
  }
  // ...
}

replaceUrl用目标页面替换当前页面,不会增加路由栈深度。适用于"切换后不需要返回当前页"的场景:

  /**
   * 替换当前页面(不压栈)
   * @param options 路由选项
   * @param from 来源标记,用于日志追踪
   */
  static async replaceUrl(options: RouterOptions, from: string = ''): Promise<void> {
    try {
      Logger.info(TAG, `${from ? `[${from}] ` : ''}replaceUrl: ${options.url}`);
      await router.replaceUrl(options);
    } catch (error) {
      Logger.error(TAG, `${from ? `[${from}] ` : ''}replaceUrl failed: ${options.url}`, error);
    }
  }

返回上一页:

  /**
   * 返回上一页
   * @param from 来源标记,用于日志追踪
   */
  static async back(from: string = ''): Promise<void> {
    try {
      Logger.info(TAG, `${from ? `[${from}] ` : ''}back`);
      router.back();
    } catch (error) {
      Logger.error(TAG, `${from ? `[${from}] ` : ''}back failed`, error);
    }
  }

获取路由参数:

  /**
   * 获取路由参数
   * @returns 路由参数对象
   */
  static getParams(): RouterParams {
    try {
      const params = router.getParams() as RouterParams;
      return params;
    } catch (error) {
      Logger.error(TAG, 'getParams failed', error);
      // 获取失败时返回空对象,防止调用方空指针异常
      const emptyParams: RouterParams = {};
      return emptyParams;
    }
  }

清空路由栈,跳转到指定页面:

  /**
   * 清空路由栈,跳转到指定页面
   * @param url 目标页面URL
   * @param from 来源标记,用于日志追踪
   */
  static async clearAndPush(url: string, from: string = ''): Promise<void> {
    try {
      Logger.info(TAG, `${from ? `[${from}] ` : ''}clearAndPush: ${url}`);
      router.clear();
      const pushOptions: RouterOptions = { url: url };
      await router.pushUrl(pushOptions);
    } catch (error) {
      Logger.error(TAG, `${from ? `[${from}] ` : ''}clearAndPush failed: ${url}`, error);
    }
  }

辅助方法:路由栈长度与状态查询:

  /**
   * 获取路由栈长度
   * @returns 路由栈长度
   */
  static getStackLength(): number {
    try {
      const len = router.getLength();
      return Number(len) || 0;
    } catch (error) {
      Logger.error(TAG, 'getStackLength failed', error);
      return 0;
    }
  }

  /**
   * 获取当前路由状态
   * @returns 路由状态对象
   */
  static getState(): router.RouterState | null {
    try {
      return router.getState();
    } catch (error) {
      Logger.error(TAG, 'getState failed', error);
      return null;
    }
  }

步骤三:参数封装与传递实战

场景一:携带文章ID跳转详情页(pushUrl)

这是应用中最常见的参数传递场景。用户在首页、科普列表、收藏页、历史记录页点击文章时,都需要携带topicId跳转到TopicDetail页面。

发起方代码(以 Index.ets 首页为例):

// 页面文件:entry/src/main/ets/pages/Index.ets

import { RouterUtil, RouterOptions, RouterParams } from '../utils/RouterUtil';
import { RouteUrls } from '../constants/RouteUrls';

// 跳转到科普详情页
goToTopicDetail(topic: Topic): void {
  // 构建类型安全的路由参数
  const params: RouterParams = { topicId: topic.id };
  // 构建路由选项
  const options: RouterOptions = {
    url: RouteUrls.TOPIC_DETAIL,
    params: params
  };
  // 使用 RouterUtil 执行压栈跳转,'Index' 标识来源页面
  RouterUtil.pushUrl(options, 'Index');
}

接收方代码(TopicDetail.ets 详情页):

// 页面文件:entry/src/main/ets/pages/TopicDetail.ets

import { RouterUtil } from '../utils/RouterUtil';
import { scienceData } from '../viewmodel/ScienceData';

@Entry
@Component
struct TopicDetail {
  @State topic: Topic | null = null;
  @State isFavorite: boolean = false;

  aboutToAppear() {
    // 通过 RouterUtil 获取路由参数
    const params = RouterUtil.getParams() as Record<string, Object>;
    // 校验参数存在性,防止空参数导致崩溃
    if (params && params.topicId) {
      // 类型断言:从 Object 转为 number
      const topicId = params.topicId as number;
      // 根据ID查询文章数据
      const foundTopic = scienceData.getTopicById(topicId);
      if (foundTopic) {
        this.topic = foundTopic;
        this.isFavorite = userPrefs.isFavoriteSync(topicId);
        this.recordRead(foundTopic);
      }
    }
  }

  // 文章不存在时的兜底处理
  // build() 方法中会判断 this.topic 是否为 null
  // 如果为 null,显示"文章不存在"提示和"返回首页"按钮
}

参数传递链路:

Index.ets
  RouterParams { topicId: 1 }
    -> router.pushUrl({ url: 'pages/TopicDetail', params: { topicId: 1 } })
      -> 系统路由序列化参数
        -> TopicDetail 页面加载
          -> RouterUtil.getParams() -> { topicId: 1 }
            -> as Record<string, Object> -> topicId as number -> 1
              -> scienceData.getTopicById(1) -> Topic 对象
场景二:携带实验ID跳转实验详情(pushUrl)

实验室列表跳转到实验详情的参数传递模式与文章详情类似,但参数类型不同:

// 页面文件:entry/src/main/ets/pages/Lab.ets

import { RouterUtil, RouterOptions, RouterParams } from '../utils/RouterUtil';
import { RouteUrls } from '../constants/RouteUrls';

// 跳转到实验详情页
goToDetail(experimentId: string): void {
  // 实验ID是 string 类型(如 'exp_001'),与文章的 number 类型不同
  const params: RouterParams = { labId: experimentId };
  const options: RouterOptions = { url: RouteUrls.LAB_DETAIL, params: params };
  RouterUtil.pushUrl(options, 'Lab');
}
// 页面文件:entry/src/main/ets/pages/LabDetail.ets

aboutToAppear() {
  const params = RouterUtil.getParams() as Record<string, Object>;
  if (params && params.labId) {
    const id = params.labId as string;
    // 根据实验ID查询实验数据
    this.experiment = scienceData.getExperimentById(id) || null;
  }
  this.isLoading = false;
}
场景三:携带分类ID跳转问答页(pushUrl + params)
// 页面文件:entry/src/main/ets/pages/Quiz.ets

aboutToAppear() {
  const params = RouterUtil.getParams() as Record<string, Object>;
  // 如果传入了分类ID,直接进入该分类的答题
  if (params && params.categoryId) {
    this.currentCategory = params.categoryId as string;
  }
  this.categories = scienceData.getAllCategories();
  Logger.info(TAG, '问答页面加载');
}
场景四:答题结果页的多参数传递

答题结果页(QuizResult.ets)是参数最多的页面,接收多达7个参数。虽然这些参数不完全在RouterParams接口中定义(如correctCountpercentage等是结果页特有的),但通过RouterUtil.getParams()统一获取:

// 页面文件:entry/src/main/ets/pages/QuizResult.ets

aboutToAppear() {
  const params = RouterUtil.getParams() as Record<string, Object>;
  if (params) {
    // 逐个参数做存在性校验和类型转换
    if (params.correctCount !== undefined && params.correctCount !== null) {
      this.correctCount = params.correctCount as number;
    }
    if (params.totalCount !== undefined && params.totalCount !== null) {
      this.totalCount = params.totalCount as number;
    }
    if (params.percentage !== undefined && params.percentage !== null) {
      this.percentage = params.percentage as number;
    }
    if (params.category !== undefined && params.category !== null) {
      this.category = params.category as string;
    }
    if (params.isDaily !== undefined && params.isDaily !== null) {
      this.isDaily = params.isDaily as boolean;
    }
    if (params.usedTime !== undefined && params.usedTime !== null) {
      this.usedTime = params.usedTime as number;
      this.hasUsedTime = true;
    }
  }
}

防御性编程:每个参数都做了undefinednull双重校验。这是因为在Previewer环境中路由参数可能为空,如果不做校验,Previewer会直接崩溃。

步骤四:pushUrl 与 replaceUrl 选择策略

4.1 两种跳转方式的核心区别
pushUrl(压栈式跳转):
  路由栈变化:[A] -> pushUrl(B) -> [A, B]
  用户行为:点击返回 -> 回到A
  适用场景:用户从列表进入详情,需要返回列表

replaceUrl(替换式跳转):
  路由栈变化:[A] -> replaceUrl(B) -> [B]
  用户行为:点击返回 -> 回到A之前的页面
  适用场景:Tab切换、重定向到首页、登录后跳转
4.2 本应用中的使用统计与策略

通过分析全应用的16处路由跳转调用,可以总结出明确的选择策略:

pushUrl 的使用场景(8处):

// 场景1:列表 -> 详情(最常见)
// 首页 -> 科普详情
RouterUtil.pushUrl({ url: RouteUrls.TOPIC_DETAIL, params: { topicId: topic.id } }, 'Index');

// 科普列表 -> 科普详情
RouterUtil.pushUrl({ url: RouteUrls.TOPIC_DETAIL, params: { topicId: topic.id } }, 'Topics');

// 收藏列表 -> 科普详情
RouterUtil.pushUrl({ url: RouteUrls.TOPIC_DETAIL, params: { topicId: topic.id } }, 'Favorites');

// 历史记录 -> 科普详情
RouterUtil.pushUrl({ url: RouteUrls.TOPIC_DETAIL, params: { topicId: topicId } }, 'History');

// 场景2:列表 -> 详情(实验)
// 实验室列表 -> 实验详情
RouterUtil.pushUrl({ url: RouteUrls.LAB_DETAIL, params: { labId: experimentId } }, 'Lab');

// 场景3:功能入口 -> 子页面
// 个人中心 -> 成就页面
RouterUtil.pushUrl({ url: RouteUrls.ACHIEVEMENT }, 'Profile');

// 科普列表 -> 设置页面
RouterUtil.pushUrl({ url: RouteUrls.SETTINGS }, 'Topics');

// 个人中心 -> 其他功能页
RouterUtil.pushUrl({ url: item.pageUrl }, 'Profile');

replaceUrl 的使用场景(8处):

// 场景1:返回首页(答题结束后)
// 问答结果 -> 首页
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS }, 'Quiz');

// 每日挑战结果 -> 首页
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS }, 'DailyChallenge');

// 每日挑战空状态 -> 首页
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS }, 'DailyChallenge');

// 答题结果页 -> 首页
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS }, 'QuizResult');

// 文章不存在时 -> 首页
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS }, 'TopicDetail');

// 场景2:空状态引导去指定Tab
// 收藏为空 -> 首页(切到科普Tab)
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS, params: { tabIndex: 1 } }, 'Favorites');

// 历史记录为空 -> 首页(切到科普Tab)
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS, params: { tabIndex: 1 } }, 'History');

// 场景3:底部Tab栏切换
RouterUtil.replaceUrl({ url: tab.pageUrl }, 'BottomTabBar');
4.3 选择策略总结
选择决策树:

  需要用户能"返回"当前页面吗?
  ├── 是 -> pushUrl(压栈跳转)
  │   典型场景:
  │   - 列表页 -> 详情页
  │   - 功能入口 -> 子功能页
  │   - 任何需要"返回"的导航
  │
  └── 否 -> replaceUrl(替换跳转)
      典型场景:
      - 底部Tab栏切换
      - 答题结束返回首页
      - 文章不存在时回首页
      - 空状态引导去其他Tab
4.4 正反对比:错误选择导致的路由栈问题
// 场景:用户在首页点击"每日挑战",答完题后点击"返回首页"

// 正确做法:答题结束后用 replaceUrl
// 路由栈:[MainTabs] -> pushUrl(DailyChallenge) -> [MainTabs, DailyChallenge]
//         -> replaceUrl(MainTabs) -> [MainTabs]
// 用户点击返回:退出应用(栈底)
// 结果:路由栈干净,没有冗余页面

// 错误做法:答题结束后用 pushUrl
// 路由栈:[MainTabs] -> pushUrl(DailyChallenge) -> [MainTabs, DailyChallenge]
//         -> pushUrl(MainTabs) -> [MainTabs, DailyChallenge, MainTabs]
// 用户点击返回:回到 DailyChallenge 页面!然后又到 MainTabs...
// 结果:路由栈堆积,用户反复在页面间循环,体验极差
// 场景:底部Tab栏切换

// 正确做法:使用 replaceUrl
// 路由栈:[MainTabs(首页)] -> replaceUrl(MainTabs(科普)) -> [MainTabs(科普)]
// 用户切换Tab不会增加栈深度,永远只有一层

// 错误做法:使用 pushUrl
// 路由栈:[MainTabs] -> pushUrl(MainTabs) -> [MainTabs, MainTabs]
//         -> pushUrl(MainTabs) -> [MainTabs, MainTabs, MainTabs]
// 用户切换5次Tab后,按返回需要连续按5次才能退出
// 结果:路由栈无限增长,最终可能导致栈溢出

步骤五:日志追踪与异常处理机制

5.1 日志格式设计

RouterUtil的每个方法都通过Logger工具输出统一格式的日志。以pushUrl为例:

Logger.info(TAG, `${from ? `[${from}] ` : ''}pushUrl: ${options.url}`);

日志输出示例:

// 从首页跳转到科普详情页
[ScienceApp] [RouterUtil] [Index] pushUrl: pages/TopicDetail

// 从收藏页跳转到科普详情页
[ScienceApp] [RouterUtil] [Favorites] pushUrl: pages/TopicDetail

// 从每日挑战替换到首页
[ScienceApp] [RouterUtil] [DailyChallenge] replaceUrl: pages/MainTabs

// 返回操作
[ScienceApp] [RouterUtil] [TopicDetail] back

// 跳转失败
[ScienceApp] [RouterUtil] [Quiz] pushUrl failed: pages/NonExist, error: page not found

日志的价值:

  • **[Index]**:一眼就能看出是哪个页面发起的跳转
  • **pushUrl: pages/TopicDetail**:清楚知道跳转目标
  • **failed**:快速定位跳转失败的记录
5.2 异常处理策略
// 本应用的异常处理策略:吞掉异常 + 记录日志
try {
  await router.pushUrl(options);
} catch (error) {
  // 不抛出异常,只记录日志
  Logger.error(TAG, `pushUrl failed: ${options.url}`, error);
}

// 替代方案对比:

// 方案A:抛出异常,让调用方处理
// 优点:调用方可以自定义错误处理
// 缺点:每个调用点都要写 catch,容易遗漏

// 方案B(本应用采用):统一捕获,不向上抛出
// 优点:调用方代码简洁,不会因为路由异常崩溃
// 缺点:调用方无法感知跳转失败

// 为什么选择方案B?
// 儿童教育应用中,路由失败不应该弹错误弹窗
// 用户(6-12岁儿童)看到技术错误信息会造成困惑
// 静默失败 + 日志记录是最友好的方式
5.3 Logger 工具的配合

RouterUtil依赖Logger工具类输出日志。Logger封装了@kit.PerformanceAnalysisKithilog接口:

// 工具文件:entry/src/main/ets/utils/Logger.ets
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN = 0x0000;
const LOG_TAG = 'ScienceApp';

export class Logger {
  static info(tag: string, message: string): void {
    hilog.info(DOMAIN, LOG_TAG, '[%{public}s] %{public}s', tag, message);
  }

  static error(tag: string, message: string, error?: Error | string): void {
    if (error) {
      const errMsg = typeof error === 'string' ? error : error.message || 'unknown error';
      hilog.error(DOMAIN, LOG_TAG, '[%{public}s] %{public}s, error: %{public}s', tag, message, errMsg);
    } else {
      hilog.error(DOMAIN, LOG_TAG, '[%{public}s] %{public}s', tag, message);
    }
  }
}

Logger使用%{public}s格式化标记确保日志在发布版本中也能被hilog工具读取(非public标记的日志在发布版会被过滤)。


五、常见问题

Q1:为什么路由参数接收必须做防御性编程?

在HarmonyOS应用中,路由参数的获取有两个特殊场景需要处理:

场景一:Previewer环境

DevEco Studio的Previewer在预览单个页面时,不会经过正常的路由跳转流程,因此router.getParams()返回的参数为空。如果页面代码直接使用params.topicId而不做校验,Previewer会崩溃。

场景二:异常跳转

如果用户通过某种异常方式(如通知栏点击、系统回调等)直接进入某个页面,可能没有携带预期参数。

Q2:本应用采用了哪些防御性参数接收模式?

本应用针对不同的参数情况采用了两种防御模式:

模式一:空值检查 + 降级UI

适用于有明确业务数据的页面,如文章详情、实验详情。参数缺失时显示兜底UI:

// TopicDetail.ets:文章详情页的参数接收
aboutToAppear() {
  const params = RouterUtil.getParams() as Record<string, Object>;
  // 第一层防御:检查 params 对象是否存在
  if (params && params.topicId) {
    // 第二层防御:类型断言
    const topicId = params.topicId as number;
    // 第三层防御:查询结果可能为空
    const foundTopic = scienceData.getTopicById(topicId);
    if (foundTopic) {
      this.topic = foundTopic;
      // 正常业务逻辑
    }
  }
  // 如果任何一层防御失败,this.topic 保持 null
  // build() 方法中有 null 判断,显示"文章不存在"兜底页面
}

模式二:默认值 + 逐字段校验

适用于参数多、每个参数有独立默认值的页面,如答题结果页:

// QuizResult.ets:答题结果页的参数接收
@State correctCount: number = 0;  // 默认值为0,防止显示异常
@State totalCount: number = 0;
@State percentage: number = 0;
@State hasUsedTime: boolean = false;  // 标记是否传入了用时参数

aboutToAppear() {
  const params = RouterUtil.getParams() as Record<string, Object>;
  if (params) {
    // 每个参数独立校验,互不影响
    if (params.correctCount !== undefined && params.correctCount !== null) {
      this.correctCount = params.correctCount as number;
    }
    if (params.usedTime !== undefined && params.usedTime !== null) {
      this.usedTime = params.usedTime as number;
      this.hasUsedTime = true;  // 标记该参数有效,控制UI显示
    }
  }
  // 在 build() 中通过 hasUsedTime 控制是否显示"用时"卡片
}

Q3:有防御和无防御的代码差异是什么?

// 无防御的写法(危险)
aboutToAppear() {
  const params = RouterUtil.getParams();
  // 直接访问,Previewer 中 params 为空会崩溃
  const topicId = params.topicId as number;
  this.topic = scienceData.getTopicById(topicId);
}
// 风险:Previewer 崩溃、异常跳转白屏、无兜底UI

// 有防御的写法(本应用采用)
aboutToAppear() {
  const params = RouterUtil.getParams() as Record<string, Object>;
  if (params && params.topicId) {
    const topicId = params.topicId as number;
    const foundTopic = scienceData.getTopicById(topicId);
    if (foundTopic) {
      this.topic = foundTopic;
    }
  }
}
// 安全:Previewer 正常、异常跳转显示兜底UI、数据为空不崩溃

六、本章小结

8.1 RouterUtil 的三层设计总结

回顾本应用的路由跳转设计,可以归纳为"三层架构":

第一层:RouteUrls(路由地址层)
  "去哪里" —— 集中定义所有页面地址
  -> 下一篇(第65篇)已详细讲解

第二层:RouterParams + RouterOptions(参数定义层)
  "带什么" —— 统一参数类型,编译期检查
  -> 本篇重点讲解

第三层:RouterUtil(跳转执行层)
  "怎么去" —— 封装跳转API,统一异常处理和日志
  -> 本篇重点讲解

三层各司其职,相互配合:

// 一次完整的类型安全跳转,需要三层协同

// 第一层:提供地址
import { RouteUrls } from '../constants/RouteUrls';

// 第二层:提供类型
import { RouterUtil, RouterOptions, RouterParams } from '../utils/RouterUtil';

// 第三层:执行跳转
const params: RouterParams = { topicId: 1 };
const options: RouterOptions = { url: RouteUrls.TOPIC_DETAIL, params: params };
RouterUtil.pushUrl(options, 'Index');

8.2 实践规范汇总

路由跳转规范清单:

  地址管理:
    所有路由地址使用 RouteUrls 常量,禁止硬编码字符串

  参数传递:
    使用 RouterParams 接口定义参数,享受类型检查
    参数接收方必须做空值校验

  跳转方式选择:
    需要返回 -> pushUrl(列表进详情)
    不需要返回 -> replaceUrl(Tab切换、结果回首页)

  日志追踪:
    所有跳转必须传入 from 参数标识来源页面

  异常处理:
    统一由 RouterUtil 捕获,调用方不需要 try-catch

8.3 正反实践对比总结

// 完整的正反对比

// 1. 跳转调用
// 正确
RouterUtil.pushUrl({ url: RouteUrls.TOPIC_DETAIL, params: { topicId: 1 } }, 'Index');
// 错误
router.pushUrl({ url: 'pages/TopicDetail', params: { topicId: 1 } });
// 问题:绕过封装,无日志无异常处理,地址硬编码

// 2. 参数构建
// 正确
const params: RouterParams = { topicId: topic.id };
const options: RouterOptions = { url: RouteUrls.TOPIC_DETAIL, params: params };
// 错误
router.pushUrl({ url: 'pages/TopicDetail', params: { id: topic.id } as any });
// 问题:参数名与接收方不一致,使用 any 绕过类型检查

// 3. 参数接收
// 正确
const params = RouterUtil.getParams() as Record<string, Object>;
if (params && params.topicId) {
  const topicId = params.topicId as number;
  // 安全使用 topicId
}
// 错误
const params = RouterUtil.getParams();
const id = params.topicId;
// 问题:params 可能为 null,直接访问会崩溃

// 4. 跳转方式选择
// 正确:答题结束返回首页
RouterUtil.replaceUrl({ url: RouteUrls.MAIN_TABS }, 'Quiz');
// 错误
RouterUtil.pushUrl({ url: RouteUrls.MAIN_TABS }, 'Quiz');
// 问题:路由栈堆积,用户按返回会回到答题页

8.4 适用场景与展望

本应用的RouterUtil+RouterParams设计特别适合以下场景:

  • 页面数量多(10个以上):统一的跳转封装减少重复代码
  • 参数传递频繁:类型安全的参数接口避免运行时错误
  • 面向儿童的应用:异常静默处理确保用户体验不受技术错误影响
  • 需要问题排查:统一的日志格式加速开发调试

在后续版本中,可以考虑为RouterParams增加更细粒度的参数类型约束——为每个目标页面定义专属的参数接口,实现"发送方和接收方的参数类型双向校验"。当前的统一接口设计是务实的选择,在类型安全和开发效率之间取得了平衡。

本篇与第65篇共同构成了本应用路由管理的完整技术方案:第65篇解决"去哪里"的问题(RouteUrls中心化管理),本篇解决"怎么去"和"带什么"的问题(RouterUtil封装 + RouterParams类型安全)。


七、相关链接

  • 源码仓库WonderSciencePark
  • 相关文章:第65篇《路由地址中心化管理》(RouteUrls 设计)
  • 下一篇预告:后续将继续分享《奇妙科学乐园》HarmonyOS 实战开发技巧,敬请关注

🔗 相关链接

Logo

AtomGit AI 社区提供模型库、数据集、Agent、Token等资源

更多推荐