做鸿蒙开发的朋友,应该对 HMRouter 不陌生了。作为一个基于 Navigation 体系的路由框架,它在页面导航、参数传递、生命周期管理上,确实比早期裸用 router 的能力完整不少。但项目一旦跑过两三个迭代,你会发现一个尴尬的现象:直接用 HMRouter 原始 API 写业务,页面 URL、参数 key、跳转逻辑散落在各个调用点,时间一长并不比最早用router.pushUrl好维护到哪里去。这也是这个系列第三篇想重点聊的事情——在 HMRouter 之上再做一层业务封装,把导航入口收敛成统一出口,让业务方只关心“去哪儿、带什么、拿到什么回来”,而不是天天跟路由框架本身的细节较劲。
这篇教程我默认你已经对 HMRouter 的基本用法有了解,至少跑通过页面跳转。如果没有,建议先回头看一下系列第一篇和第二篇,把注解、路由表生成、基础 API 过一遍。今天要讲的是实战项目中“再往前走一步”的做法:如何把 HMRouter 封装得既灵活又克制,既能解决重复代码问题,又不至于把简单跳转变成重型流程。适合正在做鸿蒙应用重构、或者准备在项目里引入 HMRouter 团队的开发者参考。
1. 为什么路由框架之上还要再做一层封装
很多人的第一反应是:HMRouter 已经帮我把页面 URL 统一管理了,注解一标,路由表自动生成,跳转只要一行pushUrl,还有什么好封装的?这个问题我很理解,但我现在的看法是:框架解决的是“从 A 页面到 B 页面怎么走”的问题,而业务工程里大量重复的是“这次跳转要带什么参数、怎么校验、从哪里来、要去哪”的上下文逻辑,这两件事不该搅在一起。
1.1 直接用裸 HMRouter 写业务是什么体验
先看一个典型场景:订单列表页点击一条订单,跳到订单详情页。用 HMRouter 原始写法大概是这样的:
let param = new HMRouterParam() .putParam('orderId', order.orderId) .putParam('source', 'order_list') .putParam('needRefresh', true) HMRouterMgr.getInstance().getRouter() .pushUrl({ url: 'OrderDetailPage', param: param })这段代码本身不算难看。但放到真实项目里,问题会出现在几个地方:
第一,订单详情页收到参数后,要从param.getParam('orderId')再手工做强转,强转失败页面直接白屏,这种错误往往到了测试后期才暴露。第二,跳转逻辑散落在各个页面里,同一个OrderDetailPage,可能被首页、订单列表、消息中心三处调用,每一处都自己拼 key,一旦字段名改掉,全局搜索才能找齐。第三,很多跳转前都有前置逻辑,比如要登录、要校验权限、要做埋点,这些逻辑如果每处各写一遍,早晚会出现漏改的情况。
更麻烦的是返回值。HMRouter 支持跳转后的结果回调,但如果你每一次都现场定义回调、现场处理结果,代码会非常散。页面 A 等结果刷新列表,页面 B 等结果更新状态,同一个详情页返回的数据格式稍微不一样,各页面处理逻辑就分叉了。
1.2 封装层到底要解决哪些问题
我在项目里做这层封装时,给自己定了四个目标,后来也一直拿这四个目标来约束封装边界:
- 统一入口:所有页面跳转必须走同一个方法,禁止任何业务代码直接 new
HMRouterParam或者直接调pushUrl。这样一旦框架升级、API 调整,只改一处。 - 参数结构化管理:每个页面定义一个独立的参数类,参数名、类型、默认值都写在类定义里,编译器能帮你检查,而不是靠字符串 key 靠默契。
- 返回值标准化:设计统一的回调模型,页面返回什么数据结构、如何清空返回标记、如何区分“正常返回”和“取消返回”,全项目一套约定。
- 拦截器收敛:登录校验、权限判断、网络状态检查、埋点上报,全部收敛到路由拦截链里,业务页面完全不用关心。
这四个目标听起来很“重”,但落到代码上其实并不复杂。核心思路是把 HMRouter 当底层能力,在上面建一个项目自己的 DSL(领域特定语言),让跳转这件事变得“说人话”。
1.3 封装层的边界:什么该封装,什么不该碰
这里要泼一盆冷水:很多人一封装就容易过度设计,把路由层搞成一个万能框架,最后业务没简化,反而多了一堆抽象概念。我给自己定的原则是:路由层只做与“页面导航”直接相关的事,业务逻辑不塞进来。
不该封装的东西包括:页面内部的数据请求逻辑、弹窗交互逻辑、业务状态管理。这些内容应该留在页面自身或者业务公共服务里,放进路由层只会让路由层变成一个大杂烩。该封装的是:跳转参数、返回结果、拦截器、页面 URL 常量、转场选项。说白了,路由层就是“导航地图 + 门禁”,不是“业务中台”。
我当时是这么判断的:如果一段代码换了跳转目标之后还要跟着改,那它就不属于路由层;如果一段代码无论跳哪个页面都需要执行,比如登录校验、日志埋点,那它大概率应该进拦截器。
2. HMRouter 核心机制再梳理:为封装打基础
做封装之前,最好把 HMRouter 的几个底层机制想明白。不是要你去看源码,而是要理解它“为什么会这样工作”,否则封装过程中遇到路由表不生成、页面找不到、参数丢失这些问题,排查起来会很吃力。
2.1 路由表是怎么生成的
HMRouter 最方便的一点是,页面只要打上@HMRouter注解,编译期就会自动生成路由表,不需要手动注册。原理是 DevEco Studio 构建的时候,注解处理器会扫描所有标注了@HMRouter的组件,生成一个路由配置文件,运行时统一注册到 Navigation 上。
这里有一个实际操作中非常容易踩的点:生成的路由表文件是编译中间产物,你在工程源码目录里看不到。如果配置有问题,它可能静默失败。排查的时候要去 build 目录下找生成的路由配置文件,确认你的页面 URL 是不是在表里。这个我在第五部分会详细讲。
路由表的 URL 有两种指定方式,一种是直接写页面组件的完整类名路径,一种是给@HMRouter注解指定一个自定义pageUrl。我建议项目里统一使用自定义pageUrl,因为类名一旦重构改名,自动生成的 URL 跟着变,老的线上版本跳转会直接失效。自定义 URL 等于给了页面一个稳定的身份证号,重构类名不影响路由稳定性。
@HMRouter({ pageUrl: 'OrderDetailPage' }) @Component export struct OrderDetailPage { ... }2.2 三套关键 API 必须分清
HMRouter 的能力大致分成三块,封装的时候要心里有数:
第一块是页面注册与路由表生成,上面说过了,对应@HMRouter注解和编译期处理器。第二块是路由管理器,核心是HMRouterMgr.getInstance().getRouter(),拿到 router 实例后可以调用pushUrl、replaceUrl、pop等方法。第三块是页面生命周期归属,HMRouter 在鸿蒙的 Navigation 体系中,页面本质是NavDestination,所以@NavDestination装饰器、页面自身的生命周期回调(比如aboutToAppear、onPageShow、onPageHide)都用得上。
封装的时候,业务代码应该只看到第一块和第三块的一部分,第二块的路由管理器调用要收敛到封装层内部,不要直接暴露给页面。这样以后 HMRouter 升级换 API,受影响的范围可控。
2.3 为什么我坚持“参数要独立定义,不要现用现传”
HMRouter 原生的参数模型是 key-value 形式,putParam('orderId', xxx),页面取的时候getParam('orderId')。这本身没问题,但工程规模一大,字符串 key 就成了隐患。你永远不知道调用方传的orderId和接收方读的orderId是不是同一个字段,尤其项目里如果有人再包一层拼参数,错一个字母,编译器不会提示,测试也不一定覆盖到。
我后来统一改成:每个页面定义专属的参数类,跳转时 new 一个实例,把字段赋值好,整个对象传给路由层;页面接收时通过泛型方法安全解析。这样字段名、类型、默认值都集中在参数类里,跳转前和接收后都有类型检查,比一坨 key-value 稳得多。实际做下来,这类改动大概帮我们节约了非常多的联调时间。
3. 实用封装:RouterService 统一出口设计
下面进入到正题,看具体怎么封装。我给出的代码是基于当前使用的一个精简方案,不是唯一答案,但思路可以复用。核心是一个RouterService类、一个RouterTable常量类、一个BaseRouteParams基类,加一个结果回调接口。
3.1 设计目标:把路由入口收敛到一个类
我先定义一个RouterTable,集中管理所有页面 URL,避免魔法字符串散落各处:
export class RouterTable { static readonly ORDER_DETAIL = 'OrderDetailPage' static readonly PRODUCT_DETAIL = 'ProductDetailPage' static readonly LOGIN = 'LoginPage' static readonly WEB_VIEW = 'WebViewPage' ... }再定义BaseRouteParams和结果回调解类型:
export class BaseRouteParams { entry?: string // 公共埋点字段,比如来源页面 constructor(entry?: string) { this.entry = entry } } export type RouteResultCallback<T> = (result?: T | null) => void有了这两个基础类型,每个页面就可以定义自己的参数和返回值类。比如订单详情页:
export class OrderDetailParams extends BaseRouteParams { orderId: string = '' needRefresh: boolean = false constructor(orderId: string, entry: string) { super(entry) this.orderId = orderId } } export class OrderDetailResult { isFavorite: boolean = false remark?: string constructor(isFavorite: boolean, remark?: string) { this.isFavorite = isFavorite this.remark = remark } }3.2 核心 RouterService 实现
接下来是RouterService。它做的事情很纯粹:接收参数对象、目标页 URL、结果回调,内部调用 HMRouter,并且统一追加公共参数。
export class RouterService { private static readonly TAG = 'RouterService' static push<T>( pageUrl: string, param?: BaseRouteParams, onResult?: RouteResultCallback<T> ): void { let hmRouterParam = new HMRouterParam() if (param) { // 统一塞入 routeParams 字段,接收方通过基类解析 hmRouterParam.putParam('routeParams', param) } // 公共参数:发起页面时间戳,用于排查链路 hmRouterParam.putParam('_nav_ts', Date.now()) let router = HMRouterMgr.getInstance().getRouter() router.pushUrl({ url: pageUrl, param: hmRouterParam, onResult: (result: any) => { if (onResult) { let typedResult = result as T | null onResult(typedResult) } } }) } static replace<T>( pageUrl: string, param?: BaseRouteParams, onResult?: RouteResultCallback<T> ): void { // 与 push 类似,内部调用 replaceUrl } static pop(result?: object): void { let router = HMRouterMgr.getInstance().getRouter() if (result) { router.pop(result) // 具体 API 以当前 HMRouter 版本为准 } else { router.pop() } } }这里有一个关键决策:所有参数都塞给一个固定的字段routeParams,而不是把一个个 key 平铺在 HMRouter 的参数对象里。优点是接收方只需要解析这一个字段,不需要关心调用方塞了多少个自定义 key。页面接收参数时,通过一个工具方法统一解析:
export function parseRouteParams<T extends BaseRouteParams>(param: HMRouterParam | undefined | null): T | null { if (!param) { return null } let raw = param.getParam('routeParams') if (raw == null) { return null } return raw as T }这样封装之后,跳转代码长这样:
RouterService.push<OrderDetailResult>( RouterTable.ORDER_DETAIL, new OrderDetailParams(order.orderId, 'order_list'), (result) => { if (result?.isFavorite) { // 更新列表收藏状态 } } )说实话,第一次看到这段代码的人会觉得它比裸写pushUrl多了一点代码量。但好处是:调用方拿到OrderDetailParams就知道要传什么,不用去详情页翻代码;接收方拿到parseRouteParams<OrderDetailParams>()就知道里面有什么,不用靠猜。
3.3 链式调用和更复杂的跳转场景
有些页面跳转需要带转场动画、需要设置单例模式、需要判断是否已经存在该页面。这些用 HMRouter 原生 API 也能做,但每次写就比较啰嗦。我设计了一个简单的RouterBuilder链式 API,专门应对这类场景,普通跳转走RouterService.push就够了,不需要动用链式。
export class RouterBuilder<T> { private pageUrl: string = '' private param?: BaseRouteParams private onResult?: RouteResultCallback<T> private isReplace: boolean = false private isSingleton: boolean = false static to<T>(pageUrl: string): RouterBuilder<T> { let builder = new RouterBuilder<T>() builder.pageUrl = pageUrl return builder } withParams(param: BaseRouteParams): RouterBuilder<T> { this.param = param return this } withResult(callback: RouteResultCallback<T>): RouterBuilder<T> { this.onResult = callback return this } asSingleton(): RouterBuilder<T> { this.isSingleton = true return this } useReplace(): RouterBuilder<T> { this.isReplace = true return this } go(): void { if (this.isReplace) { RouterService.replace(this.pageUrl, this.param, this.onResult) } else { RouterService.push(this.pageUrl, this.param, this.onResult) } } }用法:
RouterBuilder.to<OrderDetailResult>(RouterTable.ORDER_DETAIL) .withParams(new OrderDetailParams(id, 'home')) .withResult((r) => { ... }) .go()这种写法在跳转参数多、需要标注语义的场景下很舒服,代码读起来像自然语言。但要注意,不能所有跳转都强制用 Builder,简单跳转用 Builder 反而显得很重。我的策略是:默认用RouterService.push,只有涉及多选项时才用 Builder。
3.4 全局拦截器:登录态和权限校验统一处理
在实际业务中,很多页面是不允许未登录用户进入的,比如订单详情、个人中心、支付页。如果每个页面在aboutToAppear里都自己判断登录态,代码会非常重复,而且漏掉一个页面就出现越权访问。
HMRouter 支持全局路由拦截器,这功能特别适合做统一校验。大致思路是:实现一个拦截器接口,在路由跳转前判断目标页面是否需要登录,如果未登录则拦截,并引导去登录页。
export class AuthInterceptor implements RouterInterceptor { onBeforeRedirect(routeInfo: RouteInfo): boolean { // 返回 true 放行,返回 false 拦截 let needAuthPages = [ RouterTable.ORDER_DETAIL, RouterTable.PAY, RouterTable.MINE ] if (needAuthPages.includes(routeInfo.url) && !AuthService.isLogin()) { RouterService.push(RouterTable.LOGIN) return false } return true } }这里要特别提醒一个坑:拦截器里的跳转不能再触发同一个拦截器,否则可能死循环。比如未登录用户访问订单详情被拦截,然后跳登录页,如果登录页也走同一套拦截逻辑,就会无限循环。我的解法是登录页放行,不做登录拦截;更严谨的做法是给跳转参数加一个标记fromInterceptor = true,拦截器里检测到这个标记直接放行。
除了登录拦截,我还把统一埋点放在了拦截器里。每次跳转都会带来源页面entry和时间戳_nav_ts,拦截器里统一记录页面访问日志,不用在每个页面里面埋了。
4. 实操:以商品详情跳转为例完整跑一遍
理论说再多,不如直接看一个完整的实操案例。我选一个最常见的场景:首页商品列表点击商品卡片,跳到商品详情页,用户在详情页里操作后返回首页,首页根据结果刷新部分 UI。这个场景涵盖了参数传递、返回回调、类型化封装三件事,非常典型。
4.1 工程结构与前置准备
我假设你的工程已经接入 HMRouter,且入口已经初始化好 Navigation 和路由表。如果还没有,先确认这几件事:
oh-package.json5里已经引入 HMRouter 依赖。- module 的构建配置里打开了注解处理器。
- 入口页面(比如 Index 或者 MainPage)挂载了 HMRouter 需要的 Navigation 容器。
- 真机或模拟器能跑通一个最简单的
@HMRouter页面跳转。
工程结构大致如下:
entry/src/main/ets/ common/ router/ RouterService.ets RouterTable.ets BaseRouteParams.ets RouteResult.ets parseRouteParams.ets pages/ HomePage.ets ProductDetailPage.ets model/ Product.ets4.2 三个核心文件的关键代码
第一步,定义跳转参数类。商品详情页需要商品 ID、来源入口、是否自动加入购物车等字段:
// model/ProductDetailParams.ets export class ProductDetailParams extends BaseRouteParams { productId: string = '' autoAddCart: boolean = false constructor(productId: string, entry: string, autoAddCart: boolean) { super(entry) this.productId = productId this.autoAddCart = autoAddCart } }第二步,定义返回结果类。用户可能改了收藏状态、也可能把商品加购了,这些状态要带回首页:
// model/ProductDetailResult.ets export class ProductDetailResult { isFavorite: boolean = false addCartCount: number = 0 constructor(isFavorite: boolean, addCartCount: number) { this.isFavorite = isFavorite this.addCartCount = addCartCount } }第三步,在首页发起跳转。首页拿到商品卡片数据后,组装参数对象,调用RouterService.push,并在回调里处理返回结果:
// pages/HomePage.ets function onProductClick(product: Product) { RouterService.push<ProductDetailResult>( RouterTable.PRODUCT_DETAIL, new ProductDetailParams(product.id, 'home_page', false), (result) => { if (result == null) { // 用户直接返回,没有操作,不需要处理 return } if (result.isFavorite) { this.updateFavorite(product.id) } if (result.addCartCount > 0) { this.updateCartBadge(result.addCartCount) } } ) }第四步,详情页页面自身。在aboutToAppear阶段解析参数,展示数据;用户操作后返回时,构造结果对象:
// pages/ProductDetailPage.ets @HMRouter({ pageUrl: 'ProductDetailPage' }) @Component export struct ProductDetailPage { private params: ProductDetailParams | null = null aboutToAppear(): void { let navParam = this.getRouterParam() this.params = parseRouteParams<ProductDetailParams>(navParam) if (this.params) { this.loadProduct(this.params.productId) } } onBackPress(): boolean { let result = new ProductDetailResult(this.isFavorite, this.addCartCount) RouterService.pop(result) return true } }这里要解释一下为什么返回时用onBackPress而不是在 UI 按钮里直接pop。因为鸿蒙手势返回(侧滑返回)也要能带出结果,只处理按钮不够,必须把onBackPress这个系统回调也接上。返回结果统一走RouterService.pop(result),这样首页回调一定能收到。
4.3 运行时观察与性能细节
整个流程跑通之后,有几个细节值得关注:
第一,parseRouteParams的时机。我建议在aboutToAppear里解析,不要拖到onPageShow。因为aboutToAppear适合做数据初始化,越早解析越早触发网络请求,页面渲染不等待。
第二,参数类里的字段尽量用纯数据类型,不要塞函数或者复杂对象。路由参数在跨页面传递时本质是序列化传递,传函数要么失效,要么会有奇怪的报错。我在项目里就吃过亏,把一个闭包塞进参数,结果页面压后台再恢复后闭包变成 null,排查了很久。
第三,返回结果对象不要复用。每次pop都 new 一个新的结果对象出来,避免页面 A 持有结果对象引用后,详情页又改了同一对象导致 UI 错乱。这是个很小的习惯,但能避免很多难以复现的 bug。
第四,如果首页在跳转详情后,详情页又跳到支付页,支付完成再一层层返回,这时候结果回调链路会很长。我的做法是:中间页透传结果,最底层页面返回时统一把最终结果交给最初发起跳转的页面。这里不展开讲,但封装设计的时候要留好透传的入口,别到时候只能改代码重来。
5. 常见问题与排查技巧实录
封装改造过程中,我自己踩过不少坑,团队里其他同学也遇到类似问题。这一部分把高频问题整理成速查表,也补充一些排查思路。
5.1 编译后路由表没有生成
这是接入 HMRouter 最常遇到的第一道坎。现象是:页面方法都写好了,运行时就报页面找不到。排查方向:
- 检查是否引入了注解处理器依赖,并且是
annotationProcessor或对应 compileOnly 配置,这个配置不对,注解根本不会处理。 - 检查
@HMRouter是不是标在了@Component装饰的 struct 上,漏标或者标错位置,路由表里自然没有。 - 执行一次 Clean Build,然后去 build 目录下找生成的路由配置文件,看你的页面 URL 是否在里面。如果不在,说明注解处理器没扫到你的类,重点检查模块路径和依赖关系。
我自己的经验是,90% 的路由表问题都是构建缓存和依赖配置问题,而不是代码逻辑问题。所以接入早期,先建一个最简单的 demo 页面跑通全链路,再铺开到业务页面,能省很多排查时间。
5.2 页面跳转时报“目标页面不存在”
这个错一般不是 HMRouter 的问题,而是 URL 对不上。常见的原因有:
- 页面类名改了,但调用方还在用旧的类名 URL。
- 自定义
pageUrl和RouterTable里写的不一致,大小写或者多了个空格。 - 跳转时
url传的是类路径字符串,但注解里配的是自定义 URL,两者没匹配上。
我的建议是:用RouterTable常量中心化管理 URL,任何地方不直接写字符串。这样出现“页面不存在”时,先看RouterTable和页面注解是不是一一对应,基本能定位问题。
另外,如果页面在动态化或者懒加载模块里,要确认路由表是否把子模块的页面也扫进来了。多 module 工程里,这个坑非常常见,子模块的注解处理器没生效,运行时主模块跳子模块页面就会失败。
5.3 参数取出来类型对不上
页面 A 传了一个orderId,页面 B 里getParam('orderId')拿到之后强转成 string,结果崩溃或者拿到一个奇怪对象。原因有两个:一是传参时 put 的不是你想要的类型,二是强转时目标类型写错。
用我前面介绍的parseRouteParams方案后,这类问题大幅减少,因为类型是编译期锁定的。但如果你还没有改造成参数类,临时排查时可以打个日志看getParam返回的真实类型是什么。在鸿蒙开发里,很多参数经过跨页面传递后会变成序列化后的对象,原始类型信息可能丢失,这也是我强烈建议使用结构化参数类的原因。
5.4 转场动画异常或页面残留
页面跳转本来正常,加了动画参数后出现页面闪一下、或者返回时上一个页面残留半帧。这在低端机型上比较明显。经验是:转场动效参数不要在每次跳转里现写,而是封装到RouterService的统一配置里,全局只维护一套转场参数。如果某个页面需要特殊转场,单独传覆盖参数即可。
页面残留还有一个可能是单例页面使用不当。@HMRouter注解支持配置单例模式,单例页面再次进入时不会重新创建,而是回到已有实例。如果你的页面内部状态依赖aboutToAppear重新初始化,单例模式下这个回调可能不会被调用,就会出现“页面打开了但是数据没刷新”的问题。此时要关注页面是否命中单例缓存,必要时在onPageShow里处理刷新逻辑。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查手段 |
|---|---|---|
| 路由表没生成 | 注解处理器配置缺失 | 检查构建依赖,Clean 后查 build 目录生成文件 |
| 跳转报页面不存在 | URL 不一致或大小写问题 | 对照 RouterTable 与页面注解 |
| 收到参数类型不对 | key-value 强转失败 | 使用结构化参数类,日志打印真实类型 |
| 页面残留半帧 | 转场参数不合理 | 统一转场配置,低端机降低动画复杂度 |
| 页面打开数据不刷新 | 单例页面命中缓存 | 在 onPageShow 里处理刷新逻辑 |
| 拦截器死循环 | 拦截器内部跳转又触发拦截 | 放行标记或登录页不拦截 |
这个表格不是完整手册,但覆盖了我遇到的高频问题。日常开发中还有一个通用技巧:把 HMRouter 的运行日志打开,跳转前后会打印路由信息,定位问题时先看日志,比自己猜要快得多。
封装这件事,我的体会是千万不要为了“优雅”去设计过度。最早我搭过一个非常庞大的路由中间层,拦截器、参数校验、自动埋点、路由回溯全做了,结果团队用下来觉得跳个页面像串了一堆流程,反而影响了开发效率。后来砍掉了大半,只保留统一入口、结构化参数、结果回调和拦截器四件事,清爽很多。真正能在项目里长久活下来的封装,不是功能最全的,而是最贴合团队协作习惯的。希望这篇教程能帮你在做 HMRouter 封装时少走一些弯路,如果你有自己的封装思路,也欢迎在实际项目里多试几种方案再定下来。