HarmonyOS 页面路由与 Navigation 实战
在 HarmonyOS 应用里,页面跳转不只是从 A 页打开 B 页这么简单。真正落到项目里,还会遇到登录拦截、参数传递、返回结果、底部导航、多级详情页、异常返回、横竖屏恢复等问题。把路由设计清楚,页面结构会更稳,后续做模块化和状态管理也会轻很多。
本文围绕 Stage 模型与 ArkUI 的实际开发场景,讲清楚两类常用页面组织方式:轻量页面跳转与 Navigation 栈式导航。前者适合简单入口,后者更适合中大型应用的主流程。
一、先分清页面跳转和应用拉起
鸿蒙应用端经常会同时接触两种“跳转”:
| 场景 | 常用能力 | 典型用途 |
|---|---|---|
| 应用内页面切换 | router 或 Navigation |
首页到详情页、设置页、表单页 |
| Ability 之间拉起 | Want / startAbility |
从通知进入指定 Ability、跨模块入口、外部应用拉起 |
日常页面开发里,优先关注应用内页面切换。Ability 拉起属于系统调度层面的入口管理,适合放在外部链接、通知、服务入口这些位置。不要把所有页面都拆成 Ability,否则任务栈、生命周期和数据传递都会变复杂。
一个实用判断:
- 同一个业务闭环里的页面,用
Navigation组织。 - 临时简单页面,可以用
router快速打开。 - 独立任务入口、外部可拉起能力,再考虑 Ability。
二、轻量路由:router 适合简单页面
router 是 ArkUI 提供的页面级跳转能力,使用成本低,适合新手理解页面切换,也适合一些简单应用。
假设有一个商品列表页,需要打开商品详情页,可以这样写:
import router from '@ohos.router'
Button('查看详情')
.onClick(() => {
router.pushUrl({
url: 'pages/ProductDetail',
params: {
productId: 'p_1001',
from: 'home'
}
})
})
详情页读取参数:
import router from '@ohos.router'
interface ProductParams {
productId?: string
from?: string
}
@Entry
@Component
struct ProductDetailPage {
@State productId: string = ''
aboutToAppear(): void {
const params = router.getParams() as ProductParams
this.productId = params.productId ?? ''
}
build() {
Column() {
Text(`商品 ID:${
this.productId}`)
Button('返回')
.onClick(() => router.back())
}
.width('100%')
.height('100%')
.padding(16)
}
}
router.pushUrl 会把新页面压入页面栈,router.back 回到上一个页面。这个模型直观,但也有明显边界:当页面层级多、需要统一拦截、需要在不同 Tab 内维护独立栈时,router 会开始吃力。
三、Navigation 更适合主流程
Navigation 是 ArkUI 面向复杂页面结构提供的导航容器。它不是简单的 API 调用,而是把“页面栈”变成组件状态的一部分。这样做的好处是:
- 页面结构更符合声明式 UI 思路。
- 可以集中管理栈内页面、标题栏、返回按钮和页面参数。
- 适合和状态管理、模块化路由表结合。
- 在折叠屏、平板等多窗口场景下扩展空间更大。
一个最小的 Navigation 结构如下:
@Entry
@Component
struct MainPage {
private pathStack: NavPathStack = new NavPathStack()
build() {
Navigation(this.pathStack) {
HomeContent({
pathStack: this.pathStack })
}
.title('首页')
.navDestination(this.pageMap)
}
@Builder
pageMap(name: string, param: object) {
if (name === 'ProductDetail') {
ProductDetail({
productId: (param as Record<string, string>)['productId'] })
} else if (name === 'Settings') {
SettingsPage()
}
}
}
首页组件通过 NavPathStack 打开目标页:
@Component
struct HomeContent {
pathStack: NavPathStack
build() {
Column({
space: 12 }) {
Button('商品详情')
.onClick(() => {
this.pathStack.pushPath({
name: 'ProductDetail',
param: {
productId: 'p_1001' }
})
})
Button('设置')
.onClick(() => {
this.pathStack.pushPath({
name: 'Settings' })
})
}
.padding(16)
}
}
这里的核心是:页面名称和参数进入 NavPathStack,真正渲染哪个页面由 .navDestination 统一决定。业务页面不需要知道目标页面的创建细节,只要知道路由名称和参数即可。
四、用路由表降低耦合
项目变大后,不建议在 pageMap 里堆很多 if。可以把路由名称、参数类型和构建逻辑收束到一个位置。
先定义路由常量:
export const RouteName = {
PRODUCT_DETAIL: 'ProductDetail',
ORDER_CONFIRM: 'OrderConfirm',
SETTINGS: 'Settings'
} as const
export interface ProductDetailParam {
productId: string
}
export interface OrderConfirmParam {
skuId: string
count: number
}
再封装跳转方法:
export class AppNavigator {
constructor(private readonly pathStack: NavPathStack) {
}
openProductDetail(param: ProductDetailParam): void {
this.pathStack.pushPath({
name: RouteName.PRODUCT_DETAIL,
param
})
}
openOrderConfirm(param: OrderConfirmParam): void {
this.pathStack.pushPath({
name: RouteName.ORDER_CONFIRM,
param
})
}
back(): void {
this.pathStack.pop()
}
}
业务代码就会变得更干净:
Button('立即购买')
.onClick(() => {
this.navigator.openOrderConfirm({
skuId: this.skuId,
count: this.buyCount
})
})
这样做的价值不在于少写几行代码,而是把路由名称、参数结构和跳转行为固定下来。后面新增登录拦截、埋点、异常兜底时,只需要改导航层。
五、参数传递要少而稳定
页面参数最好只传“能标识资源的最小数据”,例如 productId、orderId、tabKey。不要把整份接口返回对象直接塞进路由参数。
推荐写法:
this.pathStack.pushPath({
name: RouteName.PRODUCT_DETAIL,
param: {
productId: item.id }
})
详情页根据 ID 拉取数据或从统一 Store 读取缓存:
@Component
struct ProductDetail {
productId: string
@State productName: string = ''
@State loading: boolean = true
aboutToAppear(): void {
this.loadProduct()
}
async loadProduct(): Promise<void> {
this.loading = true
try {
const product = await ProductRepository.getById(this.productId)
this.productName = product.name
} finally {
this.loading = false
}
}
build() {
Column() {
if (this.loading) {
LoadingProgress()
} else {
Text(this.productName).fontSize(20).fontWeight(FontWeight.Medium)
}
}
.padding(16)
}
}
这种方式更抗变化。列表页刷新、详情页恢复、应用进程重建时,都不依赖一份可能已经过期的大对象。
六、返回结果不要强行共享组件状态
常见场景:编辑地址页保存后,返回地址列表并刷新。初学者容易把列表页的状态对象传给编辑页修改,这会让页面耦合得很紧。
更稳的方式是用统一数据源或事件通知。保存成功后返回,列表页在重新出现时刷新:
@Component
struct AddressListPage {
@State addresses: Address[] = []
aboutToAppear(): void {
this.refresh()
}
async refresh(): Promise<void> {
this.addresses = await AddressRepository.list()
}
build() {
List() {
ForEach(this.addresses, (item: Address) => {
ListItem() {
Text(item.detail)
}
})
}
}
}
编辑页只负责保存和返回:
Button('保存')
.onClick(async () => {
await AddressRepository.save(this.formData)
this.pathStack.pop()
})
如果业务需要明确结果,可以在共享 Store 中维护一个版本号或更新时间戳,列表页检测到变化再刷新。重点是不要让两个页面互相持有对方的内部状态。
七、登录拦截放在导航入口
登录拦截是路由系统最能体现价值的地方。比如订单确认页必须登录,商品详情页可以游客访问。不要在每个按钮里都写一遍登录判断,而是把规则放进导航层。
const authRequiredRoutes: string[] = [
RouteName.ORDER_CONFIRM,
'UserProfile',
'CouponCenter'
]
export class GuardedNavigator {
constructor(
private readonly pathStack: NavPathStack,
private readonly session: UserSession
) {
}
push(name: string, param?: object): void {
if (authRequiredRoutes.includes(name) && !this.session.isLogin()) {
this.pathStack.pushPath({
name: 'Login',
param: {
redirect: name, redirectParam: param }
})
return
}
this.pathStack.pushPath({
name, param })
}
}
登录成功后读取 redirect,再进入原目标页。这样用户体验更完整,也避免了业务页到处散落鉴权逻辑。
八、底部 Tab 与独立页面栈
很多应用首页会有底部 Tab:主页、分类、消息、我的。一个细节是,每个 Tab 往往需要自己的页面栈。用户在分类 Tab 进入二级分类,再切到主页,回来时应该还停留在二级分类。
可以为不同 Tab 准备独立的 NavPathStack:
@Entry
@Component
struct TabRootPage {
@State currentIndex: number = 0
private homeStack: NavPathStack = new NavPathStack()
private categoryStack: NavPathStack = new NavPathStack()
private messageStack: NavPathStack = new NavPathStack()
private profileStack: NavPathStack = new NavPathStack()
build() {
Tabs({
index: this.currentIndex }) {
TabContent() {
Navigation(this.homeStack) {
HomePage({
pathStack: this.homeStack }) }
.navDestination(this.homeDestinations)
}
.tabBar('首页')
TabContent() {
Navigation(this.categoryStack) {
CategoryPage({
pathStack: this.categoryStack }) }
.navDestination(this.categoryDestinations)
}
.tabBar('分类')
TabContent() {
Navigation(this.messageStack) {
MessagePage({
pathStack: this.messageStack }) }
.navDestination(this.messageDestinations)
}
.tabBar('消息')
TabContent() {
Navigation(this.profileStack) {
ProfilePage({
pathStack: this.profileStack }) }
.navDestination(this.profileDestinations)
}
.tabBar('我的')
}
}
}
如果所有 Tab 共用一个栈,切换体验会非常混乱。独立栈虽然多写一点结构,但更符合用户对多入口应用的预期。
九、返回行为要有明确策略
页面栈管理里,返回行为容易被低估。推荐至少处理三类返回:
- 普通返回:当前栈有页面时
pop。 - 根页面返回:首页根节点可以提示再次返回退出,或交给系统默认行为。
- 任务完成返回:提交订单成功后清理中间页,回到订单详情或首页。
例如支付成功后,不希望用户返回到确认订单页,可以重置栈:
this.pathStack.clear()
this.pathStack.pushPath({
name: 'OrderDetail',
param: {
orderId: result.orderId }
})
对登录、下单、发布内容这类流程,栈清理比简单 pop 更重要。否则用户可能通过返回键回到已经失效的中间状态。
十、工程里的推荐目录结构
一个可维护的路由目录可以这样组织:
entry/src/main/ets/
├── common/
│ └── router/
│ ├── RouteName.ets
│ ├── RouteParam.ets
│ ├── AppNavigator.ets
│ └── RouteGuard.ets
├── pages/
│ ├── home/
│ ├── product/
│ ├── order/
│ └── profile/
└── entryability/
路由层负责“去哪里”和“能不能去”,页面层负责“怎么展示”和“怎么交互”。只要边界清晰,后续做多模块、沉浸式标题栏、页面埋点都会更容易。
十一、常见问题与处理建议
| 问题 | 原因 | 建议 |
|---|---|---|
| 返回后页面数据没刷新 | 页面没有重新拉取数据 | 在页面出现时按需刷新,或监听 Store 版本变化 |
| 参数取不到 | 路由名称写错、参数结构不一致 | 用常量和接口约束参数,不手写散落字符串 |
| Tab 切换后栈丢失 | 多个 Tab 共用同一个栈 | 每个 Tab 维护独立 NavPathStack |
| 登录后无法回到原页面 | 没保存目标路由和参数 | 登录页接收 redirect 与 redirectParam |
| 返回到失效流程页 | 提交成功后只做了普通返回 | 对完成态流程清理或重建页面栈 |
十二、落地建议
如果你正在做一个真实鸿蒙项目,可以按这个顺序演进:
- 原型和小工具页面用
router快速完成跳转。 - 主业务页面统一迁到
Navigation。 - 把路由名称、参数类型、页面构建和跳转方法集中管理。
- 对登录、埋点、异常路由做统一拦截。
- 对首页 Tab 使用独立页面栈。
- 对订单、支付、发布等流程设计明确的栈清理策略。
页面路由看起来是基础能力,但它决定了应用骨架是否稳定。早期把路由边界定清楚,后面做状态管理、数据持久化和跨端适配时,会少很多隐性耦合。