鸿蒙 ArkUI 深水区:Navigation 多级路由,从「拼页面」到「搭应用」的分水岭

鸿蒙 ArkUI 深水区:Navigation 多级路由,从「拼页面」到「搭应用」的分水岭

写在前面

如果你写 ArkUI 写过五个页面以上,大概率遇到过这个场景:

首页一个商品列表,点商品跳详情页,详情页里又有「查看评价」「同类推荐」两个跳转,评价页里还能点头像跳用户主页......

你一开始的处理方式通常是:onClickrouter.pushUrl,把 url 拼起来跳。写五个页面还能撑,写到第十个你发现:

  1. 跳转动画生硬,系统默认那一下硬切毫无设计感
  2. 路由栈乱了,用户点返回回到的不是上一页而是不知道哪一页
  3. 传参只能拼 query string,对象传不了,复杂参数要 JSON 序列化
  4. Tab 切换时,每个 Tab 的路由栈独立不了,用户在 TabA 跳三层,切 TabB 再回来,栈没了

这是「拼页面」和「搭应用」的分水岭。鸿蒙 6.1 给的答案是 Navigation 容器 + NavPathStack 路由栈------声明式路由、可编程栈、原生动画、对象传参,一个不落。

本文就用一个真机可跑 的 demo,把 Navigation 从「听名字一脸懵」讲到「下个项目直接抄」。代码托管在 AtomGit,文末有链接,真机实拍截图作证。

适合人群:写过 ArkUI、被 router.pushUrl 折磨过的同学。 不适合人群:还在学 @Builder 的同学------出门左转看我的上一篇。


一、先讲清楚:Navigation 到底在管什么

一句话:Navigation 是一个路由容器,管着「当前该显示哪个页面」和「页面跳转的栈」。

你之前写 router.pushUrl 是命令式路由------你告诉系统「跳到 url X」。Navigation 是声明式路由------你告诉容器「路由栈顶现在是 name X」,容器自己根据路由表找对应 builder 构造页面显示。

结构长这样:

scss 复制代码
Navigation(路由栈)
  ├─ 首页内容(栈底默认页,直接写在 Navigation 里)
  └─ 路由表 builder(根据 name 构造目标页)
       └─ NavDestination 容器(目标页外壳)
            └─ 真正的页面内容

四个核心概念:

| 概念 | 作用 | 一句话理解 | |---|---|---|| | NavPathStack | �路由栈 | 「所有跳转记录的栈,push/pop 都通过它」 | | Navigation | 路由容器 | 「当前显示栈顶页面,首页写在它里」 | | NavDestination | 目标页外壳 | 「每个目标页必须套这个壳,自带标题栏 + 返回」 | | 路由表 builder | name → 页面 | 「根据 name 找对应 builder 构造页面」 |

记住这四个,往下看。


二、动手:一个「首页 → 详情页」的最小路由 demo

2.1 数据模型

typescript 复制代码
class RouteItem {
  name: string = ''
  desc: string = ''
  color: string = '#007DFF'
  RouteItem() {}
  set(name: string, desc: string, color: string): RouteItem {
    this.name = name
    this.desc = desc
    this.color = color
    return this
  }
}

// 显式声明的路由参数对象(ArkTS 不允许裸对象字面量)
class RouteParam {
  id: string = ''
  title: string = ''
  color: string = '#007DFF'
  RouteParam() {}
}

两个细节值得说:

  • class + 工厂方法 set,不用 interface 字面量------因为路由参数要在页面间传递,class 实例能保持引用稳定
  • RouteParam 必须显式声明成 class,不能 { id: '1', title: '...' } 裸对象------这是 ArkTS 的强约束(arkts-no-untyped-obj-literals),比 TypeScript 严,新手必踩

2.2 详情页:NavDestination 容器

typescript 复制代码
@Component
export struct DetailPage {
  @State param: RouteParam = new RouteParam()
  private pathStack: NavPathStack = new NavPathStack()

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Stack({ alignContent: Alignment.Center }) {
          Text(this.param.title).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#fff')
        }
        .width('100%').height(120).backgroundColor(this.param.color)
        .borderRadius({ bottomLeft: 18, bottomRight: 18 })

        Column({ space: 8 }) {
          Text('你从首页跳过来了').fontSize(14).fontColor('#888')
          Text(`参数 id = ${this.param.id}`).fontSize(18).fontWeight(FontWeight.Bold)
          Text(`参数 title = ${this.param.title}`).fontSize(14).fontColor('#555')
        }
        .width('100%').padding(16).backgroundColor('#fff').borderRadius(10)

        Button('返回首页')
          .backgroundColor(this.param.color).fontColor('#fff')
          .onClick(() => { this.pathStack.pop() })
      }
    }
    .title(this.param.title)
    .onReady((context: NavDestinationContext) => {
      this.pathStack = context.pathStack
      const p = context.pathInfo.param as RouteParam
      if (p) { this.param = p }
    })
  }
}

三个关键点:

NavDestination 是目标页的「外壳」

每个被 push 进来的目标页,build() 顶层必须套 NavDestination。它自带标题栏、返回按钮、页面生命周期,你只管在里面写页面内容。

pathStackonReady

NavDestinationonReady 回调里给你一个 NavDestinationContext,里面有 pathStack(当前挂在的路由栈)和 pathInfo(当前路由项信息,含 param)。你要把它们存到组件属性上,后面 pop 用得到。

context.pathInfo.param 取参数

首页 push 时传的 param 对象,在这里通过 context.pathInfo.param 取出来。它是对象引用,不是 query string ------这是 Navigation 比 router.pushUrl 强大的核心一点,任意复杂对象都能传。

2.3 首页:Navigation 容器 + 路由表

typescript 复制代码
@Entry
@Component
struct Index {
  private pathStack: NavPathStack = new NavPathStack()
  @State routes: RouteItem[] = []

  aboutToAppear(): void {
    this.routes = [
      new RouteItem().set('商品详情', '传 id 进详情页,展示商品信息', '#007DFF'),
      new RouteItem().set('订单跟踪', '传 orderId,展示物流进度', '#FF4D4F'),
      new RouteItem().set('用户中心', '传 userId,展示个人信息', '#27AE60')
    ]
  }

  // 路由表 builder:Navigation 容器按 name 找对应 builder 构造目标页
  @Builder
  PageMap(name: string) {
    if (name === 'detail') {
      DetailPage()
    }
  }

  build() {
    Column() {
      Text('Navigation 路由 Demo').fontSize(22).fontWeight(FontWeight.Bold).margin({ top: 16 })

      Navigation(this.pathStack) {
        // 这里是首页内容(栈底默认页)
        Column({ space: 10 }) {
          Text('首页:选择一条要跳转的路由').fontSize(14).fontColor('#888')

          List({ space: 10 }) {
            ForEach(this.routes, (route: RouteItem, idx: number) => {
              ListItem() {
                Row({ space: 12 }) {
                  Stack({ alignContent: Alignment.Center }) {
                    Text(`${idx + 1}`).fontSize(18).fontColor('#fff').fontWeight(FontWeight.Bold)
                  }
                  .width(40).height(40).backgroundColor(route.color).borderRadius(20)

                  Column({ space: 4 }) {
                    Text(route.name).fontSize(16).fontWeight(FontWeight.Bold)
                    Text(route.desc).fontSize(12).fontColor('#888')
                  }
                  .alignItems(HorizontalAlign.Start).layoutWeight(1)

                  Text('›').fontSize(22).fontColor('#bbb')
                }
                .width('100%').padding(14).backgroundColor('#fff').borderRadius(10)
                .onClick(() => {
                  // pushPath 进栈:name 是路由表 key,param 传给目标页
                  const p = new RouteParam()
                  p.id = `${idx + 1}`
                  p.title = route.name
                  p.color = route.color
                  this.pathStack.pushPath({ name: 'detail', param: p } as NavPathInfo)
                })
              }
            }, (route: RouteItem, idx: number) => `${idx}`)
          }
        }
      }
      .navDestination(this.PageMap)    // 路由表挂在容器的 navDestination 属性上
      .title('首页')
    }
  }
}

三、这段代码的五个关键点

typescript 复制代码
private pathStack: NavPathStack = new NavPathStack()

你持有这个栈的引用,push/pop/replace 都通过它。这比 router.pushUrl 命令式调用优雅得多------栈是数据,你能监听栈的变化、能清空、能拿到所有历史。

pushPath 跳转,不是 push

鸿蒙 6.1 API 23 的真实方法名是 pushPath,不是文档里到处写的 push 。这是新手查文档最容易踩的坑------很多教程是旧 API,方法名是 push,API 23 改成了 pushPath

typescript 复制代码
this.pathStack.pushPath({ name: 'detail', param: p } as NavPathInfo)
  • name 是路由表里的 key,容器根据它找 builder
  • param 是传给目标页的对象,任意复杂都行
  • 整个参数要 as NavPathInfo 显式断言,ArkTS 不允许裸对象字面量

③ 路由表 builder 用 if 分发 name

typescript 复制代码
@Builder
PageMap(name: string) {
  if (name === 'detail') {
    DetailPage()
  }
}

路由表就是一个 @Builder,接收 name,用 if 分发到对应的目标页组件。多个目标页就多个 if 分支。这比写 JSON 路由配置文件直观得多------你看到的就是「name → 哪个页面」。

.navDestination(this.PageMap) 挂路由表

路由表 builder 要挂在 Navigation 容器的 navDestination 属性上。这个挂载点容易忘------忘了它,你 push 进栈啥都不发生,容器不知道去哪找目标页。

pop 返回,不是 router.back

typescript 复制代码
this.pathStack.pop()

返回用栈的 pop 方法,它会销毁栈顶页面、显示上一个页面,带原生动画。你不用操心动画、不用操心销毁、不用操心状态恢复------栈管到底。


四、真机实拍:首页 → 详情页的跳转长这样

我把这个 demo 装到真机上跑(鸿蒙 6.1.1.125, API 24),下面两张都是真机实拍,没有任何 P 图。

首页:三条路由项,点任意一条跳详情页:

点「商品详情」后,push 到详情页,标题栏带返回,内容区展示传过来的 id 和 title:

重点看第二张图:详情页顶部有原生标题栏(自动加的),内容区正确显示了首页传过来的 参数 id = 1参数 title = 商品详情------这就是 context.pathInfo.param 取出来的对象,整个跳转过程对象引用传递,没有 query string 序列化反序列化那些破事。


五、Navigation vs router.pushUrl:啥时候用哪个

新手最容易纠结的问题:既然有 router.pushUrl,还要 Navigation 干啥?

答案一句话:router 是命令式全局路由,Navigation 是声明式容器化路由

维度 router.pushUrl Navigation
路由风格 命令式(你调函数跳) 声明式(你改栈,容器自己显示)
传参 query string 或 JSON 序列化 任意对象引用传递
多 Tab 独立栈 不支持,全局一个栈 支持,每个 Tab 一个 NavPathStack
跳转动画 系统默认硬切 原生流畅过渡,可定制
路由拦截 需自己埋点 内置 InterceptionCallback
何时用 简单几个页面,无独立栈需求 多级跳转、Tab 嵌套、要拦截

一句话决策:页面超过 5 个,或者要做 Tab + 多级路由,就用 Navigation


六、常见坑(都是血泪)

症状 解法
push 跳转 编译报错「Property 'push' does not exist」 API 23 改名 pushPath,用新方法
路由表忘挂 navDestination push 进栈啥都不发生 .navDestination(this.PageMap) 挂到容器
NavDestination 套错位置 标题栏不显示,返回按钮不见 目标页 build() 顶层必须套 NavDestination
裸对象字面量传参 编译报错 arkts-no-untyped-obj-literals param 用显式 class 实例,as NavPathInfo 断言
context.pathInfo.param 取出来是 unknown 类型不匹配 as RouteParam 显式断言,别用 any
多个目标页用同一个 builder 路由表分发错乱 路由表 builder 里用 if (name === 'xxx') 分发

七、完整代码仓库

本文所有代码都已托管到 AtomGit,欢迎 clone、提 issue、点 star:

🔗 仓库地址 :atomgit.com/JaneConan/a...

仓库包含:

  • 完整的「首页 → 详情页」Navigation 路由 demo 工程
  • Index.ets 首页(Navigation 容器 + 路由表 + 路由列表)
  • DetailPage 详情页(NavDestination 外壳 + 对象取参 + pop 返回)
  • RouteParam 显式参数 class(ArkTS 强约束示范)
  • 可直接用 DevEco Studio 打开运行

八、下一步该学什么?

跑通这个 demo 之后,你的 ArkUI 路由就入门了。建议按这个顺序往下:

  1. InterceptionCallback 路由拦截:跳转前埋点、登录态校验、参数清洗
  2. Navigation 嵌套 Tabs :每个 Tab 一个独立 NavPathStack,Tab 切换栈独立
  3. replacePath 替换栈顶:登录页跳主页,替换栈顶不让用户返回登录页
  4. NavPathStack 持久化 :配合 @StorageLink,路由栈跨冷启动保留
  5. NavDestination 生命周期 :类似 Vue 的 onActivated/onDeactivated,做页面可见时刷新数据

写在最后

Navigation 这个东西,本质是把**「页面跳转」从命令式调用变成声明式数据**。你改栈顶数据,容器自己渲染对应页面------这和前端圈 React Router v6 的 loader、Vue Router 的 beforeEach 思路相通,只是鸿蒙用原生容器实现得更顺滑。

一旦你开始用栈思维写路由,你会发现大部分「跳转 + 返回 + 传参 + 动画」的需求,都是栈操作的自然结果。代码量少一半,bug 少九成,设计逮不住你跳转动画生硬。

代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手 push 一次再 pop 一次感受下栈操作。

跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀


作者:JaneConan 仓库:atomgit.com/JaneConan/a... 协议:Apache-2.0,随便用,别告我

相关推荐
千纸鹤安安18 小时前
如何看待 Go 1.18 引入的泛型?对 Go 开发者来说是必须掌握的吗?
后端
颜进强18 小时前
Claude Code -21 Agent 规划化编写范式
前端·后端
wing9819 小时前
通往全干之路之:被迫成为全栈
前端·后端·程序员
云上小朱20 小时前
在k8s部署alist 3.62.0
后端
花开彼岸天~20 小时前
鸿蒙原生开发手记:徒步迹 - 轨迹回放动画实现
后端
进击的前栈20 小时前
鸿蒙原生开发手记:徒步迹 - 文件读写与缓存管理
后端
布朗克16820 小时前
Go入门到精通-22-同步原语
开发语言·后端·golang·同步原语
云上小朱20 小时前
在docker环境部署Alist3.62.0
后端
wei_shuo20 小时前
KES 事务管理与MVCC深度解析:隔离级别、快照机制与并发控制
后端