鸿蒙 Navigation 模块化实践

刚开始接触鸿蒙原生开发时,我对路由的理解很简单:从 A 页面跳到 B 页面,能传参数,能返回,基本就算完成任务。

做练习项目时,这样确实没什么问题。一个应用总共就三四个页面,跳转代码写在哪里都能找到。

但实际项目里的页面数量上来之后,情况就不一样了。

首页、内容、个人中心、设置等模块各自有一批页面。如果页面之间直接互相引用,或者到处手写路由字符串,时间一长就很容易出现这些问题:

  • 修改页面名称时,需要全局搜索。
  • 业务模块之间互相依赖。
  • 页面参数缺少统一约定。
  • 返回页面后刷新数据很麻烦。
  • 路由日志、无动画跳转等能力重复实现。

所以这次项目重构中,我觉得比较值得分享的一个点,就是基于 Navigation + NavPathStack + router_map.json 整理了一套路由方案。

它不是什么特别炫的功能,用户甚至感知不到,但对项目后续维护确实很有帮助。

整体思路

整个方案可以简单概括为:

入口页面维护一个导航栈,各业务模块注册自己的页面,跳转方只关心页面名称和参数。

假设项目按照下面的方式拆分:

text 复制代码
entry/
  应用入口

features/
  home/
  article/
  profile/
  settings/

commons/
  router/

entry 负责应用启动和根导航容器。

features 下的模块负责各自的页面和业务。

commons/router 则提供统一的路由接口和页面名称。

这样一来,文章列表页跳转到文章详情页时,不需要直接导入详情页组件,只需要知道它对应的路由名称。

入口页面只维护导航容器

首先在应用入口创建一个 NavPathStack,并将它传给 Navigation

typescript 复制代码
import { RouterManager } from '../router/RouterManager'
import { PageNames } from '../router/PageNames'

@Entry
@ComponentV2
struct MainPage {
  @Local navPathStack: NavPathStack = new NavPathStack()

  aboutToAppear(): void {
    RouterManager.init(this.navPathStack)
    RouterManager.replace(PageNames.HOME_PAGE)
  }

  build() {
    Navigation(this.navPathStack) {
      Column() {
        Text('应用启动中')
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
    .mode(NavigationMode.Stack)
    .hideTitleBar(true)
  }
}

这里最关键的地方,是整个应用共享同一份 NavPathStack

后续无论从首页进入文章页,还是从个人中心进入设置页,操作的都是这一个导航栈。

一开始我对 Navigation 的理解更接近一个普通容器。实际用下来才发现,它不仅负责展示页面,也承担着页面栈管理、参数传递和返回结果处理等工作。

每个模块注册自己的页面

假设现在有一个文章模块,它提供文章列表页和文章详情页。

首先在模块的 module.json5 中关联路由表:

json5 复制代码
{
  "module": {
    "name": "article",
    "type": "har",
    "deviceTypes": [
      "phone"
    ],
    "routerMap": "$profile:router_map"
  }
}

然后在模块的资源目录中创建 router_map.json

json 复制代码
{
  "routerMap": [
    {
      "name": "ArticleListPage",
      "pageSourceFile": "src/main/ets/pages/ArticleListPage.ets",
      "buildFunction": "articleListBuilder",
      "data": {
        "description": "文章列表页"
      }
    },
    {
      "name": "ArticleDetailPage",
      "pageSourceFile": "src/main/ets/pages/ArticleDetailPage.ets",
      "buildFunction": "articleDetailBuilder",
      "data": {
        "description": "文章详情页"
      }
    }
  ]
}

对应的页面文件中,需要提供与 buildFunction 同名的 Builder:

typescript 复制代码
@Builder
function articleDetailBuilder() {
  ArticleDetailPage()
}

@ComponentV2
struct ArticleDetailPage {
  build() {
    NavDestination() {
      Column() {
        Text('文章详情')
      }
      .width('100%')
      .height('100%')
    }
    .hideTitleBar(true)
  }
}

这几个地方需要对应起来:

text 复制代码
router_map.json 中的 name
        ↓
页面跳转时使用的路由名称

router_map.json 中的 buildFunction
        ↓
页面文件中的 @Builder 函数

router_map.json 中的 pageSourceFile
        ↓
Builder 所在的 ArkTS 文件

只要模块已经被应用依赖,并且 module.json5 正确关联了路由表,这些页面就可以通过系统路由表找到。

这样做以后,入口页面不需要手动导入所有业务页面,也不用维护一个越来越长的 PageMap

如果每个页面都直接操作 NavPathStack,就要想办法把导航栈传到各个地方。

为了让调用方式统一,可以封装一个静态的 RouterManager

typescript 复制代码
export class RouterManager {
  private static navPathStack: NavPathStack = new NavPathStack()

  static init(stack: NavPathStack): void {
    RouterManager.navPathStack = stack
  }

  static push(
    name: string,
    param?: object,
    onPop?: (popInfo: PopInfo) => void
  ): void {
    if (onPop) {
      RouterManager.navPathStack.pushPath({
        name,
        param,
        onPop
      })
      return
    }

    RouterManager.navPathStack.pushPathByName(name, param)
  }

  static pop(result?: object): void {
    if (result) {
      RouterManager.navPathStack.pop(result)
      return
    }

    RouterManager.navPathStack.pop()
  }

  static replace(name: string, param?: object): void {
    RouterManager.navPathStack.replacePathByName(name, param)
  }

  static popToName(name: string): void {
    RouterManager.navPathStack.popToName(name)
  }

  static clear(): void {
    RouterManager.navPathStack.clear()
  }
}

之后业务页面只需要调用:

typescript 复制代码
RouterManager.push(PageNames.ARTICLE_LIST_PAGE)

RouterManager.push(
  PageNames.ARTICLE_DETAIL_PAGE,
  { articleId: '1001' }
)

RouterManager.pop()

这里的封装并不复杂,甚至可以说非常薄。

但统一入口的意义不只是少写几行代码。以后如果需要增加路由日志、页面统计、无动画跳转或者登录拦截,都可以在同一个地方处理。

页面名称集中管理

系统路由表使用字符串作为页面名称。如果在业务代码里到处手写字符串,很容易出现拼写错误:

typescript 复制代码
RouterManager.push('ArticleDetialPage')

这里把 Detail 拼成了 Detial,编译器未必能帮我们发现。

一种简单的处理方式,是集中维护页面名称:

typescript 复制代码
export class PageNames {
  static readonly HOME_PAGE: string = 'HomePage'

  static readonly ARTICLE_LIST_PAGE: string = 'ArticleListPage'
  static readonly ARTICLE_DETAIL_PAGE: string = 'ArticleDetailPage'

  static readonly PROFILE_PAGE: string = 'ProfilePage'
  static readonly PROFILE_EDIT_PAGE: string = 'ProfileEditPage'

  static readonly SETTINGS_PAGE: string = 'SettingsPage'
}

跳转时使用常量:

typescript 复制代码
RouterManager.push(PageNames.ARTICLE_DETAIL_PAGE)

这样至少可以避免业务代码里散落大量魔法字符串,也更方便查找某个页面被哪些地方使用。

不过这里需要注意:常量和 router_map.json 之间并没有自动建立类型关系。

如果常量里写的是:

typescript 复制代码
static readonly ARTICLE_DETAIL_PAGE: string = 'ArticleDetailPage'

而路由表里不小心写成:

json 复制代码
"name": "ArticleDetailsPage"

还是可能在运行时出现问题。

所以集中管理只能降低出错概率,不能完全代替检查。

假设从文章列表进入详情页,需要传递文章 ID。

可以先定义参数类型:

typescript 复制代码
interface ArticleDetailRouteParam {
  articleId: string
}

跳转时传入参数对象:

typescript 复制代码
const param: ArticleDetailRouteParam = {
  articleId: '1001'
}

RouterManager.push(
  PageNames.ARTICLE_DETAIL_PAGE,
  param
)

目标页面可以在 NavDestinationonReady 中读取参数:

typescript 复制代码
@Builder
function articleDetailBuilder() {
  ArticleDetailPage()
}

@ComponentV2
struct ArticleDetailPage {
  @Local private articleId: string = ''

  private initPage(param?: ArticleDetailRouteParam): void {
    if (!param?.articleId) {
      return
    }

    this.articleId = param.articleId
    this.loadArticle()
  }

  private loadArticle(): void {
    // 根据 articleId 加载文章内容
  }

  build() {
    NavDestination() {
      Column() {
        Text(`文章ID:${this.articleId}`)
      }
      .width('100%')
      .height('100%')
    }
    .hideTitleBar(true)
    .onReady((context: NavDestinationContext) => {
      const param =
        context.pathInfo.param as ArticleDetailRouteParam | undefined

      this.initPage(param)
    })
  }
}

刚接触时,我会下意识地在组件生命周期里找路由参数。后来才慢慢习惯:对于 Navigation 页面,参数属于当前导航路径的信息,可以通过 context.pathInfo.param 获取。

还有一个容易忽略的问题:

typescript 复制代码
context.pathInfo.param as ArticleDetailRouteParam

这里的 as 只是类型断言,并不会在运行时检查参数是不是真的包含 articleId

因此目标页面最好还是处理一下参数为空或者字段缺失的情况,不要完全相信调用方。

返回上一个页面时携带结果

除了进入页面时传参数,NavPathStack 还支持在返回时携带结果。

这个能力很适合"列表页进入编辑页"的场景。

例如个人资料页进入资料编辑页,保存成功返回后,个人资料页需要重新加载数据。

先定义返回结果:

typescript 复制代码
interface ProfileEditResult {
  saved: boolean
}

进入编辑页时注册 onPop

typescript 复制代码
RouterManager.push(
  PageNames.PROFILE_EDIT_PAGE,
  undefined,
  (popInfo: PopInfo) => {
    const result =
      popInfo.result as ProfileEditResult | undefined

    if (result?.saved) {
      this.reloadProfile()
    }
  }
)

编辑页保存成功后带结果返回:

typescript 复制代码
RouterManager.pop({
  saved: true
} as ProfileEditResult)

如果用户只是点击返回,没有保存,则可以直接调用:

typescript 复制代码
RouterManager.pop()

这种方式的调用关系比较清楚:

  • 进入编辑页时,调用方注册结果处理。
  • 编辑页完成操作后,返回处理结果。
  • 原页面根据结果决定是否刷新。

相比使用全局变量保存"是否编辑成功",这种方式更容易理解,也不容易留下过期状态。

这套方案真正解决了什么

回头来看,亮点并不是封装了一个 push() 方法。

真正有价值的是下面这些事情被串在了一起:

  • 根页面只维护一份导航栈。
  • 每个业务模块注册自己的页面。
  • 跳转方不直接依赖目标页面组件。
  • 页面名称集中管理。
  • 页面参数有相对统一的传递方式。
  • 返回页面时可以携带处理结果。
  • 所有栈操作经过统一入口,方便后续扩展。

例如首页想进入文章列表,只需要:

typescript 复制代码
RouterManager.push(PageNames.ARTICLE_LIST_PAGE)

首页不需要导入 ArticleListPage,也不需要知道这个页面位于哪个文件夹。

以后文章模块调整页面目录,只要路由名称保持不变,调用方通常不需要跟着修改。

这也是我认为模块化路由比较有价值的地方:它让页面跳转依赖的是一个相对稳定的路由协议,而不是另一个模块里的具体组件。

几个容易踩的坑

这套方案用起来不算复杂,但配置项比较分散,刚接触时还是有一些容易忽略的地方。

Builder 名称没有对应

路由表里配置的是:

json 复制代码
"buildFunction": "articleDetailBuilder"

页面里却写成:

typescript 复制代码
@Builder
function detailPageBuilder() {
  ArticleDetailPage()
}

两边名字不同,页面自然无法按照配置完成构建。

排查这类问题时,最好同时检查 router_map.json 和页面文件。

路由目标页面需要以 NavDestination 作为页面根节点:

typescript 复制代码
@ComponentV2
struct ArticleDetailPage {
  build() {
    NavDestination() {
      Text('文章详情')
    }
  }
}

不要把它简单写成普通组件:

typescript 复制代码
@ComponentV2
struct ArticleDetailPage {
  build() {
    Column() {
      Text('文章详情')
    }
  }
}

普通组件可以放在页面里面,但不能代替目标页面的 NavDestination

只创建了 router_map.json

创建路由表后,还要在模块的 module.json5 中关联它:

json5 复制代码
"routerMap": "$profile:router_map"

同时,提供页面的 HAR 模块也需要被最终应用依赖。

只创建配置文件,并不代表应用一定能找到这些页面。

页面名称发生重复

页面名称是在应用路由范围内使用的标识。

如果不同模块都注册了一个叫 DetailPage 的页面,后面很难判断实际会找到哪一个。更稳妥的方式是带上业务含义:

text 复制代码
ArticleDetailPage
ProductDetailPage
OrderDetailPage

虽然名字长一点,但看代码时会清楚很多。

参数类型存在,运行时却不一定可靠

即使已经定义了接口,外部仍然可能传入错误的数据。

因此页面初始化时最好做基本检查:

typescript 复制代码
private initPage(param?: ArticleDetailRouteParam): void {
  if (!param || !param.articleId) {
    RouterManager.pop()
    return
  }

  this.articleId = param.articleId
}

关键参数缺失时,与其让页面带着空数据继续运行,不如记录日志或者安全返回。

一部分代码绕过了 RouterManager

如果项目已经选择通过 RouterManager 统一管理路由,业务代码最好不要再直接操作原始 NavPathStack

否则后续在 RouterManager 中增加日志、页面状态记录或者拦截逻辑时,绕过管理器的跳转就不会被处理。

最后说两句

作为一个刚入门鸿蒙原生开发的工程师,我一开始更关注页面怎么写、组件怎么摆、状态怎么刷新。路由看上去只是负责跳页面的工具,很难成为最先关注的部分。

但实际项目里,路由正好处在各业务模块的连接位置。

这套方案当然也不是"一次设计,以后再也不用调整"。例如路由常量和配置文件之间还缺少编译期校验,参数类型也主要依靠开发约定。

不过相比页面互相导入、到处手写路由名称,它已经让项目结构清楚了不少。

参考资料

相关推荐
woshihuanglaoshi1 小时前
参数验证_Flutter在鸿蒙平台确保路由参数类型安全
学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
绝世番茄1 小时前
鸿蒙原生 ArkTS 布局之 List 的单选与多选模式实战指南
华为·list·harmonyos·鸿蒙
胡琦博客2 小时前
HarmonyOS 智能工具箱(三):语音交互工具
交互·xcode·harmonyos
国服第二切图仔2 小时前
HarmonyOS7新特性之沉浸光感:新一代材质渲染技术解析
harmonyos
●VON2 小时前
鸿蒙 PC Markdown 编辑器链接补全与离线校验:中文路径、标题锚点与授权边界
华为·编辑器·harmonyos·鸿蒙
kiros_wang2 小时前
鸿蒙性能优化全维度实战(启动速度 + 内存治理 + 帧率稳定 + 包体积瘦身)
华为·性能优化·harmonyos
●VON2 小时前
鸿蒙 PC Markdown 编辑器快速打开:模糊排序、最近权重与纯键盘路径
华为·编辑器·计算机外设·harmonyos·鸿蒙
●VON2 小时前
鸿蒙 PC Markdown 编辑器命令面板:把桌面高频操作收束到一个入口
服务器·华为·编辑器·harmonyos·鸿蒙
爱写代码的森2 小时前
鸿蒙三方库 | harmony-utils之FileUtil文件拷贝与移动详解
华为·harmonyos·鸿蒙·huawei