背景知识
在做鸿蒙应用的时候,导航是必不可少的底层能力。鸿蒙文档提供了两套路由方案。
这个是鸿蒙最初鸿蒙设计的一套路由方案,已经被官方标记为不推荐,就没什么好学的了。大致看了文档,这套方案偏向传统小屏移动端路由,不太适用鸿蒙主推的一次开发,多端部署开发范式。
Navigation
组件适用于模块内和跨模块的路由切换,一次开发,多端部署场景。在不同尺寸的设备上,Navigation
组件能够自适应显示大小,自动切换分栏展示效果。也在目前鸿蒙官方针对路由主推的方案,我这边在学习Navigation
组件过程中积累的经验和大家分享一下。
Navigation核心思想
这部分知识其实鸿蒙官方文档都有提供,我主要总结几个核心知识点,了解这些就可以轻松拿捏鸿蒙的路由相关知识了。
页面显示模式
Navigation
组件的mode
属性。目前该组件提供个两种显示模式,单页面模式和分栏模式,很明显,一个适用小屏手机,另一个适合于pad
或者折叠屏手机。所以我们在使用的时候可以把mode
属性设置为NavigationMode.Auto
,这样在页面宽度大于等于600vp
的使用分栏模式,否则自动使用单页面模式。
单页面模式NavigationMode.Stack | 分栏模式NavigationMode.Split |
---|---|
Navigation() {
...
}
.mode(NavigationMode.Auto)
Navigation和NavDestination
Navigation
和NavDestination
都是容器组件,必须配合使用,一般用在程序入口页面使用Navigation
包裹,子页面使用NavDestination
包裹,然后使用NavPathStack
配合navDestination
属性进行页面路由。
NavPathStack
Navigation
路由栈。这个类用来管理路由。如果做过移动端开发,这个很好理解,提供了常见的路由调整方法,如push
, replace
,pop
等。可以通过相关的接口去实现页面跳转。可以获取页面跳转中传递的参数
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
navDestination
是Navigation
组件的属性,我们可以叫他路由表。如果说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个子页面pageOneTmp
、pageTwoTmp
、pageThreeTmp
。这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.hap
的Navigation
组件绑定,RouterModule
通过持有NavPathStack
管理Navigation
组件的路由信息。路由表builderMap
是Map
结构,以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
模块,包括页面A1
和A2
;和一个harB
模块,包括页面B1
和B2
。
然后我们需要在Entry
模块的Index.est
页面路由到harA
的A1
页面,harB
的B1
页面,
我们还需要实现在HarA
的A1
页面跳转到harB
的B1
页面。
我们还需要实现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
假设我们项目还需要两个模块hara
和harb
,分别对应项目中不同的功能,比如一个是登录模块,一个是支付模块。hara和harb都是静态模块,创建方式同步骤1。就不多说了。
创建结束之后,项目结构如下
harA
模块包含A1,A2两个页面。
harB
模块包含B1,B2两个页面。
这些页面都是用来测试路由跳转的,页面内容很简单,都是标注当前页面名称。当然有几个重点我解释下
tag0
定一个全局@Builder
方法harBuilder
,主要原因是WrappedBuilder
只能接受全局@Builder
。什么是全局@Builder
,点击查看。
tag3
就是我们定义的页面映射名称,因为要注册到全局路由表,需要保证唯一性,我这边就按照模块名_页面名
进行拼接得到。
tag1
就是把tag0
定义的函数作为wrapBuilder入参,得到一个WrappedBuilder
实例。
tag2
就是我们第3大步骤定义的动态注册路由表。
注意:tag1
和tag2
,在动态import
A2
页面之后,会自动执行。就是第5大步骤中以下方式。大家可以在tag1
和tag2
打上断点,如果没有执行,可能就是下面的代码报错。
await import(router.harName).then((ns: ESObject): Promise<void> => {
return ns.harInit(router.pageName);
});
同时,如果harA
和harB
需要使用路由,比如harA
模块的A1
页面需要跳转到harB
模块的B2
页面。也需要引入LeafRouter
模块,引入方式同步骤6。
ohpm install ../LeafRouter
安装成功之后oh-package.json5
文件截图如下。
9. Entry模块一些必要的配置
为了保证Entry模块,可以正常跳转harA
模块的页面,或者harB
模块的页面。也需要做如下两个配置。
- 首先也需要安装依赖
ohpm install ../hara
和ohpm install ../harb
为什么叫hara
和harb
。hara
字符串是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能力。
上面的用例都可以在源码中查看,源码地址。