[鸿蒙从零到一] 页面路由与 Navigation 实战

5 阅读8分钟

HarmonyOS 页面路由与 Navigation 实战

在 HarmonyOS 应用里,页面跳转不只是从 A 页打开 B 页这么简单。真正落到项目里,还会遇到登录拦截、参数传递、返回结果、底部导航、多级详情页、异常返回、横竖屏恢复等问题。把路由设计清楚,页面结构会更稳,后续做模块化和状态管理也会轻很多。

本文围绕 Stage 模型与 ArkUI 的实际开发场景,讲清楚两类常用页面组织方式:轻量页面跳转与 Navigation 栈式导航。前者适合简单入口,后者更适合中大型应用的主流程。

一、先分清页面跳转和应用拉起

鸿蒙应用端经常会同时接触两种“跳转”:

场景常用能力典型用途
应用内页面切换routerNavigation首页到详情页、设置页、表单页
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
    })
  })

这样做的价值不在于少写几行代码,而是把路由名称、参数结构和跳转行为固定下来。后面新增登录拦截、埋点、异常兜底时,只需要改导航层。

五、参数传递要少而稳定

页面参数最好只传“能标识资源的最小数据”,例如 productIdorderIdtabKey。不要把整份接口返回对象直接塞进路由参数。

推荐写法:

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
登录后无法回到原页面没保存目标路由和参数登录页接收 redirectredirectParam
返回到失效流程页提交成功后只做了普通返回对完成态流程清理或重建页面栈

十二、落地建议

如果你正在做一个真实鸿蒙项目,可以按这个顺序演进:

  • 原型和小工具页面用 router 快速完成跳转。
  • 主业务页面统一迁到 Navigation
  • 把路由名称、参数类型、页面构建和跳转方法集中管理。
  • 对登录、埋点、异常路由做统一拦截。
  • 对首页 Tab 使用独立页面栈。
  • 对订单、支付、发布等流程设计明确的栈清理策略。

页面路由看起来是基础能力,但它决定了应用骨架是否稳定。早期把路由边界定清楚,后面做状态管理、数据持久化和跨端适配时,会少很多隐性耦合。