鸿蒙系统路由模块研究与使用

234 阅读12分钟

背景知识

在做鸿蒙应用的时候,导航是必不可少的底层能力。鸿蒙文档提供了两套路由方案。

  1. router

这个是鸿蒙最初鸿蒙设计的一套路由方案,已经被官方标记为不推荐,就没什么好学的了。大致看了文档,这套方案偏向传统小屏移动端路由,不太适用鸿蒙主推的一次开发,多端部署开发范式。

  1. Navigation

Navigation组件适用于模块内和跨模块的路由切换,一次开发,多端部署场景。在不同尺寸的设备上,Navigation组件能够自适应显示大小,自动切换分栏展示效果。也在目前鸿蒙官方针对路由主推的方案,我这边在学习Navigation组件过程中积累的经验和大家分享一下。

Navigation核心思想

这部分知识其实鸿蒙官方文档都有提供,我主要总结几个核心知识点,了解这些就可以轻松拿捏鸿蒙的路由相关知识了。

页面显示模式

Navigation组件的mode属性。目前该组件提供个两种显示模式,单页面模式和分栏模式,很明显,一个适用小屏手机,另一个适合于pad或者折叠屏手机。所以我们在使用的时候可以把mode属性设置为NavigationMode.Auto,这样在页面宽度大于等于600vp的使用分栏模式,否则自动使用单页面模式。

单页面模式NavigationMode.Stack分栏模式NavigationMode.Split
Navigation() {
  ...
}
.mode(NavigationMode.Auto)

Navigation和NavDestination

NavigationNavDestination都是容器组件,必须配合使用,一般用在程序入口页面使用Navigation包裹,子页面使用NavDestination包裹,然后使用NavPathStack配合navDestination属性进行页面路由。

NavPathStack

Navigation路由栈。这个类用来管理路由。如果做过移动端开发,这个很好理解,提供了常见的路由调整方法,如push, replacepop等。可以通过相关的接口去实现页面跳转。可以获取页面跳转中传递的参数

1. 普通跳转,通过页面的name去跳转,并可以携带param。

    this.pageStack.pushPath({ name: "PageOne", param: "PageOne Param" })
    this.pageStack.pushPathByName("PageOne", "PageOne Param")

2. 带返回回调的跳转,跳转时添加onPop回调,能在页面出栈时获取返回信息,并进行处理。

    this.pageStack.pushPathByName('PageOne', "PageOne Param", (popInfo) => {
    console.log('Pop page name is: ' + popInfo.info.name + ', result: ' + JSON.stringify(popInfo.result))
    });

3. 带错误码的跳转,跳转结束会触发异步回调,返回错误码信息。

    this.pageStack.pushDestinationByName('PageOne', "PageOne Param")
    .catch((error: BusinessError) => {
        console.error(`Push destination failed, error code = ${error.code}, error.message = ${error.message}.`);
    }).then(() => {
    console.error('Push destination succeed.');
    });

4.页面返回

// 返回到上一页
this.pageStack.pop()
// 返回到上一个PageOne页面
this.pageStack.popToName("PageOne")
// 返回到索引为1的页面
this.pageStack.popToIndex(1)
// 返回到根首页(清除栈中所有页面)
this.pageStack.clear()

5.页面替换

// 将栈顶页面替换为PageOne
this.pageStack.replacePath({ name: "PageOne", param: "PageOne Param" })
this.pageStack.replacePathByName("PageOne", "PageOne Param")

6. 页面删除

// 删除栈中name为PageOne的所有页面
this.pageStack.removeByName("PageOne")
// 删除指定索引的页面
this.pageStack.removeByIndexes([1,3,5])

7. 获取页面传递参数

// 获取栈中所有页面name集合
this.pageStack.getAllPathName()
// 获取索引为1的页面参数
this.pageStack.getParamByIndex(1)
// 获取PageOne页面的参数
this.pageStack.getParamByName("PageOne")
// 获取PageOne页面的索引集合
this.pageStack.getIndexByName("PageOne")

navDestination

navDestinationNavigation组件的属性,我们可以叫他路由表。如果说NavPathStack是路由栈,那navDestination就是页面映射,可以通过一个特定字符串来映射具体的调整页面,这样映射之后,NavPathStack就可以使用字符串来进行跳转;

Navigation(RouterModule.router) {
  ...
}
.navDestination(this.routerMap);

如下PageOne就是一个字符串,他映射到一个具体的页面组件。这样的好处就是路由跳转和具体页面组件进行了解耦。

this.pageStack.pushPath({ name: "PageOne", param: "PageOne Param" })

路由基本解决方案

针对一些Demo,以及小型项目,可以使用这个方案。当然对于学习Navigation组件,理解这套方案也是必须的。

考虑到鸿蒙开发工具的兼容性问题,本文Demo的开发工具版本

如下图所示。

1. 新建一个鸿蒙空白项目,在Index.ets新增NavPathStack实现

Index.est页面初始化一个NavPathStack对象,这个对象需要和Navigation绑定,然后就可以使用pageInfos实例进行页面跳转了。

pageInfos使用@Provide这个装饰器,后面的页面就可以使用@Consume来获取这个实例。也可以使用@StorageLink装饰器,这属于鸿蒙状态管理范畴,先不表了。

@Provide('pageInfos') pageInfos: NavPathStack = new NavPathStack()

2. 编写页面映射Builder

这个PageMap方法就是提供给navDestination属性使用的对象,里面提供了路由映射,比如字符串NavDestinationTitle1可以映射到pageOneTmp()页面。代码还是在Index.est页面。

@Builder
PageMap(name: string) {
  if (name === "NavDestinationTitle1") {
    pageOneTmp()
  } else if (name === "NavDestinationTitle2") {
    pageTwoTmp()
  } else if (name === "NavDestinationTitle3") {
    pageThreeTmp()
  }
}

3 编写首页build方法

进入Index.ets,编写build()代码。点击页面中的按钮,就会调用 this.pageInfos.pushPath({ name: "NavDestinationTitle" + item})来就行了路由跳转了。

build() {
  Column(){
    Navigation(this.pageInfos){
      List({ space: 12 }) {
        ForEach(this.arr, (item:string) => {
          ListItem() {
            Text("NavRouter" + item)
              .width("100%")
              .height(72)
              .backgroundColor('#FFFFFF')
              .borderRadius(24)
              .fontSize(16)
              .fontWeight(500)
              .textAlign(TextAlign.Center)
              .onClick(()=>{
                this.pageInfos.pushPath({ name: "NavDestinationTitle" + item})
              })
          }
        }, (item:string):string => item)
      }
      .width("90%")
      .margin({ top: 12 })
    }.title("鸿蒙路由")
    .mode(NavigationMode.Stack)
    .navDestination(this.PageMap)
    .titleMode(NavigationTitleMode.Mini)
    .hideBackButton(true)
  }.height('100%')
  .width('100%')
  .backgroundColor('#F1F3F5')
}

4. 编写子页面

在第二步中写了3个子页面pageOneTmppageTwoTmppageThreeTmp。这3个页面除了了名字,其他内容都一样,这边就贴了pageOneTmp页面的的代码了。


@Component
export struct pageOneTmp {
  @Consume('pageInfos') pageInfos: NavPathStack;

  build() {
    NavDestination() {
      Column() {
        Text("NavDestinationContent1")
      }.width('100%').height('100%')
    }.title("NavDestinationTitle1")
    .onBackPressed(() => {
      const popDestinationInfo = this.pageInfos.pop() // 弹出路由栈栈顶元素
console.log('pop' + '返回值' + JSON.stringify(popDestinationInfo))
      return true
})
  }
}

处理完这几步,就可以实现路由了,相对来说还是挺简单的。对于一些简单的应用,或者Demo,可以使用这个路由方案,相对简单。

路由高级解决方案

使用上面的方法,会存在几个问题。

  • 使用Navigation时,所有路由页面需要主动通过import方式逐个导入当前页面,并存入页面路由表routerMap中。
  • 主动使用import的方式需显性指定加载路径,造成开发态模块耦合严重。
  • 路由系统不够独立,不太好在全局任何地方进行路由跳转
  • 大型应用一般都存在多个相对独立的模块,独立模块如何相互路由,使用这种方式很难实现。

正因为有以上等问题的存在,针对中大型应用,更加推荐使用自定义动态路由,来吧,开整!

整体思路

定义一个RouterModule模块,RouterModule模块包含全局的路由栈和路由表信息。路由栈是NavPathStack对象,该对象与Entry.hapNavigation组件绑定,RouterModule通过持有NavPathStack管理Navigation组件的路由信息。路由表builderMapMap结构,以key-vaule的形式存储了需要路由的页面组件信息,其中key是自定义的唯一路由名,value是WrappedBuilder对象,该对象包裹了路由名对应的页面组件。相信大家看完这段话会云里雾里,没关系,看完下文的实操,再回头来看这段话就会柳暗花明。

大致模型如下:

RouterModule模式显示实现路由相关的工作;

其他模块都依赖RouterModlue;

各位独立的模块都使用RouterModule进行路由;

暂时无法在飞书文档外展示此内容

在开始编程之前,还需要知道鸿蒙的一个能力,就是动态import。动态import支持条件延迟加载,支持部分反射功能,可以提升页面的加载速度;动态import支持加载HSP模块/HAR模块/OHPM包/Native库。使用其实很简单,正常我们import一个文件或者一个har库,都写在文件的顶部。比如

import {deviceInfo} from '@kit.BasicServicesKit';

而动态import,import就变成了一个全局方法,接受路径参数,当然要保证页面或者库存在。

import('harlibrary').then((ns:ESObject) => {
  ns.Calc.staticAdd(8, 9);  // 调用静态成员函数staticAdd()
});

import('myHar/Index').then((ns:ESObject) => {
  console.log(ns.add(3, 5));
});

有了这个基础知识,我们就来实现一个小Demo,比如我们有4个模块,一个独立的处理路由模块LeafRouter;一个入口模块Entry;一个harA模块,包括页面A1A2;和一个harB模块,包括页面B1B2

然后我们需要在Entry模块的Index.est页面路由到harAA1页面,harBB1页面,

我们还需要实现在HarAA1页面跳转到harBB1页面。

我们还需要实现Index.est->A1->B1->B2这些路由跳转之后,然后B2直接返回到A1页面。

大概页面截图如下:

1. 在项目中创建一个静态模块

右击鸿蒙工程,创建一个模块,New->Module,选择静态模块,命名为LeafRouter,名字大家按照自己的喜欢随便起。

2. 定义路由栈和路由表

展示新创建的模块,进入src/main/ets/,新建一个Arkts File文件。命名为RouterModule

RouterModule.est文件中编写两个静态变量因为保存路由栈和路由表。

路由栈routerMap我们使用Map对象包裹,是考虑到某些应用存在多个Navigation组件;

路由表builderMap

export class RouterModule {
    static builderMap: Map<string, WrappedBuilder<[object]>> = new Map<string, WrappedBuilder<[object]>>();
    static router:  NavPathStack = new NavPathStack();

  ...
}

说句题外话,不过大家应该会用到,比如想做应用页面日志上报,可以在路由栈中使用拦截器来做,具体代码如下:

public static addRouterInterception(): void {
  RouterModule.router.setInterception({
    willShow: (from: NavDestinationContext | "navBar", to: NavDestinationContext | "navBar",
      operation: NavigationOperation, animated: boolean) => {
      if (typeof to === "string") {
        console.log("target page is navigation home page.");
        return;
      }
      // 将跳转到PageTwo的路由重定向到PageOne
 let target: NavDestinationContext = to as NavDestinationContext;
      let t = target.pathInfo.name;
      console.log("路由拦截-willShow ${t}");
    },
    didShow: (from: NavDestinationContext | NavBar, to: NavDestinationContext | NavBar, operation: NavigationOperation, isAnimated: boolean) => {
      console.log("路由拦截-didShow");
    },
    modeChange: (mode: NavigationMode) => {
      console.log("路由拦截modeChange");
    }
  })
}

3. 动态注册路由表

既然是动态注册路由,页面肯定是按需进行动态import的,这样只有用户真正需要路由到某一个模块的时候,才引入,可以降低应用内存使用,提升启动速度。

registerBuilder这个函数,是把各个模块需要路由的页面进行注册,把页面组件和pageName字符串进行一一映射;

getBuilder函数用来根据pageName获取具体的页面组件;

export class RouterModule {
  ... 
public static registerBuilder(pageName: string, builder: WrappedBuilder<[object]>): void {
  const hasBuilder = RouterModule.builderMap.get(pageName);
  if(hasBuilder == null || hasBuilder == undefined){
    RouterModule.builderMap.set(pageName, builder);
  }
}

// Get builder by name.
public static getBuilder(pageName: string): WrappedBuilder<[object]> {
  const builder = RouterModule.builderMap.get(pageName);
  if (!builder) {
    Logger.info('not found builder ' + pageName);
  }
  return builder as WrappedBuilder<[object]>;
}
  ...
}

4. 新建一个路由数据封装对象

这个主要用来保存路由目标页面,和路由传递的参数,在LeafRouter模块的src/main/ets目录下,新建一个文件RouterModel.ets

harName: 项目中的其他模块名,比如harA, harB

pageName: 路由的页面

param:路由传递的参数

export class RouterModel{
  // 模块名字,如果为null 就是主模块
harName?: string|null;
  // 页面名称 格式${pagePath}_${pageName}.
pageName: string = "";
  //页面传递的参数对象
param?: object = new Object();
}

##5. 新增路由跳转方法

这一步是动态路由的核心,需要好好理解。在RouterModule新增一个push方法,参数就是前一步定义的RouterModel对象,这个方法用来动态import har库,然后路由到该库指定的页面。

import引入模块之后,会进入then回调,里面的ns就是该模块的引用,可以使用ns调用该模块的Index.est文件的方法。harInit方法就是再import该模块之后,然后就可以成功根据pageName引用具体的页面组件。我们稍后再说。

两个动态import之后,就可以使用路由栈进行页面跳转了。个人感觉鸿蒙这个设计的目的就是就是启动提速,提升用户体验。

这一步结束之后,基本上LeafRouter模块的编码就算完成了。接下来进入Entry模块来使用之。

public static async push(router: RouterModel): Promise<void> {
  try{
    await import(router.harName).then((ns: ESObject): Promise<void> => {
      return ns.harInit(router.pageName);
    });
    RouterModule.router.pushPath({ name: router.pageName, param: router.param });
  } catch (e) {
    console.log("push error ${e}");
  }
}

6. 主模块引用LeafRouter

添加本工程其他模块,有两个方法:

方法一:

使用终端进入entry目录;

然后使用命令行 ohpm install ../LeafRouter引用我们创建的LeafRouter模块;成功之后会自动在entry模块的oh-package.json5中的dependencies新增"leafrouter": "file:../LeafRouter"

方法二:

也可以直接在entry模块的oh-package.json5文件中dependencies添加"leafrouter": "file:../LeafRouter"。然后再终端执行ohpm install

7. 主模块关联路由栈和路由表

这部分逻辑相当简单,和基本解决方案类似。

路由栈RouterModule.router直接作为Navigation组件的参数传入,如下面代码的tag1;

动态路由表如tag2所示,会根据pageName字符串映射参数动态创建子页面组件

对于主模块其他页面,我们也可以使用tag3所示的方案。

比如我们需要路由到本项目hara模块的A1页面,就可以使用tag4所示的方式。hara模块,我们后文详细说,这个大家有一个印象就行。

import { BuilderNameConstants, buildRouterModel, RouterModel, RouterModule } from 'leafrouter';

@Entry
@Component
struct EntryHap {
  @Builder
  routerMap(pageName: string, param: object) {
    if(builderName == "entry_listpage"){
      ListPage();  //tag3
    }else{
      RouterModule.getBuilder(pageName).builder(param); //tag2
    }
  };

  build() {
    Navigation(RouterModule.router) {  //tag1
        Button("to A1", { stateEffect: true, type: ButtonType.Capsule })
          .width('80%')
          .height(40)
          .margin(20)
          .onClick(() => {
            let routerModel: RouterModel = {
              harName: "hara",
              pageName:BuilderNameConstants.HARA_A1,
              param:new Object({
                  origin: 'Entry',
                postId: "4323rsdafsdfasdf"
              })
           };
            RouterModule.push(routerModel);  //tag4
      })
      ...
    }
    .title('NavIndex')
    .navDestination(this.routerMap);
  }
}

8. 创建其他静态模块harA,harB

假设我们项目还需要两个模块haraharb,分别对应项目中不同的功能,比如一个是登录模块,一个是支付模块。hara和harb都是静态模块,创建方式同步骤1。就不多说了。

创建结束之后,项目结构如下

harA模块包含A1,A2两个页面。

harB模块包含B1,B2两个页面。

这些页面都是用来测试路由跳转的,页面内容很简单,都是标注当前页面名称。当然有几个重点我解释下

tag0定一个全局@Builder方法harBuilder,主要原因是WrappedBuilder只能接受全局@Builder。什么是全局@Builder点击查看

tag3就是我们定义的页面映射名称,因为要注册到全局路由表,需要保证唯一性,我这边就按照模块名_页面名进行拼接得到。

tag1就是把tag0定义的函数作为wrapBuilder入参,得到一个WrappedBuilder实例。

tag2就是我们第3大步骤定义的动态注册路由表。

注意:tag1tag2,在动态import A2页面之后,会自动执行。就是第5大步骤中以下方式。大家可以在tag1tag2打上断点,如果没有执行,可能就是下面的代码报错。

await import(router.harName).then((ns: ESObject): Promise<void> => {
      return ns.harInit(router.pageName);
    });

同时,如果harAharB需要使用路由,比如harA模块的A1页面需要跳转到harB模块的B2页面。也需要引入LeafRouter模块,引入方式同步骤6。

ohpm install ../LeafRouter安装成功之后oh-package.json5文件截图如下。

9. Entry模块一些必要的配置

为了保证Entry模块,可以正常跳转harA模块的页面,或者harB模块的页面。也需要做如下两个配置。

  • 首先也需要安装依赖ohpm install ../haraohpm install ../harb

为什么叫haraharbhara字符串是harA模块中oh-package.json5文件中的name属性。harb同理。大家也可以改名字。

  • 配置build-profile.json5文件,在该文件的runtimeOnly中添加依赖包,要不然动态import方法会报错。

10.开始动态路由跳转吧

到这一步,所有的准备工作都完成了,现在根据自己的业务需要,就可以愉快的使用动态路由跳转了。

比如我想在Entry模块的Index.est页面跳转到harb模块的B1页面,就可以按照如下的方式来跳转

Button("to B1", { stateEffect: true, type: ButtonType.Capsule })
  .width('80%')
  .height(40)
  .margin(20)
  .onClick(() => {
    let routerModel: RouterModel = {
      harName: "harb",
      pageName: "harb_B1",
      param:new Object({
        origin: 'Entry',
        postId: "4323rsdafsdfasdf"
      })
    };
    RouterModule.push(routerModel);
  })

至于路由跳转中其他的方法,比如返回,返回到某一个页面,替换当前页面再跳转,都加在RouterModule.est文件中根据业务需求去加就行。

总结

本文提供了两种鸿蒙路由跳转的方式,基本解决方案比较简单,但是也需要掌握,不然无太好理解鸿蒙提供的动态路由方式。动态路由得以实现就是鸿蒙提供了动态import能力。

上面的用例都可以在源码中查看,源码地址