引言
在 HarmonyOS NEXT 里做页面跳转,有两条并行的路:一条是 @ohos.router 提供的全局路由 API,写法轻快、接近小程序的 navigateTo;另一条是 ArkUI 的 Navigation 容器组件配合 NavPathStack,官方从 API 10 起持续加码,API 11 补上系统路由表与自定义转场,API 12 又加了 setInterception 拦截器与 pushDestination 的异步失败语义。
两条路都能跑通业务,但它们的页面栈是两套互不相通的数据结构。很多团队在项目中期才踩到:一半页面走 router.pushUrl,一半页面走 pageStack.pushPathByName,结果返回键行为错乱、getParams() 拿不到值、NavPathStack 在子组件里恒为 undefined。本文先把 router 的 API 面完整讲一遍,再讲 Navigation 的栈操作、NavDestination 配置、系统路由表与自定义转场,最后用对照表和坑清单帮你在项目早期就把方案定下来。
前置阅读:鸿蒙应用与页面生命周期 、鸿蒙元服务与卡片开发 。
router 全局路由 API 详解
router 模块从 API 7 起就存在,API 12 之后官方推荐改成从 @kit.ArkUI 统一导入,而不是老的 @ohos.router。功能没变,只是模块归口调整,新项目直接用 kit 导入即可。
// 推荐(API 12+)
import { router } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
router 的核心能力是六件事:入栈、替换、出栈、清栈、取参、查栈。全部是全局单例上的方法,不依赖组件实例,因此在工具类、网络回调、甚至非 UI 的 .ets 文件里都能直接调用。这是它最大的优点,也是问题来源。
pushUrl 与 RouterMode
pushUrl 是入栈入口,签名是 pushUrl(options: RouterOptions, mode?: RouterMode): Promise<void>。RouterOptions 只有三个字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
url | string | 是 | 目标页面路径,必须在 main_pages.json 中注册过 |
params | Object | 否 | 传给目标页面的参数对象,会被原样保存进页面栈 |
recoverable | boolean | 否 | 是否支持应用恢复,默认 true |
RouterMode 决定重复入栈策略:
RouterMode.Standard:默认值,每次调用都新建一个页面实例压栈。同一个页面可以压多份,各自持有独立的params。RouterMode.Single:如果栈里已经存在同名页面,不新建实例,而是把它移到栈顶并触发一次onPageShow,同时用新的params覆盖旧参数。
// pages/Index.ets
import { router } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
interface DetailParams { id: string; from: string; }
@Entry
@Component
struct Index {
async gotoDetail(id: string): Promise<void> {
const params: DetailParams = { id: id, from: 'Index' };
try {
await router.pushUrl({ url: 'pages/Detail', params: params, recoverable: true },
router.RouterMode.Standard);
} catch (err) {
const e = err as BusinessError;
console.error(`pushUrl failed, code=${e.code}, msg=${e.message}`);
}
}
build() {
Column({ space: 12 }) {
Button('跳转详情,Standard').onClick(() => { this.gotoDetail('1001'); })
Button('跳转详情,Single').onClick(async () => {
await router.pushUrl({ url: 'pages/Detail' }, router.RouterMode.Single);
})
}
.width('100%')
.padding(16)
}
}
注意 Single 模式的一个反直觉行为:它不是「阻止跳转」,而是「复用栈内已有实例并把它提到栈顶」。如果那个实例在栈中间,它上方的所有页面会被弹出。很多开发者以为 Single 只是防重复,结果发现中间页面莫名其妙消失了。
replaceUrl 与 back
replaceUrl(options, mode?) 用新页面替换当前栈顶页面,栈深度不变。典型场景是启动页跳首页、登录页跳主界面,用户按返回时不应该回到登录页。
back(options?) 出栈,options 不只是「去哪」,还能顺手把结果回传给上一页:
router.back({ url: 'pages/Index', params: { updated: true } });
容易混淆的点:back 的 params 不会替换上一页原本的 params,而是作为「返回携带的数据」被合并进去。上一页 onPageShow 里 router.getParams() 拿到的仍是原来那批参数,需要用额外字段判断是否有回传。
clear 与 getLength
router.clear() 清空整个页面栈,只保留当前页面。它在登出、切换账号时非常有用,但也非常危险:清栈后 back 会直接退出应用,用户会以为应用崩了。更稳妥的做法是 clear() 之后立刻 pushUrl 到首页,或者干脆用 replaceUrl。router.getLength() 返回当前页面栈深度,HarmonyOS 对页面栈有上限,标准形态下是 32 层,达到上限后继续 pushUrl 会抛 100003。
参数传递与 getParams
router.getParams() 返回栈顶页面的 params 对象,类型是 Object,需要自己断言。它只在页面被创建或被复用(Single 模式)时才有意义,普通 @Component 里调用拿到的是当前栈顶页面的参数,语义很容易搞错。
// pages/Detail.ets
import { router } from '@kit.ArkUI';
interface DetailParams { id: string; from: string; }
@Entry
@Component
struct Detail {
@State id: string = '';
@State from: string = 'unknown';
aboutToAppear(): void {
const raw = router.getParams() as DetailParams | undefined;
if (raw !== undefined && raw.id !== undefined) {
this.id = raw.id;
this.from = raw.from ?? 'unknown';
}
}
build() {
Column({ space: 8 }) {
Text(`id = ${this.id}`).fontSize(18)
Text(`from = ${this.from}`).fontSize(14).fontColor('#666')
Button('返回并带结果').onClick(() => {
router.back({ url: 'pages/Index', params: { updated: true } });
})
}
.width('100%')
.padding(16)
}
}
params 走的是内存引用传递,不是序列化拷贝。传一个含方法的对象也能带过去,但别这么干:页面栈里的对象生命周期不受控,持有闭包会拖住整个页面实例,是内存泄漏的常见来源。传纯数据(Record<string, Object> 或简单 interface)最安全。
getState 与页面栈快照
router.getState() 返回当前页面栈顶的状态快照 RouterState,含 index(从 1 开始的位置)、name(页面文件名)、path(完整路径),一行代码即可读出:const state = router.getState();。它只能读栈顶,既不能遍历整个栈,也不能按名字查找,这是 router 在设计上的硬边界。
错误码
router 的异常都通过 BusinessError.code 抛出,处理时至少要覆盖这几个:
| 错误码 | 含义 | 常见触发场景 |
|---|---|---|
| 100001 | 内部错误 | 页面栈状态异常,通常是并发跳转导致 |
| 100002 | url 无效或页面不存在 | 路径没在 main_pages.json 注册,或拼写错误 |
| 100003 | 页面栈数量达到上限 | 连续 push 超过 32 层,多为循环跳转 bug |
| 100004 | 命名路由页面不存在 | 用了 pushNamedRoute 但目标未注册 |
100002 是最常见的那个,九成情况是新增页面后忘了在 src/main/resources/base/profile/main_pages.json 里加路径。这个文件不会被 IDE 自动同步,改名或移动文件后必须手动更新。
router 的局限
把上面的 API 面看完,router 的边界就很清楚了:
- 不支持自定义转场动画。系统只提供默认的左右滑入滑出,无法改成淡入、缩放或共享元素。想要「图片从列表飞到大图」这类效果,router 做不到。
- 栈管理能力弱。只有
getLength和getState两个只读接口,没有popTo、没有按名字移除、没有getIndexByName。想实现「回到首页并清掉中间所有页面」,只能clear后重新 push,会丢失首页的状态。 - 页面耦合强。跳转目标以字符串路径硬编码在代码里,重命名页面时没有编译期保护,只有运行时 100002。
- 返回拦截弱。只能在页面级
onBackPress里处理,且页面一旦不是@Entry就完全没有这个钩子。 - 一多适配缺失。平板和折叠屏上的分栏布局需要自己写断点判断,Navigation 的
NavigationMode.Split是开箱即用的。
这五点加起来,就是官方在 API 10 之后力推 Navigation 的原因。但要说清楚:router 并没有被废弃,它在轻量场景下依然是成本最低的选择。
Navigation 组件与 NavPathStack
Navigation 是一个容器组件,自身只负责「壳」——标题栏、工具栏、内容区;页面栈由它持有的 NavPathStack 对象管理。这个设计把「导航能力」从全局单例变成了组件状态,带来三个直接好处:栈可以被多个 Navigation 实例隔离、栈操作有完整 API、栈变化可以驱动 UI 刷新。
NavPathStack 的注入方式
NavPathStack 必须由外层页面创建,再通过 @Provide 向下注入,子组件用 @Consume 取。直接 new NavPathStack() 放在子组件里是错的——那会得到一个和 Navigation 无关的空栈。
// pages/MainPage.ets
import { DetailParam } from '../model/DetailParam';
@Entry
@Component
struct MainPage {
// 关键:@Provide 把栈注入组件树,key 显式写成 'pageStack'
@Provide('pageStack') pageStack: NavPathStack = new NavPathStack();
@Builder
pageMap(name: string, param: object) {
if (name === 'DetailPage') {
DetailPage({ param: param as DetailParam })
}
}
build() {
Navigation(this.pageStack) {
Column({ space: 12 }) {
Button('pushPath 进入详情').onClick(() => {
this.pageStack.pushPath({
name: 'DetailPage',
param: { id: '1001', title: '订单详情' } as DetailParam
});
})
Button('pushPathByName 进入设置').onClick(() => {
this.pageStack.pushPathByName('SettingPage', { theme: 'dark' });
})
}
.width('100%')
.padding(16)
}
.title('首页')
.navDestination(this.pageMap)
}
}
@Provide 的 key 用字符串显式指定,避免和同名变量冲突。如果写成 @Provide pageStack: NavPathStack,key 就是变量名 pageStack,子组件必须写 @Consume pageStack,变量名必须完全一致,重构时极易漏改。
pushPath 与 pushPathByName
两个入栈方法,区别只在参数形态:
pushPath(info: NavPathInfo, animated?: boolean): void:NavPathInfo是对象,字段为name、param、onPop、isEntry。pushPathByName(name: string, param: Object, animated?: boolean): void:名字和参数分开传,另有带onPop回调的重载。
它们都是同步返回 void,失败不会抛异常——目标 name 找不到时只是什么都不发生。需要「失败可感知」时用 pushDestination / pushDestinationByName,它们返回 Promise<void>,失败会 reject。isEntry: true 表示这是一个可直接被系统路由表识别的入口页面,路由表方案下会被自动填充。
pop popToName 与 removeByName
出栈相关方法构成了 Navigation 栈管理的完整能力,这也是它相对 router 最实质的优势:
| 方法 | 签名要点 | 用途 |
|---|---|---|
pop | pop(animated?) / pop(result, animated?) | 弹出栈顶,可携带返回结果 |
popToName | popToName(name, result?, animated?) | 弹到指定名字的页面,返回它所在的索引 |
popToIndex | popToIndex(index, result?, animated?) | 弹到指定层级 |
removeByName | removeByName(name) | 只删除指定名字的页面,不影响其他层 |
removeByIndexes | removeByIndexes(indexes) | 批量按索引删除 |
replacePath | replacePath(info, animated?) | 用新页面替换栈顶 |
clear | clear(animated?) | 清空栈,只保留根页面 |
moveToTop | moveToTop(name, animated?) | 把指定页面提到栈顶,不删其他 |
popToName 返回目标页面在栈中的索引,没找到时返回 -1。这个返回值很有用:可以据此判断「回退是否真的发生了」,没找到时再补一次 replacePath。
// components/DetailPage.ets
@Component
export struct DetailPage {
@Consume('pageStack') pageStack: NavPathStack;
@Prop param: DetailParam;
build() {
NavDestination() {
Column({ space: 12 }) {
Text(`id = ${this.param.id}`).fontSize(18)
Button('popToName 回首页').onClick(() => {
const index = this.pageStack.popToName('MainPage', { refreshed: true });
if (index < 0) {
this.pageStack.replacePath({ name: 'MainPage', param: {} });
}
})
Button('只移除自己').onClick(() => { this.pageStack.removeByName('DetailPage'); })
}
.width('100%')
.padding(16)
}
.title('详情')
.mode(NavDestinationMode.STANDARD)
.onBackPressed(() => {
return false; // 返回 true 表示自行处理,阻止默认出栈
})
.onShown(() => { console.info('NavDestination onShown'); })
}
}
NavDestination 组件
NavDestination 是 Navigation 栈内页面的统一外壳,必须作为 @Component 的根节点,否则不会参与转场动画,标题栏也不会显示。它的 mode 有两种:NavDestinationMode.STANDARD(默认,入栈后铺满,可被上层页面覆盖)和 NavDestinationMode.DIALOG(以弹窗形式呈现,背景半透明,可配合 onBackPressed 实现点击外部关闭)。onShown / onHidden 是页面级可见性回调,对应 router 的 onPageShow / onPageHide;onWillShow / onWillHide(API 12)在动画开始前触发,适合做数据预取。
title 与 toolbar 与 menus
Navigation 和 NavDestination 各自有一套标题栏配置,层级关系是「子页面的配置覆盖父容器」:
Navigation(this.pageStack) {
// 内容
}
.title('首页') // 字符串或 CustomBuilder
.titleMode(NavigationTitleMode.Mini) // Mini 小标题 / Full 大标题
.menus([{ value: '搜索', icon: 'search.png', action: () => {} }])
.toolbarConfiguration([{ value: '首页', icon: 'home.png', action: () => {} }])
.hideTitleBar(false)
title 传 CustomBuilder 时,可以做出带副标题、带搜索框的自定义标题栏,这是 router 完全无法覆盖的场景。toolbarConfiguration 只对 NavigationMode.Split 下的侧边栏生效,Stack 模式下会被忽略。
customNavContentTransition 自定义转场
这是 Navigation 相对 router 最具决定性的一项能力。通过 customNavContentTransition 注册一个工厂函数,返回自定义的 NavigationTransition 对象,就能完全接管入栈、出栈、替换三种操作的动画:
import { FrameNode } from '@kit.ArkUI';
class SlideTransition implements NavigationTransition {
private fromNode: FrameNode | undefined;
private toNode: FrameNode | undefined;
constructor(from: NavContentInfo, to: NavContentInfo) {
this.fromNode = from.node;
this.toNode = to.node;
}
transition(progress: number): void {
// progress 由框架按帧驱动,取值 0 到 1
this.toNode?.commonAttribute.translate({ x: 360 * (1 - progress) });
this.fromNode?.commonAttribute.opacity(1 - progress * 0.3);
}
transitionEnd(): void {
this.fromNode = undefined;
}
}
// 注册处
Navigation(this.pageStack)
.navDestination(this.pageMap)
.customNavContentTransition((from: NavContentInfo, to: NavContentInfo,
operation: NavigationOperation) => {
if (operation === NavigationOperation.PUSH) {
return new SlideTransition(from, to);
}
// 返回 undefined 时回落到系统默认转场
return undefined;
})
NavigationTransition 接口要求实现 transition(progress: number) 和 transitionEnd(),框架按帧回调 progress,你在里面改节点属性即可。NavContentInfo 提供 name、index、param、navDestinationId 和 node(FrameNode)。如果只是想微调系统转场的时长与缓动,可以在 transition 里配合 getUIContext().animateTo,而不是手写插值。
代价是性能与复杂度:自定义转场跑在主线程的每一帧上,节点属性改得太多会掉帧;transition 里的异常不会被框架吞掉,一旦抛错整个转场会卡在中途。
系统路由表方案
到 API 11 之前,Navigation 有个尴尬点:@Builder 的 pageMap 必须显式 import 每一个页面组件,导致首页文件成了所有页面的依赖汇聚点,编译期耦合严重,也拖慢冷启动。系统路由表解决了这个问题,做法分三步。
第一步,在 module.json5 里声明路由表文件:
{
"module": {
"name": "entry",
"type": "entry",
"routerMap": "$profile:route_map"
}
}
第二步,在 src/main/resources/base/profile/route_map.json 里登记页面:
{
"routerMap": [
{
"name": "DetailPage",
"pageSourceFile": "src/main/ets/pages/DetailPage.ets",
"buildFunction": "DetailPageBuilder",
"data": { "description": "订单详情页", "needLogin": true }
}
]
}
第三步,在页面文件里导出同名 @Builder 函数:
// pages/DetailPage.ets
@Builder
export function DetailPageBuilder() {
DetailPage()
}
@Component
struct DetailPage {
@Consume('pageStack') pageStack: NavPathStack;
build() {
NavDestination() {
Text('详情页').fontSize(20)
}
.title('详情')
}
}
配好之后,Navigation 上的 .navDestination(this.pageMap) 就可以删掉,框架会根据 name 自动从路由表加载对应页面。业务代码里依然写 pageStack.pushPathByName('DetailPage', param),但不再需要 import DetailPage。几个约束要记住:
buildFunction的名字必须和文件里export的@Builder函数名完全一致。- 被路由表引用的页面文件,不能再被其他文件
import后当作普通组件使用,否则会破坏懒加载收益,甚至导致重复注册。 data字段(API 12 新增)可以挂业务元数据,运行时通过NavPathInfo或拦截器读取,适合做「该页面是否需要登录」这类前置校验。
Navigation 与 router 混用注意事项
最要紧的一条:router 的页面栈和 Navigation 的 NavPathStack 是两套完全独立的数据结构,互不可见。 用 router.pushUrl 进入的页面不会出现在 pageStack 里,反之亦然。由此衍生出几个具体后果:
router.getLength()不会把 Navigation 内的页面算进去,用它做「栈深保护」会失准。- 在 Navigation 页面里调用
router.back(),可能直接退出应用而不是回到上一层 Navigation 页面。 NavDestination内的页面没有onPageShow/onPageHide/onBackPress这三个页面级钩子,必须改用onShown/onHidden/onBackPressed。写惯了 router 的人最容易在这里踩空。
如果项目确实要迁移,推荐按模块切分而不是按页面切分:一个模块内部的页面要么全走 router,要么全走 Navigation,模块入口处用一次跳转做桥接。跨模块混用时,明确约定「Navigation 只作为顶层容器,router 只用于跨 Ability 跳转」,能规避绝大多数返回错乱。
页面跳转返回值与 pop 回调
router 的返回值模型是「上一页在 onPageShow 里读 getParams()」,属于拉模式,时机不精确。Navigation 提供了推模式:
// 调用方:注册 onPop 回调,页面被 pop 时触发
this.pageStack.pushPathByName('DetailPage', { id: '1002' },
(popInfo: PopInfo) => {
console.info(`result = ${JSON.stringify(popInfo.result)}`);
console.info(`from = ${popInfo.info.name}`);
});
PopInfo 有两个字段:info 是出栈页面的 NavPathInfo(含 name 和 param),result 是出栈时携带的结果对象,由出栈方通过 pop(result)、popToName(name, result) 或 popToIndex(index, result) 传入。用 pushPath 时,回调写在 NavPathInfo.onPop 上,效果等价。
两个关键约束:onPop 只在注册它的那次 push 所对应的页面实例出栈时触发,页面被 removeByName 移除、或整个栈被 clear 时回调不会触发;回调可能在转场动画中途执行,不要在里面对 UI 状态做「改了立刻重绘」的假设,稳妥做法是把结果写进 @State 或 AppStorage,让状态驱动刷新。
两种方案对照表
| 维度 | router 全局路由 | Navigation 加 NavPathStack |
|---|---|---|
| 引入版本 | API 7 起,API 12 推荐改用 kit 导入 | Navigation API 8 起,NavPathStack API 10 起 |
| 栈管理 | 仅 getState、getLength 两个只读接口 | pushPath、pop、popToName、removeByName、popToIndex 全可写 |
| 转场动画 | 只有系统默认,不可定制 | customNavContentTransition 完全自定义,可回落默认 |
| 参数传递 | params 对象加 URL,getParams 一次性读取 | NavPathInfo.param,getParamByName 可按名反复读取 |
| 返回值 | back 携带 params,上一页在 onPageShow 拉取 | onPop 回调 PopInfo 推模式,pop 时携带 result |
| 系统路由表 | 不支持,路径写死在 main_pages.json | module.json5 的 routerMap 加 route_map.json,无需 import |
| 返回拦截 | 仅 @Entry 页面的 onBackPress | NavDestination.onBackPressed 加 setInterception |
| 一多分栏 | 无内建支持,需自行判断断点 | NavigationMode.Stack、Split、Auto 开箱即用 |
| 页面耦合 | 路径字符串硬编码,无编译期保护 | 组件引用加路由表,重命名有编译期报错 |
| 适用规模 | 页面少于 10 个的轻量应用、快速原型 | 中大型应用、需要自定义转场与分栏适配 |
权衡取舍
选型不是「新的就是好的」,要看三个约束。第一是迁移成本。 从 router 迁到 Navigation 不是替换几个 API 调用:每个目标页面要从 @Entry @Component 改成普通 @Component 加 NavDestination 根节点,onPageShow 要改成 onShown,getParams() 要改成 @Consume 加 getParamByName(),页面栈的创建与注入要重新梳理。一个 30 页的应用,工作量大致在 3 到 5 人日。
第二是性能特征。 Navigation 的 NavDestination 默认按需创建、出栈即销毁,内存占用比 router 的页面栈更可控;但自定义转场跑在主线程,动画期间如果有大量 @State 更新会掉帧。router 的页面栈由框架统一管理,转场期间开销相对固定。第三是团队认知。 router 的心智模型是「全局函数加页面路径」,新同学十分钟能上手;Navigation 需要理解 @Provide / @Consume 的注入链路、NavDestination 的渲染时机、路由表的加载顺序。如果团队规模小、迭代快、页面不复杂,坚持 router 是理性的。
比较务实的建议是:新项目直接用 Navigation,并且从第一天就用系统路由表,避免后期重构;存量项目按模块逐步迁移,先用 Navigation 包一层顶层容器,把 router 限制在跨 Ability 的场景里。
常见坑清单
- router 栈与 Navigation 栈不互通。 在 Navigation 页面里调
router.back()可能直接退出应用。混用时必须明确「谁是当前栈的所有者」,并统一返回入口。 - NavPathStack 未用
@Provide导致子组件拿不到。 子组件写@Consume('pageStack') pageStack: NavPathStack,父级没有对应的@Provide('pageStack')时会报错或拿到 undefined。父子 key 必须逐字一致。 - pop 的参数类型。
pop(result, animated?)的第一个参数是结果对象,第二个才是是否播动画。写成pop(true)会把true当结果传出去,调用方拿到的popInfo.result是布尔值而不是对象。 - Single 模式重复入栈导致中间页面消失。
RouterMode.Single会把已存在的同名页面提到栈顶并弹出它上面的所有页面。做「防重复点击」不要用它,应该在按钮上加节流,或用getState().name先判断。 - 页面被回收后回调丢失。
onPop绑定的是页面实例,实例被removeByName或clear移除后回调不会再触发。需要「无论怎么退出都能收到结果」的场景,改用AppStorage或Emitter传递。 - 系统路由表的 buildFunction 未导出。
route_map.json里写的buildFunction必须在对应.ets文件里export function,且被@Builder装饰。名字对不上时页面白屏,日志里往往只有一条不显眼的警告。 - 新增页面忘了注册 main_pages.json。 router 跳转抛 100002,且这个文件不会随文件重命名自动更新。
- 在 aboutToAppear 里读
router.getParams()拿到旧值。 页面被 Single 模式复用时aboutToAppear不会重跑,参数更新要在onPageShow里读。Navigation 侧对应的问题是在onShown里重新getParamByName。
小结
router 和 Navigation 不是替代关系,而是两个抽象层级:router 是「全局函数式导航」,Navigation 是「组件化导航容器」。判断标准很简单——如果应用需要自定义转场、需要按名字管理页面栈、需要在平板上有分栏布局,就选 Navigation,并且从项目第一天就用系统路由表;如果只是几个页面的轻量工具,router 的零心智负担依然是优势。
真正会出问题的从来不是选错方案,而是两套栈混着用。把「谁是当前页面栈的所有者」在架构文档里写清楚,比记住任何一个 API 都重要。跨端场景下,小程序的组件化导航设计与 Navigation 有不少可对照之处,可以参考 小程序组件化架构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。