前言

做鸿蒙应用页面结构时,我现在更倾向于把 Navigation 当成应用内部的主路由容器来设计。页面跳转、返回、替换、跨模块加载、分栏展示,这些能力放在同一套路由栈里处理,后续维护会轻很多。

以前写 Navigation 首页时,常见做法是把首页内容直接放在 Navigation 的闭包里。这个写法简单,但页面变复杂以后会出现一些不舒服的地方。首页内容和子页面结构不一致,生命周期也不完全一致;手机端、平板端、折叠屏展开态再叠加进来,首页会变成一个特殊页面,很多逻辑要单独处理。

HarmonyOS 6.0 API 20 给 Navigation 增加了新的重载接口:

Navigation(pathInfos: NavPathStack, homeDestination: HomePathInfo)

官方 API 参考中,Navigation(pathInfos: NavPathStack, homeDestination: HomePathInfo) 从 API version 20 开始支持,homeDestination 用来指定主页 NavDestination,HomePathInfo 通过 nameparam 组织主页信息。

这个变化对页面架构影响很大。首页不再直接写在 Navigation 闭包里,而是变成一个标准的 NavDestination。首页、详情页、设置页、列表页都可以放进同一套 NavDestination 体系里管理,页面跳转和生命周期处理也更统一。

一、首页变成 NavDestination 后,页面结构会更干净

Navigation 本身是路由导航的根视图容器,通常作为 Page 页面根容器使用,内部包含标题栏、内容区和工具栏。内容区既可以显示首页内容,也可以显示通过 NavDestination 管理的非首页页面。

传统写法一般是这样:

@Entry
@Component
struct Index {
  private stack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.stack) {
      Column() {
        Text('首页')
        Button('进入详情页')
          .onClick(() => {
            this.stack.pushPath({ name: 'PageDetail' });
          })
      }
      .width('100%')
      .height('100%')
    }
    .title('主页')
  }
}

这个写法适合小项目。首页内容直接放在 Navigation 里,按钮点击后把详情页压入路由栈。

问题会在项目变复杂后出现。详情页是 NavDestination,首页却是 Navigation 里的普通子组件。页面生命周期、标题控制、返回行为、参数接收方式都容易分叉。很多开发者会在首页和子页面之间写两套逻辑,短期能跑,后续改起来很麻烦。

API 20 的新写法把首页也改成 NavDestination:

@Entry
@Component
struct Index {
  private stack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.stack, { name: 'PageHome' }) {
    }
    .width('100%')
    .height('100%')
  }
}

这里的 { name: 'PageHome' } 对应路由表里的主页配置。Navigation 会根据 homeDestination 找到对应的 NavDestination 构建函数,创建主页内容。

这时候 Navigation 里的闭包不要再写首页 UI。主页 UI 放到 PageHome 对应的 NavDestination 里。这样一来,首页和子页面都走同一套路由表、同一个 NavPathStack、同一类生命周期回调。

二、路由表配置要先稳定下来

使用主页 NavDestination 时,路由表配置是基础。官方文档里,Navigation 页面路由操作基于 NavPathStack 提供的方法实现,每个 Navigation 都需要创建并传入一个 NavPathStack 对象。系统路由表通过 router_map.json 里的 name 找到对应页面信息并入栈。

模块配置文件里先声明路由表:

{
  "module": {
    "routerMap": "$profile:route_map"
  }
}

然后在 resources/base/profile 目录下创建 route_map.json

{
  "routerMap": [
    {
      "name": "PageHome",
      "pageSourceFile": "src/main/ets/pages/HomePage.ets",
      "buildFunction": "PageHomeBuilder",
      "data": {
        "description": "应用主页"
      }
    },
    {
      "name": "PageDetail",
      "pageSourceFile": "src/main/ets/pages/DetailPage.ets",
      "buildFunction": "PageDetailBuilder",
      "data": {
        "description": "详情页"
      }
    }
  ]
}

这里有几个地方要保持一致。

name 是路由名称,后续 Navigation(this.stack, { name: 'PageHome' })pushPath({ name: 'PageDetail' }) 都会用到它。

pageSourceFile 是页面源文件路径。路径写错时,路由表能加载,但跳转时找不到目标页面,调试时很容易误以为是 NavPathStack 的问题。

buildFunction 要和页面文件里的 @Builder 函数名称一致。官方跨包路由文档也提醒,跳转目标页面的入口 Builder 函数名称需要和 router_map.json 里的 buildFunction 保持一致,否则编译时会报错。

data 不是必须字段。它适合放一些描述性配置,或者后续做路由元信息管理。常规页面跳转参数还是放在 NavPathInfo.param 里更自然。

三、主页 NavDestination 里获取 NavPathStack

主页变成 NavDestination 后,跳转逻辑也放到主页组件内部处理。这个时候不建议从外部硬传一堆回调进去,直接在 onReady 里拿 NavDestinationContext 更清楚。

官方资料里提到,NavDestination 可以通过 onReady 事件拿到对应的 NavPathInfo 和所属的 NavPathStackonReady(callback: Callback<NavDestinationContext>) 从 API version 11 开始支持,会在 NavDestination 即将构建子组件前触发。

主页可以这样写:

@Component
struct PageHome {
  private stack?: NavPathStack;

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text('首页')
          .fontSize(24)
          .fontWeight(FontWeight.Medium)

        Button('进入详情页')
          .onClick(() => {
            this.stack?.pushPath({
              name: 'PageDetail',
              param: {
                id: 1001,
                source: 'home'
              }
            });
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
    }
    .title('主页')
    .onReady((ctx: NavDestinationContext) => {
      this.stack = ctx.pathStack;
    })
    .onShown(() => {
      console.info('PageHome shown');
    })
  }
}

@Builder
export function PageHomeBuilder() {
  PageHome();
}

这里不需要把 stack 声明成 @StateNavPathStack 负责页面栈操作,官方资料里也提到,NavPathStack 无需声明为状态变量,也可以实现页面栈操作。

主页里拿到 ctx.pathStack 后,就可以直接执行 pushPathpopreplacePath 等操作。首页和子页面统一放在 NavDestination 体系里以后,页面跳转的写法会更稳定,后续抽公共路由工具也更方便。

详情页也按同样的方式取栈和参数:

interface DetailParam {
  id: number;
  source: string;
}

@Component
struct PageDetail {
  private stack?: NavPathStack;
  @State private detailId: number = 0;
  @State private source: string = '';

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text(`详情 ID:${this.detailId}`)
          .fontSize(20)

        Text(`来源:${this.source}`)
          .fontSize(14)
          .fontColor('#666666')

        Button('返回')
          .onClick(() => {
            this.stack?.pop();
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
    }
    .title('详情页')
    .onReady((ctx: NavDestinationContext) => {
      this.stack = ctx.pathStack;

      const param = ctx.pathInfo.param as DetailParam;
      this.detailId = param.id;
      this.source = param.source;
    })
    .onBackPressed(() => {
      this.stack?.pop();
      return true;
    })
  }
}

@Builder
export function PageDetailBuilder() {
  PageDetail();
}

参数读取放在 onReady 里处理,能保证 NavDestination 上下文已经准备好。这里也顺手修掉了原稿里一个代码问题:详情页里使用 this.pathStack.pop(),但组件中没有定义 pathStack 字段。实际代码里要先从 ctx.pathStack 拿到栈引用,再调用 pop()

四、生命周期不要再按普通 Page 理解

Navigation 接入后,很多页面状态问题都和生命周期理解有关。NavDestination 有自己的生命周期回调,例如 onWillShowonShownonWillHideonHiddenonWillDisappear 等。官方 NavDestination 资料也能看到这些生命周期名称,页面显示、隐藏、销毁等阶段需要用 NavDestination 自身回调处理。

我通常会这样分工:

NavDestination() {
  // 页面内容
}
.onReady((ctx: NavDestinationContext) => {
  this.stack = ctx.pathStack;
  this.initParams(ctx);
})
.onWillShow(() => {
  console.info('page will show');
})
.onShown(() => {
  this.refreshVisibleData();
})
.onWillHide(() => {
  this.saveDraftIfNeeded();
})
.onHidden(() => {
  console.info('page hidden');
})

onReady 更适合做上下文初始化。比如保存 pathStack,读取 pathInfo.param,初始化页面级配置。

onShown 更适合做可见后的刷新。比如从详情页返回首页后刷新列表,或者重新读取当前选中的 Tab 状态。

onWillHide 更适合处理离开前保存。比如表单草稿、筛选条件、滚动位置。

onHidden 可以处理一些轻量级清理。比如暂停页面内的动画、视频、轮询。

主页 NavDestination 也会走这些回调。应用启动时会进入主页显示流程;从主页进入详情页时,主页会进入隐藏状态;返回主页时,主页再次进入显示流程。这个机制比普通 Page 生命周期函数更适合 Navigation 内部的多页面切换。

五、复杂路由场景先统一入口,再谈封装

NavPathStack 提供了很多操作方法,常用的有 pushPathpopreplacePathpopToNamepopToIndexmoveToTop 等。官方 Navigation 路由文档也明确,Navigation 路由相关操作都基于导航控制器 NavPathStack 提供的方法实现。

在业务里我会先收敛三个常用入口。

class AppRouter {
  constructor(private stack: NavPathStack) {}

  pushDetail(id: number) {
    this.stack.pushPath({
      name: 'PageDetail',
      param: {
        id,
        source: 'app_router'
      }
    });
  }

  back() {
    this.stack.pop();
  }

  backToHome() {
    this.stack.popToName('PageHome');
  }
}

这样页面里就不需要到处写路由名称。路由名称集中在一个地方,后续改 PageDetail 的命名、参数结构、跳转动画,都不会散落到每个页面组件里。

路由拦截也可以放在统一入口里处理。setInterception 可以监听页面切换、模式变化等事件,适合做埋点、登录判断、导航状态同步。

this.stack.setInterception({
  didShow: (from, to, operation, isAnimated) => {
    console.info(`from: ${from.pathInfo?.name}, to: ${to.pathInfo?.name}`);
  },
  modeChange: (mode) => {
    console.info(`Navigation mode changed: ${mode}`);
  }
});

多端适配时,Navigation 的价值会更明显。手机端通常是 Stack 模式,用户一层一层进入页面;平板和折叠屏展开态更适合 Split 或 Auto 模式,左侧保留列表,右侧展示详情。首页变成 NavDestination 后,手机端和大屏端可以尽量复用同一批页面组件,只在布局和展示模式上做差异处理。

模块化项目也适合尽早使用路由表。Navigation 支持系统路由表和自定义路由表两种实现方式,路由表配置可以完成本包和跨包页面跳转。

如果项目后面要拆 HAR 或 HSP,业务模块可以维护自己的页面构建函数和路由配置,主工程只负责聚合和调用。这样团队协作会轻很多,页面之间的依赖也更容易控制。

总结

API 20 的 Navigation(pathInfos: NavPathStack, homeDestination: HomePathInfo) 适合用来整理应用的根导航结构。首页通过 HomePathInfo 指定为 NavDestination 后,首页和子页面可以进入同一套路由管理体系。

落地时我会优先处理这几个点。

入口页面只负责创建 NavPathStack 并绑定主页;首页和子页面都写成 NavDestination;route_map.jsonnamepageSourceFilebuildFunction 保持一致;页面内通过 onReady 获取 ctx.pathStackctx.pathInfo.param;生命周期逻辑迁移到 NavDestination 对应回调里;复杂跳转尽量收敛到统一路由入口。

这套写法对中大型鸿蒙应用更友好。首页不再是特殊结构,子页面也不用单独维护一套路由习惯。后续要做平板适配、折叠屏展开态、跨模块页面加载、路由拦截和状态恢复,都会有更稳定的基础。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐