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

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

写在前面

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

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

你一开始的处理方式通常是:onClick 里 router.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。它自带标题栏、返回按钮、页面生命周期,你只管在里面写页面内容。

② pathStack 从 onReady 取

NavDestination 在 onReady 回调里给你一个 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,随便用,别告我

相关推荐
大鸡腿同学4 小时前
手心向上,手心向下
后端
CCblandy5 小时前
令牌桶在 0.9 倍负载超发 19%、固定窗口边界放行 20 个:四种限流算法同构实测
后端·python·算法·性能优化·架构
凤山老林6 小时前
Spring Boot 集成 Sa-Token 实现高可用权限认证:登录鉴权、动态路由权限与接口防重放实战
spring boot·后端·sa-token·bootstrap
Bazingga7 小时前
一次推理的完整旅程:ReAct推理深度解析
后端
姜鱼问生8 小时前
Nginx gzip 压缩:一个配置让页面加载快 3 倍
后端·算法·架构
颜酱8 小时前
一栋商业大厦,讲清 InnoDB 的表空间、段、区和页
后端·mysql
码事漫谈8 小时前
1956 亿行数据该怎么存:金仓时序数据库的三个实测
后端
Wx-bishekaifayuan8 小时前
springboot桂味美食分享系统40540-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
彭亚川Allen8 小时前
从零手搓 RAG 之检索全流程,给大模型接上自己的知识库
前端·后端·ai编程
IT_陈寒8 小时前
别让Java字符串拼接偷偷吃掉你的内存
前端·人工智能·后端