Navigation 是鸿蒙官方点名的路由方案,可健康菜谱助手接入它时,官方示例没有回答的问题比给的多:路由表里塞几十个页面怎么管理、跨模块注册路由表怎么避免冲突、NavDestination 的生命周期和页面栈怎么配合、动态路由(参数决定目标页)怎么落地。这篇复盘 Navigation 路由表从接入到生产验证的全过程:路由表的三种组织方式、注册冲突的治理、转场与生命周期、以及一套固定跑的回归清单。
一、从 router 到 Navigation:一次被动的迁移
项目最初用 router 全家桶,迁移 Navigation 的直接动因有三:router 的页面栈信息拿不到(pushPath 后栈里有什么没有官方接口,自定义返回逻辑全靠猜);转场动画定制受限;系统后续的新特性(预测性返回、系统转场)都优先给 Navigation。迁移策略不是重写——先在首页容器换成 Navigation,原有 router 页面逐个包成 NavDestination,用两个迭代完成平滑过渡。这次被动迁移给的最大教训:路由方案是应用的地基,越晚换成本越高,新项目直接 Navigation 起步,不要留历史包袱。
| 对比项 | router | Navigation |
|---|---|---|
| 页面栈可见性 | 无官方接口 | 栈内元素可查可管理 |
| 转场定制 | 受限 | 系统级转场+自定义动画 |
| 系统新特性 | 不再扩展 | 预测性返回/系统转场全支持 |
| 表驱动路由 | 无 | route_map 声明式注册 |
| 官方态度 | 维护模式 | 推荐方案 |
二、路由表的三种组织方式与最终选择
Navigation 的路由注册有三种方式。其一,系统路由表(route_map):在 module.json5 里声明 routerMap 资源,每个页面一个 json5 文件声明 buildFunction——官方标准,但每个页面一个文件,三十个页面就是三十个文件,跨模块场景还有归属问题。其二,代码注册(NavPathStack 的 setInterception 或手动 map):灵活但散落,谁都能往里塞,不受控。其三,集中注册表:一个路由文件统一导出 Map,启动时灌进 NavPathStack。项目最终用系统路由表为主(标准页面,享受工具链检查)+集中注册表为辅(动态页面、参数化路由),纯粹的代码注册只留给调试场景。选择依据:可被工具链静态检查的注册方式优先,错误在编译期暴露比运行期崩溃便宜得多。
// 系统路由表:src/main/resources/base/profile/route_map.json { "routerMap": [ { "name": "RecipeDetail", "pageSourceFile": "src/main/ets/pages/RecipeDetail.ets", "buildFunction": "recipeDetailBuilder" } ] }// 页面侧:builder 函数负责参数解析 @Builder export function recipeDetailBuilder(params: object) { RecipeDetailPage({ recipeId: (params as RouteParams).recipeId }) } // 跳转方 this.stack.pushPathByName('RecipeDetail', { recipeId: '123' })三、跨模块路由:注册冲突的治理
应用拆了三个 HAR 模块(公共组件、菜谱域、计划域)后,路由注册开始打架:两个模块各自往 route_map 里声明了同名页面(注册冲突,后装的覆盖先装的,谁先谁后全看打包顺序),定位起来非常恶心。治理规则三条:一、路由名带模块前缀(rec_detail、plan_edit),从命名上杜绝撞名;二、公共页面(登录、设置)只允许在入口模块注册,域内页面只允许在域模块注册,注册权写进模块的 README 当约束;三、CI 里加一步 route_map 合并检查,发现同名条目直接失败。三条规则落地后,注册冲突从"每月一次"到零。跨模块路由的另一个坑:子模块的 buildFunction 引用了模块内组件,但 route_map 的 pageSourceFile 路径按相对路径找,跨包时路径层级写错只会静默失败——打包后在真机上点一遍所有路由,是发布前的固定动作。
四、NavPathStack:栈操作的完整心智模型
NavPathStack 是 Navigation 的栈管理器,pushPathByName、pop、replacePath、clear、removeByName、getAllPathName 构成完整操作集。工程里要定的心智规则:返回永远用 pop,不手写返回逻辑;替换语义(登录成功后清登录页)用 replacePath;登出清栈用 popToName 回根再 push 首页,避免 clear 之后栈空渲染问题。最容易被误用的两个:一是 push 带参数后,目标页通过 onReady 拿参数,时序上晚于 aboutToAppear——参数初始化必须放 onReady,放 aboutToAppear 里第一次进来是空;二是 pop 可以带 result 回传给来源页(onPop 回调),比事件总线干净,这个能力很多团队不知道,还在用全局状态回传。
// 参数与回传的标准姿势 // 目标页:参数在 onReady 里接 struct RecipeDetailPage { pathStack: NavPathStack = new NavPathStack() recipeId: string = '' build() { NavDestination() { /* ... */ } .onReady((ctx: NavDestinationContext) => { this.pathStack = ctx.pathStack this.recipeId = (ctx.pathInfo.param as RouteParams).recipeId this.load() }) } } // 来源页:pop 回传结果 this.stack.pop(true, { saved: true }) // push 方在 onPop 里收 .onClick(() => this.stack.pushPathByName('Editor', param, (info: PopInfo) => { const saved = (info.info as ResultParam).saved if (saved) this.reload() }))五、动态路由:参数决定目标的落地
固定路由表覆盖不了的场景:深链进入(外部传来的目标不确定)、A/B 配置的落地页、权限分级的页面(未登录跳登录,已登录跳目标)。集中注册表方案里加一层解析器:路由描述串(plan/123、rec_detail?id=9)先过权限中间件(未登录且目标受限时替换为登录路由+回跳参数),再过配置开关(实验页路由按开关指向不同实现),最后才查表 push。解析器是纯函数,单测覆盖三条分支即可。这个设计上线后接住了两个新需求:分享深链落地(外链串解析)和会员页替换(开关切页面实现),路由层零改动。
// 动态路由解析器:纯函数,三分支可测 function resolveRoute(stack: NavPathStack, raw: string, login: boolean) { const [name, query] = raw.split('?') if (PROTECTED.includes(name) && !login) { stack.pushPathByName('Login', { redirect: raw }) // 受限页先登录 return } const target = FLAG_MAP[name] ?? name // 开关替换实现 stack.pushPathByName(target, parseQuery(query)) }六、转场与生命周期:NavDestination 的时序表
NavDestination 的生命周期时序是排查"进页面就崩"类问题的关键:created → aboutToAppear → onWillShow → onShown(转场完成) → [用户操作] → onWillHide → onHidden → aboutToDisappear → onDestroy。两个工程要点:其一,数据加载放 onWillShow 而不是 aboutToAppear——onWillShow 在转场动画开始时触发,加载与动画并行,页面出现时骨架已有数据;放 aboutToAppear 里会拖慢转场首帧。其二,页面栈内被覆盖的页面收到 onHidden 后定时器、动画要主动停,onShown 再恢复——有次统计页的刷新定时器在页面被覆盖后还在跑,后台日志刷了半小时。生命周期规则写成团队 checklist,code review 时对照检查。
| 时机 | 该做什么 | 不该做什么 |
|---|---|---|
| aboutToAppear | 轻量初始化 | 不发网络请求 |
| onWillShow | 启动数据加载 | — |
| onShown | 恢复定时器/动画 | — |
| onHidden | 停定时器/动画 | 继续后台刷新 |
| onPop | 接收回传结果 | — |
七、真实事故:路由表热更新后的空指针
一次发版后部分用户点任何页面都没反应,复现条件蹊跷:只发生在从深链冷启的场景。排查半天,根因是路由表初始化时序——集中注册表的灌入放在 EntryAbility 的 onCreate,而深链处理在 onWindowStageCreate 之后更早触发,注册表还没灌好,pushPathByName 查不到路由静默失败。修复:注册表灌入提前到 AbilityStage.onCreate(比 EntryAbility 更早),深链处理改成等注册表 ready(单例 Promise),两个时序都对齐后问题消失。这类初始化时序问题日志里没有任何报错,只能靠"哪条链路先跑"的推演——把路由注册时机写进架构文档,是那次事故留下的资产。
// 注册表 ready 化:深链等待灌入完成 class RouteRegistry { private readyPromise: Promise<void> | null = null init() { this.readyPromise = this.loadAll() } async ensureReady() { await this.readyPromise } // 深链入口先 await }八、返回与预测性返回:系统手势的配合
Navigation 原生支持预测性返回手势(侧滑预览上一页),但要拿到完整体验需要配合:页面内自定义返回拦截用 onWillPop(编辑页有未保存内容时弹确认),返回手势与拦截的协作系统已处理好,不要自己接管手势;返回动画由系统转场提供,页面别再叠自定义退出动画。工程红线:禁止在返回路径上做"偷偷再 push 一次"的花活(有的团队用 push 替换 pop 模拟返回来保状态),栈会越叠越深,最终把用户困在应用里出不去——真机测试专项里加了一条"任意页面连按返回键十次必须能退出应用"。
九、生产验证:路由回归的固定清单
Navigation 接入后的回归清单固定六项,发版前真机跑一遍:一、全路由遍历——脚本读 route_map 逐条 push,验证三十个路由全部可达(防注册丢失);二、深链矩阵——冷启/热启 × 受限/非受限 × 登录/未登录,八种组合落点全对;三、栈深度压测——连续 push 十层后逐层 pop,验证内存与转场无异常;四、回传链路——编辑保存回传、选择页回传两条链路值正确;五、预测性返回——侧滑手势预览流畅、onWillPop 拦截生效;六、返回退出——任意页面返回键十连击必出应用。清单配 hilog 断言脚本,半自动跑,三十分钟收工。路由是应用主动脉,回归优先级高于任何业务功能。
十、总结:路由表的四条军规
收束成四条:一、注册方式可检查优先——系统路由表为主、集中注册为辅,命名带模块前缀防冲突,CI 拦同名;二、参数走 onReady、回传走 onPop,不碰全局状态中转;三、初始化时序前置——注册表灌入早于一切深链入口,ready 化兜底;四、返回交还给系统,onWillPop 做拦截,禁止 push 冒充 pop。Navigation 不是比 router"更新"而已,它把路由从散落的调用变成可声明、可检查、可测试的表——地基换对了,上面的预测性返回、系统转场全是白捡的。
参考资料:
- 华为开发者:Navigation 与 NavPathStack
- 华为开发者:系统路由表 route_map
- 华为开发者:NavDestination 生命周期
#鸿蒙 #HarmonyOS 7(API 26)