@fw/router:鸿蒙模块化路由框架

@fw/router是在HarmonyOS鸿蒙系统中开发应用所使用的开源模块化路由框架。
该路由框架基于模块化开发思想设计,支持页面路由和服务路由,支持自定义装饰器自动注册,与系统路由相比使用更便捷,功能更丰富。

详见gitee传送门

背景

页面路由

鸿蒙应用开发中,系统目前提供了两种页面路由管理方案:@ohos.router和Navigation。
但是这两种方案的使用方式与鸿蒙主推的Stage模块化开发模式都不是很匹配。
@ohos.router模式在Hsp中和Har中的页面调用api不一样,不支持页面返回值;Navigation模式,在Har包中定义的页面需要手动import使用,无法解耦。

而真正基于模块化开发,模块拆分后上升到远端,模块之间不好直接依赖,因此需要有一个统一的方案将路由的api统一,并且可以支持解耦。
这是技术要求,也是业务要求。

@ohos.router、Navigation和@fw/router功能比较

功能@ohos.routerNavigation@fw/router
命名路由entry中不支持需要配置路由表@Entry+自定义装饰器
自动注册支持需要配置路由表@Entry+Hvigor插件
页面返回值不支持支持支持
服务路由不支持不支持支持
动态导入不支持需要配置路由表支持
TabBar嵌入页面解耦不支持不支持可使用routeName直接获取页面
拦截器不支持支持支持
全局自定义Dialog不支持支持支持

服务路由

所谓服务路由即通过路由url的方式调用可能与页面无关服务代码,并可以获取返回值。

服务路由对于模块化开发是很重要的功能,是模块解耦的重要手段。

以最常见的账户模块为例,账户模块除了提供登录等页面外,还需要给业务模块提供用户信息、登出等逻辑操作。如果没有服务路由,那么账户模块就需要提供一个AccountManager直接给业务模块使用。

但是AccountManager在技术架构上存在一些缺陷,比如:

  • 业务模块需要直接依赖账户模块,无法解耦;
  • AccountManager基于某一种语言开发,无法跨技术栈,比如直接给H5,Flutter,RN等页面调用;
  • 在多端开发的场景下,AccountManager提供的api不一致,给H5,Flutter,RN等页面调用会增加适配开发成本和沟通成本;

而基于路由url提供账户模块的用户信息、登出等逻辑,代码解耦,url和参数保持一致,天然就解决了这些问题。

功能与特性

基于模块化的开发需求,本框架支持以下功能:

  • 支持页面路由和服务路由;
  • 页面路由支持多种模式(router模式,Navigation模式,混合模式);
  • router模式支持打开非命名路由页面;
  • 页面打开支持多种方式(push/replace),参数传递;关闭页面,返回指定页面,获取返回值,跨页面获取返回值;
  • 支持服务路由,可使用路由url调用公共方法,达到跨技术栈调用以及代码解耦的目的;
  • 支持页面路由/服务路由通过装饰器自动注册;
  • 支持动态导入(在打开路由时才import对应har包),支持自定义动态导入逻辑;
  • 支持添加拦截器(打开路由,关闭路由,获取返回值);
  • Navigation模式下支持自定义Dialog对话框;

代码架构与实现原理

@fw/router代码结构

.
├── FWNavigation.ets // 封装系统Navigation,给router页面附加Navigation页面管理能力
├── RouterDefine.ts // router组件类型定义
├── RouterInterceptorManager.ets // 路由组件拦截器管理类
├── RouterManager.ets // 路由组件管理类,解耦方法调用三种路由管理类和拦截器
├── RouterManagerForNavigation.ets // 对接Navigation能力的路由组件管理类
├── RouterManagerForService.ts // 对接服务能力的路由组件管理类
└── RouterManagerForSystemRouter.ts // 对接@ohos.router能力的路由组件管理类

@fw/router目前主要包含了四块功能,分别是:系统router路由模式,Navigation路由模式,服务路由,路由拦截器。

基于低耦合高内聚的思想,系统router路由模式,Navigation路由模式,服务路由功能分别封装到各自的管理类中,并且统一实现RouterHandler接口。

系统router路由模式

主要实现代码见源代码RouterManagerForSystemRouter类。

  1. 封装@ohos.routerapi,统一Hap和Har包页面调用方式;
  2. 统一push和replace的调用方式;
  3. 基于@ohos.arkui.observer页面生命周期监听,实现页面返回值处理;

Navigation路由模式

主要实现代码见源代码RouterManagerForNavigation类。

  1. 实现自定义装饰器;支持插件扫描生成自动注册代码;
  2. 支持页面@Builder注册逻辑;
  3. 支持多NavPathStack管理;

服务路由

主要实现代码见源代码RouterManagerForService类。

  1. 实现自定义装饰器;
  2. 支持服务类自动注册;
  3. 实现服务类的注册和调用逻辑;

路由拦截器

路由拦截器代码没有直接在路由管理器中耦合调用,而是使用util.Aspect的插桩方法,替换RouterManager的相关方法,做到不侵入RouterManager的代码。

  1. 定义拦截器基类和管理类;
  2. 实现拦截器管理逻辑;
  3. 通过插桩拦截RouterManager方法,调用拦截器对应;

下载安装

ohpm install @fw/router

使用说明

页面路由-系统router模式

1.初始化

使用router模式管理页面时,处理页面返回值依赖UIAbility对象,需要对路由组件进行初始化。

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
    RouterManager.getInstance().init(this);
  }
}

2.定义页面路由

遵循系统router组件定义页面路由的逻辑:

@Entry({ routeName: "demoPage" })
@Component
export struct DemoPage {
  build() {
    Column() {
    }
  }
}

在组件对外Index.ets(组件项目oh-package.json5下面的main对应文件,没有就创建)中export页面。

export { DemoPage } from './src/main/ets/pages/DemoPage'

注意: routeName须保持唯一,同时须符合鸿蒙页面别名规范。

3.调用路由并传参

RouterManager.getInstance().openWithRequest({
  url: 'libraryHarDemo/demoPage?key=www',
  params: { 'from': 'libraryHar-TestPage' }
})

调用路由时使用动态导入能力,需要添加配置,参照开启动态导入能力
使用路由的其他问题见路由打开相关问题

Entry中的非命名路由

因为Entry中的页面不支持设置命名路由,RouterManager支持用页面path打开。

RouterManager.getInstance()
  .openWithRequest({
    url: 'pages/SecondPage',
    params: { "from" : "libraryHar-TestPage" } as Record<string, ESObject>
  })

4.获取传入参数

使用router.getParams()获取参数。

@Entry({ routeName: "demoPage" })
@Component
export struct DemoPage {
  @Prop params?: Record<string, ESObject>

  aboutToAppear(): void {
    if (router.getParams()) {
      this.params = router.getParams() as Record<string, string>;
    }
  }
}

5.关闭页面回传数据

关闭页面支持返回上一页和返回指定页面。

// 返回上一页
RouterManager.getInstance().back({params: {
  info: `${this.pageName} back`,
  data: `${this.pageName} back data`
}})
// 返回指定页面
RouterManager.getInstance()
  .back({url:"libraryHar/testPage", params:{
    info: `${this.pageName} back testPage`,
    data: `${this.pageName} back testPage data`
  }})

6.获取页面返回值

openWithRequest方法的返回值是Promise<RouterResponse>,直接用.then获取上一页的返回数据。

RouterManager.getInstance()
  .openWithRequest({
    url: 'libraryHarDemo/returnResultPage',
  }).then((response) => {
    if (response.code == RouterResponseError.Success.code) {
      const p = response.data as Record<string, string>;
      promptAction.showDialog({ message: JSON.stringify(p) })
    }
  })

页面路由-Navigation模式

1.Navigation容器

使用Navigation模式,需要用户自己在业务根视图中嵌套Navigation,并和RouterManager保持联通。

如果没有特殊要求可以直接在业务根视图中嵌套@fw/router提供的FWNavigation组件。

@Entry
@Component
struct Index {
  build() {
    Row() {
      FWNavigation() {
        Column() {
          // 业务代码
        }
        .width('100%')
      }
    }
    .height('100%')
  }
}

若有其他需要耦合Navigation组件的逻辑,可以参考FWNavigation组件代码自己实现相关逻辑。

2.定义页面路由

定义Navigation页面时,需要嵌套NavDestination组件。

@Component
export struct DemoDestination {
  @Prop params?: Record<string, ESObject>
  build() {
    Column() {
      NavDestination() {
        DemoPageContent({ pageName: 'DemoDestination', params: this.params })
      }
    }
  }
}

DemoDestination的自动注册。

1.添加自定义注解

DemoDestination添加自定义注解NavigationRoute设置路由名称,如果包含参数需要设置hasParams为true。

@NavigationRoute({ routeName: "demoPage", hasParams: true })
@Component
export struct DemoDestination {
}

2.集成hvigor插件

在工程的hvigor/hvigor-config.json5文件中添加插件依赖:

{
  "dependencies": {
    "@ohos/hvigor-ohos-plugin": "4.1.2",
    "fw-router-hvigor-plugin": "1.0.0"
  },
}

在对应的模块hvigorfile.ts中引用路由提供的插件。

import { harTasks } from '@ohos/hvigor-ohos-plugin';
import { routerHvigorPlugin } from 'fw-router-hvigor-plugin';

export default {
    system: harTasks,  /* Built-in plugin of Hvigor. It cannot be modified. */
    plugins:[
        routerHvigorPlugin()
    ]         /* Custom plugin to extend the functionality of Hvigor. */
}

3.项目编译运行运行

routerHvigorPlugin插件会自动扫描本模块代码中的NavigationRoute装饰器,自动生成代码写入模块的/src/main/ets/generated/RouterBuilder.ets文件。

如果当前module是har包,会自动将之添加到Index.ets文件export:

export * from './src/main/ets/generated/RouterBuilder';

之后,当触发动态导入模块时,自动生成的代码也会自动导入从而触发自定义装饰器逻辑完成自动注册。

如果当前module是hap包,会自动将之添加到EntryAbility.ets文件import。

import('../generated/RouterBuilder');

3.调用路由并传参

Navigation模式的调用方式与系统router模式一致。

RouterManager.getInstance().openWithRequest({
  url: 'libraryHarDemo/demoPage?key=www',
  params: { 'from': 'libraryHar-TestPage' }
})

调用路由时使用动态导入能力,需要添加配置,参照开启动态导入能力
使用路由的其他问题见路由打开相关问题

4.获取传入参数

页面定义自动处理时,自定义注解NavigationRoute设置hasParams为true,并定义@State params用来接收参数即可。

@NavigationRoute({ routeName: "demoPage", hasParams: true })
@Component
export struct DemoDestination {
  @State params: Record<string, string> = {}

}

5.关闭页面回传数据

Navigation模式的关闭页面回传数据方式与router一致。

// 返回上一页
RouterManager.getInstance().back({params: {
  info: `${this.pageName} back`,
  data: `${this.pageName} back data`
}})
// 返回指定页面
RouterManager.getInstance()
  .back({url:"libraryHar/testPage", params:{
    info: `${this.pageName} back testPage`,
    data: `${this.pageName} back testPage data`
  }})

6.获取页面返回值

Navigation模式的获取页面返回值方式与router一致。

RouterManager.getInstance()
  .openWithRequest({
    url: 'libraryHarDemo/returnResultPage',
  }).then((response) => {
    if (response.code == RouterResponseError.Success.code) {
      const p = response.data as Record<string, string>;
      promptAction.showDialog({ message: JSON.stringify(p) })
    }
  })

服务路由

1.定义服务路由

import { ServiceRoute, Route, SuccessCallback, ErrorCallback } from '@fw/router'

@Route({ routeName: "testService" })
export class TestServiceRoute extends ServiceRoute {
  onAction(pageInstance: ESObject, params: Record<string,ESObject>, callback: SuccessCallback, errorCallback?: ErrorCallback) {
    callback({ name: "ZhangSan" })
  }
}

onAction方法的params参数即为路由调用的入参。

在组件Index.ets(没有就创建)中export。

export { TestServiceRoute } from './src/main/ets/service/TestServiceRoute'

2.使用服务路由

调用服务路由、传参、获取返回值和页面路由使用同样的api。

RouterManager.getInstance().openWithRequest({
    url: 'libraryHarDemo/testService',
    params: { 'from': 'libraryHar-TestPage' }
}).then((response) => {
    promptAction.showDialog({ message: JSON.stringify(response?.data) })
})

调用路由时使用动态导入能力,需要添加配置,参照开启动态导入能力
使用路由的其他问题见路由打开相关问题

路由打开相关问题

路由名称

页面路由和服务路由定义时只需要指定名称(比如demoPagetestService),但是调用路由时需要在名称之前增加其所属的包名(例如libraryHarDemo/demoPagelibraryHarDemo/testService)。
当然,你也可以传递一个完整的url字符串app://base/libraryHarDemo/demoPage,目前路由组件支持该样式,但没有使用url中的scheme和host部分。

页面路由打开模式

打开页面路由默认是push模式,如果想要replace,可以指定openMode参数。

RouterManager.getInstance().openWithRequest({
    originPath: 'libraryHarDemo/demoPage?key=www',
    params: { 'from': 'libraryHar-TestPage' },
    openMode: PageRouteOpenMode.replace
})

router和Navigation都支持replace操作。

动态导入问题

解释:

动态导入是指应用开始运行时不import页面路由或者服务路由所在的har包,而是在业务代码调用指定路由时,先import指定har包,然后再执行相关代码。

开启动态导入能力

修改entry/build-profile.json5,增加相关设置:

{
  "buildOption": {
    "arkOptions": {
      "runtimeOnly": {
        "sources": [],
        "packages": [
          "@fw/router",
          'libraryHar',
          "libraryHarDemo",
        ]
      }
    }
  },
}
自定义动态导入逻辑
  1. 目前路由组件默认是开启动态导入能力的,不过目前默认是动态导入整个har包;
  2. 如果想要更细的控制粒度,比如按页面动态导入,需要实现RouterManager.getInstance().delegatedynamicImport方法,自定义动态导入逻辑;
  3. 如果是本地har包,动态导入逻辑正常;而远程依赖har包,在api11上无法成功,也需要实现RouterManager.getInstance().delegatedynamicImport方法,在业务代码撰写import()方法;
RouterManager.getInstance().delegate = {
  dynamicImport:(packageName: string, request: RouterRequestWrapper): Promise<ESObject> => {
    return import(packageName)
  }
}
  1. 如果不需要动态导入逻辑,可以直接关闭;该状态下,需要自己在业务代码中导入har包或者指定文件;
RouterManager.getInstance().enableDynamicImport = false;

模块名与包名问题

动态导入功能依赖path格式,{moduleName}/.../{pageName},动态导入目前是解析路由名第一个/字符之前的部分作为包名进行导入;

如果是全新开发应用,只需要将路由定义中的moduleName和har包名称保持一致。

但是如果路由名称需要和以前开发的iOS/Android平台应用保持,那路由中包含的moduleName可能就无法和har包保持一致。

路由组件提供了模块名-包名的映射关系,可以自由设置某个har包中支持moduleName。

RouterManager.getInstance().setModuleToPackageMapping('demo', 'libraryHarDemo')
RouterManager.getInstance()
  .openWithRequest({
    originPath: 'demo/oldService'
  }).then((response) => {
    promptAction.showDialog({ message: JSON.stringify(response?.data) })
  })

注意:

moduleName对packageName是多对一关系;因此一个moduleName只能存在于一个har包中。

如果因为某些原因,业务确实存在一个moduleName存在多个har包中的情况,只能关闭动态导入功能或者自定义动态导入逻辑。

页面路由router和Navigation混合模式

页面路由的router模式和Navigation模式可以混合使用。

页面路由定义

router页面和Navigation页面混合使用时,页面定义方式以及获取入参的方式与单独使用router或单独使用Navigation时相同。

但是每一个router的页面都需要嵌入Navigation,并且和RouterManager联动。

可以使用路由组件封装了FWNavigation组件,也可以参照其代码自己实现。

@Entry({ routeName: "demoPage" })
@Component
export struct DemoPage {
  @Prop params?: Record<string, ESObject>

  aboutToAppear(): void {
    if (router.getParams()) {
      this.params = router.getParams() as Record<string, string>;
    }
  }

  build() {
    Column() {
      // 此处由系统Navigation组件替换为FWNavigation组件
      FWNavigation() {
        DemoPageContent({ pageName: 'DemoPage', params: this.params })
      }
    }
  }
}

打开页面

路由组件提供了属性,以修改打开页面路由的策略:

RouterManager.sInstance.routerStrategy = RouterStrategy.navigationFirst
/**
 * 页面路由打开策略
 */
export enum RouterStrategy {
  /**
   * 优先NavPathStack打开页面。只有当NavPathStack无法响应时,才尝试用router打开页面。
   */
  navigationFirst,
  /**
   * 优先用router打开页面。只有当router无法响应时,才尝试NavPathStack打开页面。
   */
  routerFirst,
  /**
   * 只用NavPathStack打开页面。
   */
  navigationOnly,
  /**
   * 只用router打开页面。
   */
  routerOnly,
}

正常情况下,用户想要使用单独的router模式或者单独的Navigation模式,不需要关心routerStrategy属性,只需要按照文档定义对应页面即可。

在混合模式下,RouterStrategynavigationFirstrouterFirst可能会影响具体的效果。

当一个页面路由同时存在router类型页面和Navigation类型页面时,navigationFirst会首先尝试用Navigation打开页面,如果无法打开,则使用router打开;routerFirst会首先尝试用router打开页面,如果无法打开,则使用Navigation打开。

返回指定页面

在单独router模式和单独Navigation模式中,back方法的toUrl参数没有限制。

但是在混合模式中,当前router中返回时,无法返回到之前router内嵌套的Navigation页面中,只能返回到当前router中内嵌的Navigation页面或者router页面。

路由拦截

1.定义拦截器

自定义拦截器类,实现RouterInterceptor接口。
接口支持三个方法,分别对应不同的场景。

export interface RouterInterceptor {
  /**
   * 路由打开操作。
   * @param request 路由请求,注意参数是经过处理的RouterRequestWrapper类型。
   * @returns 是否拦截,如果返回true,则路由不会继续原处理逻辑。
   */
  open?(request: RouterRequestWrapper): Promise<boolean>
  /**
   * 关闭页面操作。
   * @param options 关闭页面参数,注意参数是经过处理的RouterBackOptionsWrapper类型。
   * @returns 是否拦截,如果返回true,则路由不会继续原处理逻辑。
   */
  close?(options?: RouterBackOptionsWrapper): boolean
  /**
   * 收到响应数据。
   *
   * 需要对错误做统一处理或者对特殊错误拦截请实现该方法。
   * @param request 路由请求对象。
   * @param response 路由返回对象。
   * @returns 是否拦截,如果返回true,则路由不会继续原处理逻辑。
   */
  onResponse?(request: RouterRequestWrapper, response: RouterResponse): Promise<boolean>
}

2.注册拦截器

this.webPageInterceptor = new WebPageInterceptor()
RouterManager.getInstance().registerInterceptor(this.webPageInterceptor)

3.移除拦截器

RouterManager.getInstance().unregisterInterceptor(this.webPageInterceptor)

4.拦截器使用场景

1.鉴权或重定向

打开路由需要登录或者有其他权限限制时,可以使用该方案。

单纯的路由重定向需求,比如原生页面降级为H5页面,H5页面重定向到别的H5页面,也可以参考该逻辑。配合网络配置即可完成动态重定向。

export class WebPageInterceptor implements RouterInterceptor {
  open(request: RouterRequestWrapper): Promise<boolean> {
    return new Promise<boolean>((resolve, reject) => {
      if (request.params['needLogin'] as boolean && !AccountManager.instance.isLogin) {
        // 打开登录页面,并传入原路由地址。登录成功后会自动跳转原路由地址。
        RouterManager.getInstance().openWithRequest({ url: 'login/loginPage', params: { redirectUrl: request.rawRequest.url } })
        resolve(true)
      } else {
        resolve(false)
      }
    })
  }
}
2.不同scheme协议兼容(如http)

当需要支持不同类型的url时,可以使用拦截器然后分发到对应的处理逻辑中。这样可以做到调用入口统一。

下面是让RouterManager支持直接打开http地址(https://www.baidu.xn--com)-jr6gy69ktwd5q7dyib./

export class WebPageInterceptor implements RouterInterceptor {
  open(request: RouterRequestWrapper): Promise<boolean> {
    return new Promise<boolean>((resolve, reject) => {
      if (request.rawRequest.url.startsWith('http')) {
        RouterManager.getInstance().openWithRequest({ url: 'pages/MockWebPage', params: { url: request.rawRequest.url } })
        resolve(true)
      } else {
        resolve(false)
      }
    })
  }
}
3.fallback处理

对于未实现的路由,如果想要定义一个统一的缺省页面,可以使用该方式。

export class PageFallBackInterceptor implements RouterInterceptor {
  onResponse(request: RouterRequestWrapper, response: RouterResponse): Promise<boolean> {
    return new Promise<boolean>((resolve, reject) => {
      if (response.code == RouterResponseError.RequestNotFoundResponsor.code) {
        RouterManager.getInstance().openWithRequest({ url: 'pages/PageNotFoundPage', params: request })
        resolve(true)
      } else {
        resolve(false)
      }
    })
  }
}
4.错误统一处理

需要对于路由的错误返回有统一处理,showToast或者日志,可以参照该方案。

export class RouterFailLogInterceptor implements RouterInterceptor {
  onResponse(request: RouterRequestWrapper, response: RouterResponse): Promise<boolean> {
    return new Promise<boolean>((resolve, reject) => {
      if (response.code != RouterResponseError.Success.code) {
        console.log(`路由请求[${request.uri}]报错,错误code=${response.code}, msg=${response.msg}`)
      }
      resolve(false)
    })
  }
}

业务场景

Dialog

使用Navigation页面模式时支持自定义Dialog对话框。

下面是Alert对话框的演示代码:

@NavigationRoute({ routeName: "alert", hasParams: true })
@Component
export struct AlertOnlyNavigation {
  @Prop params?: Record<string, ESObject>
  build() {
    Column() {
      NavDestination() {
        AlertContent({ params: this.params })
      }
      .mode(NavDestinationMode.DIALOG)
    }
  }
}

@Component
export struct AlertContent {
  @Prop params: Record<string, ESObject>
  build() {
    Column() {
      Row() {
        Column() {
          Text(this.params['title'])
          Text(this.params['message'])
            .margin({top: 16})
          Row() {
            Button('取消')
              .layoutWeight(1)
              .onClick(() => {
                RouterManager.getInstance().back({ params: { 'action': '取消' } })
              })
            Button('确定')
              .layoutWeight(1)
              .onClick(() => {
                RouterManager.getInstance().back({ params: { 'action': '确定' } })
              })
          }
          .margin({top: 16})
        }
        .margin({left: 16, right: 16})
        .padding(16)
        .layoutWeight(1)
        .backgroundColor(Color.White)
        .borderRadius(10)
      }
      .height('100%')
    }
    .width('100%')
    .backgroundColor('#33000000')
  }
}

打开Alert的方式与路由相同。

RouterManager.getInstance().openWithRequest({
  url: 'libraryHarDemo/alert',
  params: { 'title': '温馨提示', 'message': '测试文本问问本' }
}).then((response) => {
  if (response.code == RouterResponseError.Success.code) {
    promptAction.showDialog({ message: `你点击了 ${response.data.action}` })
  }
})

可以对打开Alert的方法进行二次封装。

Logo

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

更多推荐