资讯详情

资讯详情

鸿蒙Navigation实战:路由栈、参数传递与登录守卫

搞定了前面三篇的组件基础之后页面导航是鸿蒙开发里绕不开的一道坎。如果你之前像我一样习惯用 router 那套写页面跳转项目一复杂就开始别扭路由字符串满天飞、传参数只能序列化、页面之间根本不知道彼此在干什么。这一篇我不想再停在“能跑就行”的层面而是把一个成熟项目里真正可用的 Navigation 导航方案讲透包括 NavPathStack 路由栈怎么玩、NavDestination 页面骨架怎么搭、参数怎么传、动画怎么配最后给一套带登录守卫的完整工程骨架。内容不少建议直接收藏遇到问题回来翻。1. 为什么 Navigation 是鸿蒙页面导航的必选项1.1 老路由方式的三个硬伤刚接触鸿蒙开发的时候页面跳转最直白的路子就是router.pushUrl({ url: pages/Detail })。单看这一步没什么问题但等你做到十几个页面互相跳来跳去老办法的痛点会非常突出。第一个硬伤是路由注册字符串无法被编译期检查。写错一个路径IDE 不会报错运行到那一步才闪退页面多了以后非常难受。第二个硬伤是参数传递。router 的 params 对这种简单场景够用可真要传一个复杂的对象——比如订单详情、列表项快照——就得手工序列化/反序列化。更麻烦的是页面 B 要把结果回传给页面 A要么写在全局变量里要么借助事件能力绕一圈代码维护成本直线上升。第三个硬伤是页面生命周期与路由栈隔离。router 是“每个页面各管各的”页面退出了就是退出了想精准知道哪个页面从栈里弹出来了、想做一些返回后的数据联动几乎没有现成的钩子。这些痛点倒不是说老方案错而是它撑不起大型项目的复杂度。官方后续也明确调整了路线把 Navigation 作为长期推荐的导航方案并在新框架里接棒 router 成为页面跳转的真正主角。1.2 Navigation 带来的核心变化Navigation 看起来只是一个组件实际上它把“路由”这个概念从“跳一下”升级成了“一套页面容器体系”。第一是类型安全。它的NavPathStack.pushPathByName()或pushPath()可以在编译期感知页面是否存在、参数类型是否匹配不再是一堆看不见摸不着的字符串拼接。第二是单容器多页面。所有通过 Navigation 推入的页面都挂载在同一个 UIAbility 内由一个容器统一调度。这样既减少了页面实例化的开销也让转场动画、导航栏、返回逻辑完全可控。第三是状态与栈式管理。Navigation 内置NavPathStack路由栈栈上每个节点对应一个NavDestination页面。你能精确控制入栈、出栈、替换、回退、清空甚至能从路由中取出某个页面的参数、判断当前栈顶是谁。开发复杂业务时这是质的区别。简单说Navigation 就是鸿蒙版的 App 导航大管家。我认识的一位搞了三年鸿蒙的老哥总结得直白老路由方式是公交车上喊一嗓子“下一站到了”坐过站了谁也不知道Navigation 则是列车控制台每一节车厢的位置、状态、上下车情况都在你手里。1.3 项目里到底怎么选型如果你是全新项目没什么好纠结的直接用 Navigation。如果是老项目想把 router 页面迁移过来也不用一次推倒重来可以把新页面逐步往 Navigation 体系里迁老页面先用原有逻辑维持。实际业务中弹窗、半屏页面这类轻量场景用系统的模态组件更顺手没必要硬塞进路由栈里。核心原则是需要栈式管理、需要状态联动、需要跨页面回传的页面跳转全部交给 Navigation剩下的轻交互保持简单。2. NavPathStack 路由栈核心 API 与实战用法2.1 路由栈是怎么一回事NavPathStack 本质上是一个后进先出的栈结构。你可以把它想象成餐厅里一叠盘子往上面放盘子就是 push取走最顶上的盘子就是 pop想从中间抽掉某个不用的盘子就是 remove。系统托盘里每次只能看到最顶上的页面但下面的页面还活着状态往往也还保留着。这个“状态保留”非常关键。如果你用 router 跳转大多数情况下页面一退就销毁了而 Navigation 栈里的页面默认会保留实例状态从 A 推到 B再退回到 AA 的滚动位置、表单内容都还在。体验接近原生 iOS/Android 的页面栈。2.2 核心 API 速查表路由栈的常用操作我都整理成表格了实际项目中基本就是反复用这些方法作用典型场景pushPathByName(name, param, animated?)按页面名将页面压入栈顶普通页面跳转比如进详情页pushPath(info)压入一个携带页面构建器的对象类型安全的跳转方式replacePathByName(name, param)用新页面替换当前栈顶页面登录成功后把登录页换成首页pop(result?)弹出栈顶页面可携带返回值返回上一页并回传数据popToName(name, result?)弹栈直到指定页面位于栈顶回到列表页并刷新popToRoot(result?)弹到栈底即第一个页面回首页清掉中间页面removePathByName(name)按页面名移除栈中页面清理已经不需要的页面getParamByName(name)获取某页面入栈时携带的参数在页面里读取路由参数getCurrentIndex()获取当前栈顶页面的索引判断当前处于哪个阶段clear()清空整个路由栈登出时清空所有页面注意这些 API 是给Navigation组件配套的使用前必须先创建一个NavPathStack实例然后把它传给Navigation容器。2.3 路由栈的创建、传递与单例问题窗口页面里最标准的用法是这样Entry Component struct Index { pathStack: NavPathStack new NavPathStack() build() { Navigation(this.pathStack) { // 主页面内容 } } }这样创建的栈只在当前组件内可用。但实际项目里很多页面并不直接是 Navigation 所在的窗口页面而是通过路由推入的 NavDestination 子页面。如果每个页面都 new 一个 NavPathStack那整个路由体系就断成两截了。我的做法是把路由栈做成一个全局单例挂在工具模块里// common/RouterManager.ets export class RouterManager { static pathStack: NavPathStack new NavPathStack() }然后窗口页面的 Navigation 绑定这一个实例。子页面里要用栈操作时直接RouterManager.pathStack.pushPathByName(...)或pop()全工程只此一份谁都不会拿错。AppStorage 也能存实例但单例更直白调试时也更容易追踪栈状态。创建好路由栈之后我强烈建议你写一个小工具函数把 push、pop、replace 都包一层统一处理错误日志和业务埋点。否则后面代码里全是裸调的pushPathByName排查问题时像是进了没有路灯的隧道。3. 一个页面骨架是怎么搭起来的3.1 三个组件各管什么Navigation 体系的三件套很容易混淆我先把它分清楚Navigation最外层的页面容器决定整棵页面树的结构承载路由栈。NavDestination栈中每个页面节点的载体相当于一个“路由页面容器”。NavBar导航栏NavDestination 自带的顶部导航条可以设置标题、菜单按钮。层级关系大致是这样的Navigation容器 ├── 首页内容Navigation 的子内容 └── NavDestination由路由推入的页面 ├── NavBar导航栏标题栏、返回按钮等 └── 页面内容很多新手把NavDestination和普通Column混为一谈直接拿一个Builder函数去渲染内容也不包NavDestination。结果顶部没有原生返回按钮、转场动画也不会生效、更拿不到生命周期回调——因为这些能力本来就属于 NavDestination。3.2 用 Builder 注册页面项目里我最常用的注册方式是“命名路由 条件 Builder”每接入一个页面在路由 Builder 里加一个条件分支即可Builder pageMap(name: string, param: unknown) { if (name Login) { NavDestination() { LoginPage() } .title(登录) .onReady((context: NavDestinationContext) { // 页面就绪可以做初始化 }) } else if (name Home) { NavDestination() { HomePage() } .title(首页) } else if (name Detail) { NavDestination() { DetailPage({ item: param as DetailItem }) } .title(详情) } }外层Navigation使用这个 BuilderNavigation(this.pathStack) { this.pageMap(Home, null) }调用跳转时RouterManager.pathStack.pushPathByName(Detail, selectedItem)这套方案在 API 10 以后的官方推荐路径中非常常见。它的好处是页面注册入口集中页面名和构建逻辑一一对应排查“页面找不到”之类问题时效率高。3.3 生命周期钩子别再乱放代码每个 NavDestination 会经历几个阶段分别是onReady、onShown、onHidden对应“页面可用、页面可见、页面不可见”。这三个阶段的意义新手很容易搞混。以我的经验做一个分工建议onReady页面初始化适合设置标题、配置导航栏菜单、准备页面数据源。此时页面还没有展示不适合做频繁动画。onShown页面真正出现在用户面前。适合发起网络请求、开始统计、触发埋点。这样用户看到的页面就是有数据的状态不会白屏等半天。onHidden页面被推到后台比如又 push 了一个新页面。适合暂停视频播放、停止轮询、清理定时器、保存草稿状态。项目里我曾经见过把网络请求丢在build()或aboutToAppear里的写法结果每次页面还没完全进场就开始请求用户体验乱成一团。把生命周期用对页面状态就不会奇怪。4. 参数传递、数据回传与页面转场动画4.1 三种参数传递方式Navigation 体系下参数传递比 router 舒服太多。我常用的有三种方式第一种是“路由参数”传法。pushPathByName(Detail, param)时直接丢参数对象NavDestination内部用param拿到即可。可以传对象、数组、基本类型不用像以前那样序列化。第二种是“Builder 闭包捕获”传法。不用命名路由而是直接在 push 时传一个构建函数外界数据通过闭包直接捕获进来。这种方式类型约束最强同一个页面不同的数据来源也可以通过不同 Builder 组装。第三种是“依赖注入”传法。页面不直接接收参数而是从订阅管理中心、状态管理器里读取数据。适合大型工程页面解耦干净但也要避免过度设计。普通项目里命名路由 参数对象已经能覆盖 80% 以上的业务场景。4.2 返回时怎么把数据带回去Navigation 的回传机制最让人舒服的是pop(result)可以直接携带一个返回值。配合上一个页面的onShown或路由状态监听就能完成数据联动。我给一个最简单的示例思路。假设有个选择城市页面点击城市后返回上一页// 城市选择页内 onCityClick(city: City) { routerPathStack.pop(city) }上一页onShown时通过getParamByName拿回上一个页面入栈时对应的参数或者用结果拦截来更新 UI。实操中最干净的方式是把“上一页”需要的回调函数对象也定义成路由参数的一部分子页面在合适的时机调用回调。这样数据回传的路径非常明确不会出现“数据到底回传给了谁”的迷惑。我踩过一个坑为了省事直接在onShown里无条件刷新页面结果每次从子页面返回都重新请求接口列表闪烁严重。正确的做法是让子页面明确“是否发生了需要刷新的变化”如果没有变化上一页就原地不动保留用户已经浏览到的位置。4.3 自定义转场动画的两个实战示例默认的 push/pop 转场已经不错了但电商、阅读、多媒体应用通常需要更个性的动画。Navigation组件提供了pageTransition属性我们可以统一配置转场效果。基础配置示例Navigation(this.pathStack) { // ... } .pageTransition({ transition: (type: PageTransitionType, mode: PageTransitionMode) { const options { curve: Curve.EaseInOut, duration: 300 } if (type PageTransitionType.Push) { return mode PageTransitionMode.Entry ? transitionAnimation({ translate: { x: 100% } }, options) : transitionAnimation({ translate: { x: -20% } }, options) } if (type PageTransitionType.Pop) { return mode PageTransitionMode.Entry ? transitionAnimation({ translate: { x: -20% } }, options) : transitionAnimation({ translate: { x: 100% } }, options) } return undefined } })这段做的是经典的“右侧滑入、左侧淡出”效果。除了translate位移还可以搭配scale、opacity、rotate自由度很高。我在一个阅读类项目里做了“当前页轻微放大 透明度变化”的转场用户反馈沉浸感强不少。如果你想让某个页面走特殊的卡片式浮层效果也可以单独在应用内设置 ContainerTransform 或自定义浮层不过日常页面用系统转场就够了。转场别花哨过头动效时间超过 500ms 用户就会觉得卡。共享元素转场是另一个加分项。两个页面之间图片、标题等元素可以“穿梭”过去视觉连续性非常强。鸿蒙里给目标元素加上sharedTransition配置再在页面跳转时保持同一个 id就能实现平滑过渡。这个功能适合列表到详情页的场景强烈建议一试。5. 完整实战带登录守卫的导航骨架5.1 目录结构与先决条件空讲概念容易飘我直接给一套我在项目里反复使用的导航骨架。假设我们要做一个包含登录、首页、列表、详情四个页面的演示项目页面不多但已经能把 Navigation 核心特性都用上。先看目录结构entry/src/main/ets/ ├── common/ │ └── RouterManager.ets // 全局路由栈单例 ├── pages/ │ ├── Index.ets // Navigation 容器窗口 │ ├── LoginPage.ets // 登录页 │ ├── HomePage.ets // 首页 │ ├── ListPage.ets // 列表页 │ └── DetailPage.ets // 详情页 ├── model/ │ └── ItemModel.ets // 数据模型 └── viewmodel/ └── UserViewModel.ets // 登录状态管理我习惯把路由栈单例放在common下业务页面、数据模型、状态管理分开放。别把路由栈写进某个业务页面里不然换个页面就“找不着北”。5.2 路由栈单例与容器窗口RouterManager 的写法如下// common/RouterManager.ets export class RouterManager { static pathStack: NavPathStack new NavPathStack() static pushLogin() { this.pathStack.clear() this.pathStack.pushPathByName(Login, null) } static pushHome() { this.pathStack.clear() this.pathStack.pushPathByName(Home, null) } static pushDetail(item: ItemModel) { this.pathStack.pushPathByName(Detail, item) } }Index.ets 里绑定这个单例// pages/Index.ets Entry Component struct Index { build() { Navigation(RouterManager.pathStack) { this.pageMap(Home, null) } } Builder pageMap(name: string, param: unknown) { if (name Login) { NavDestination() { LoginPage() } .title(登录) .onReady(() { console.info(Login page ready) }) } else if (name Home) { NavDestination() { HomePage() } .title(首页) } else if (name Detail) { NavDestination() { DetailPage({ item: param as ItemModel }) } .title(详情) } } }窗口页面的根部只用 Navigation不在它外层再套Column或Row这是个容易忽略的细节。Navigation 本身具备完整的布局能力多余容器反而会让页面层级和转场动画出问题。5.3 登录守卫是怎么拦截跳转的所谓“登录守卫”就是用户没有登录时点击需要权限的页面不直接进而是先弹到登录页登录成功后再自动回到目标页。实现这个逻辑最顺手的位置是在 RouterManager 里写统一的跳转方法static checkLogin(): boolean { const user UserViewModel.getInstance().currentUser return user ! null user.token ! } static guardJump(name: string, param: unknown) { if (this.checkLogin()) { this.pathStack.pushPathByName(name, param) } else { // 记录目标页面登录成功后跳回 this.pendingTarget { name, param } this.pathStack.pushPathByName(Login, null) } }登录页面里登录接口返回成功后RouterManager.pathStack.pop() RouterManager.pathStack.replacePathByName(Home, null)注意这里是replacePathByName不是pushPathByName。如果用push登录页会残留在栈里用户返回到头还能回到登录页体验上很别扭。必须把 Login 出栈、Home 入栈或者干脆用 replace 一步到位。彻底退出登录时清空整个路由栈然后重新进登录页RouterManager.pathStack.clear() RouterManager.pathStack.pushPathByName(Login, null)5.4 页面间的跳转调用方式首页里点击一个商品列表项时代码是这么写的// HomePage.ets onItemClick(item: ItemModel) { RouterManager.guardJump(Detail, item) }详情页里拿到参数// DetailPage.ets Component export struct DetailPage { Prop item: ItemModel null build() { Column() { Text(this.item?.name ?? ) Text(this.item?.price?.toString() ?? ) } .width(100%) .height(100%) .padding(16) } }参数在pageMap里显式转换类型要么传any要么做断言。类型安全比想象中重要一旦参数类型不对页面里拿到的可能是undefined运行半天才发现是路由参数丢了。为了稳妥我习惯在pageMap分支里加一个判空if (name Detail) { if (param ! null param ! undefined) { // 正常渲染 } else { // 打印错误日志渲染兜底视图 } }这一个小步骤能节省不少夜间排查时间。6. 高频踩坑与排查记录6.1 页面转场首帧白屏现象是页面跳转动画刚开始时目标页内容区域是白的等动画快结束了才显示内容。经常是把网络请求放在onReady或页面构造函数里动画已经开始了数据还没到。解决方案是业务数据统一在onShown之后触发请求并且列表先给一个骨架屏或缓存兜底。另外一个坑是页面里用了很大的图片资源转场过程中解码占用了大量主线程这种问题要配合图片懒加载/缩略图来处理。6.2 pop 回去后列表不刷新我在 4.2 提到过这个。除了无脑刷新带来的闪烁还有一种情况是 list 数据源被直接修改了但 UI 没有感知。列表页的Foreach如果渲染的是数组的浅拷贝子页面修改内容后上一页看到的还是切片副本。经验是把列表数据放到 ViewModel 的Observed类里或者让列表页在拿到回传结果后主动替换对应字段并强刷新。优先选用可观察数据源比手动“刷一下”可靠。6.3 命名路由找不到页面黑屏明明在pageMap里写了对应分支push 过去还是黑屏。我从实操里总结出三种最常见原因第一种页面名不一致。pushPathByName里的 name 和pageMap判断的 name 差了一个空格或大小写。第二种NavDestination 没有包在正确的 Navigation 作用域里。你得确保pageMap分支里的NavDestination()是在导航容器内注册的不是在某个普通组件里。第三种Builder 里条件分支过早 return 了空组件导致 NavDestination 没被创建。排查黑屏时先把 Builder 分支简化为只渲染一个纯色背景确认路由本身通不通再一步步加回业务内容。6.4 底部 Tab 场景的多实例状态管理很多项目的首页底部有若干个 Tab每个 Tab 内部又各自维护页面栈。这时全局单例栈就不完全够用了因为它只能维护一个栈。更合理的做法是每个 Tab 维护一个独立的NavPathStack分别绑定到各自的 Navigation 上。Tab 切换时系统通常会保存各 Tab 栈的页面状态这也是 Navigation 相对 router 的大优势每个 Tab 的浏览进度互不干扰。如果你确实需要跨 Tab 跳转比如从 Tab A 直接进入 Tab B 的某个子页面那就要在对应 Tab 的栈实例上做 push并显式切换 Tab。我的经验是跨 Tab 跳转尽量少做否则状态管理复杂度会直线上升。6.5 路由栈里的内存泄漏与页面堆积Navigation 的页面默认会真真切切地保留在栈里页面只进不出时内存堆积是非常快的。别把NavDestination当成普通的Column随手就 push。正常业务中走完详情流程回到列表后考虑用removePathByName把已经没用的详情页从栈里移除尤其涉及长列表和图片很多的页面。另一个隐蔽泄漏点是页面里注册了系统事件订阅、定时器onHidden里只暂停不注销最后被反复入栈出栈时订阅越积越多。通用规则是凡是在onShown开启的东西能在onHidden暂停的暂停能注销的注销。我自己维护项目时还会定期打印一遍当前getPathStack()的快照检查栈里到底堆了哪些页面。看到高度低于预期就顺手做一次栈修剪这个小习惯帮我避免过不少莫名其妙的内存抖动。7. 写在最后的个人体会Navigation 这套体系刚上手时确实比 router 多了一道理解门槛但一旦跨过去整个项目的页面组织会变得非常清爽。我个人最深刻的一点体会是不要急着把代码写满而是先画清楚你的页面栈长什么样、谁在上谁在下、返回时要发生什么再动手写路由代码。导航设计的质量直接影响 App 在用户手里的顺滑感这比单个页面做得多么炫酷更重要。如果你正卡在某个跳转问题上大概率能从上面这六类问题里找到影子。真要是还有新的坑也别慌先回到路由栈本身把入栈、出栈、状态、参数四条线捋一遍问题往往就清晰了。后续我还会继续记录鸿蒙开发里的实用组件和应用架构实践这个系列咱们慢慢聊。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →