鸿蒙应用开发从入门到入魔:Navigation路由管理为什么这么麻烦?

鸿蒙应用的路由管理

鸿蒙应用开发,ArkUI框架提供了两种路由管理方式,分别是@ohos.router(以下简称router)和Navigation。

router管理页面

我们先来看router是怎么管理页面的。

router的页面通过@Entry来声明,支持普通路由和命名路由。

// 定义页面
@Entry
@Component
struct SecondPage {
  build() {
    Column() {
      Text('这是SecondPage')
    }
    .height('100%')
    .width('100%')
  }
}
// 打开
router.pushUrl({ url: 'pages/SecondPage' })

// 定义命名路由页面
@Entry({ routeName: 'libraryHar/mainPage' })
@Component
export struct MainPage {
  @State message: string = 'Hello World';

  build() {
    Row() {
      Column() {
        Text(this.message)
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
      }
      .width('100%')
    }
    .height('100%')
  }
}
// 打开
import 'libraryhar/src/main/ets/components/MainPage';
router.pushNamedRoute({ name: 'libraryHar/mainPage' })

虽然router的普通路由和命名路由的区分让项目开发产生了一些额外的复杂度,但router的页面定义方式还是比较符合常用习惯的。

Navigation管理页面

再让我们来看看Navigation是怎么管理页面的。

@Entry
@Component
struct NavigationIndex {
  navPathStack: NavPathStack = new NavPathStack()

  @Builder
  routerMap(name: string, params: ESObject) {
    if ('SecondNavDestination' === name) {
      SecondNavDestination()
    } else {
      Text('404 Not Found')
    }
  }

  build() {
    Navigation(this.navPathStack) {
      Column() {
        Text('打开SecondNavDestination')
          .onClick(() => {
            this.navPathStack.pushDestination({ name: 'SecondNavDestination' })
          })
      }
    }
    .navDestination(this.routerMap)
  }
}

Navigation是导航容器,具体的页面其实是NavDestination。

Navigation提供了navDestination方法用来动态创建NavDestination页面。

虽然Navigation的页面管理提供了动态化的能力,但目前的这种写法无法做到解耦,每新增一个页面都要增加一个if语句。

这种方式和router的管理方式相比差别很大,无法满足开发者对于解耦或者动态化的要求。

如何动态化

通过上一节的例子我们可以看到,Navigation标准的路由管理是用builder方法来实现的,如果想要更好的使用Navigation路由,我们首先想到的就是把NavDestination的实例化过程拆分出去,不直接在builder方法中进行。

装饰器?

router是通过@Entry装饰器来声明页面和路由的,Navigation并没有提供类似的装饰器。

那么我们是否可以提供一个类似的装饰器完成相关功能呢?

比如:

interface NavigationEntryOptions {
  routeName: string
}

function NavigationEntry(options: NavigationEntryOptions) {
  return Object
}

@NavigationEntry({ routeName: "testPage" });
export struct TestPage {
  build() {
    
  }
}

但是实际上不行,在ArkTS中,struct不支持添加自定义装饰器,虽然编译器不会报错,但是装饰器却不执行。

反射创建实例?

装饰器不可以,我们然后想到的方法自然是反射,既然要用routeName获取一个Component对象。

但是可惜,ArkTS不允许使用TS的动态化方法:

限制使用标准库
规则:arkts-limited-stdlib

级别:错误

ArkTS不允许使用TypeScript或JavaScript标准库中的某些接口。大部分接口与动态特性有关。

从TS到ArkTS的适配规则

动态返回实例

既然不让反射创建实例,那我们直接创建好实例,然后和routeName设置好匹配关系,然后动态返回呢?

struct WrappedNavigationIndex {
  navDestinationMap: Record<string, ESObject> = {}
  aboutToAppear(): void {
    this.navDestinationMap['WrappedSecondNavDestination'] = WrappedSecondNavDestination()
  }

  @Builder
  routerMap(name: string, params: ESObject) {
    this.navDestinationMap[name]
  }
}

还是不行,Navigation.navDestination方法对于返回值类型有要求,必须是@Component装饰的实例。

'PageManager.shared' does not comply with the UI component syntax. <ArkTSCheck>

封装

因为ArkUI的页面是基于struct而不是class,所以无法继承,也无法声明一个符合comply with the UI component syntax要求的类型。

看似陷入了死胡同,不过官方也考虑到了,所以提供了一个特殊类型:WrappedBuilder

可以使用wrapBuilder函数创建该类型,但需要传入一个@builder装饰的函数作为参数。

@Entry
@Component
struct WrappedNavigationIndex {
  navDestinationMap: Record<string, WrappedBuilder<[params?: ESObject]>> = {}
  aboutToAppear(): void {
    this.navDestinationMap['WrappedSecondNavDestination'] = wrapBuilder(WrappedSecondNavDestinationBuilder)
  }

  @Builder
  routerMap(name: string, params: ESObject) {
    this.navDestinationMap[name].builder()
  }
}

@Builder
export function  WrappedSecondNavDestinationBuilder() {
  WrappedSecondNavDestination()
}

@Component
export struct WrappedSecondNavDestination {
  build() {
    NavDestination() {
      Column() {
        Text('这是WrappedSecondNavDestination')
      }
    }
  }
}

如何自动化注册?

通过封装builder,我们做到了动态化返回NavDestination,但NavDestination实例我们目前需要手动一个个创建并注册。

那么有没有办法不需要手动注册,而是像@Entry那样自动创建并注册吗?

注册表:官方方案

我们先来看看官方提供的方案。

方案详情请查看官方文档:系统路由表

  {
    "routerMap": [
      {
        "name": "PageOne",
        "pageSourceFile": "src/main/ets/pages/PageOne.ets",
        "buildFunction": "PageOneBuilder",
        "data": {
          "description" : "this is PageOne"
        }
      }
    ]
  }

我们可以看到在路由表配置文件中,有一个字段是buildFunction,字段说明为跳转目标页的入口函数名称,必须以@Builder修饰。
这即是我们文章上面定义的页面builder方法。

系统路由表通过自动解析路由表配置,将页面namebuildFunction包装成的WrappedBuilder实例关联在一起,从而做到自动化注册效果。

代码生成+注册表:鸿蒙案例库方案

该方案是指cases开源案例库中的dynamicRouter

其原理见自动生成动态路由

最为核心的功能在于添加装饰器和插件配置文件,编译时自动生成动态路由表,其通过自定义装饰器和hvigor插件,自动生成builder方法和路由表。

其中的插件就是指cases库中的AutoBuildRouter

我们来看一下dynamicRouter生成的代码和路由表是什么样的。

// auto-generated
import { DynamicsRouter, AppRouterInfo } from '@ohos/dynamicsrouter/Index';
import { AddressExchangeView } from '../view/AddressExchangeView'

@Builder
function addressExchangeViewBuilder() {
  AddressExchangeView();
}

export function addressExchangeViewRegister(routerInfo: AppRouterInfo) {
  DynamicsRouter.registerAppRouterPage(routerInfo, wrapBuilder(addressExchangeViewBuilder));
}
{
  "routerMap": [
    {
      "name": "addressexchange/AddressExchangeView",
      "pageModule": "addressexchange",
      "pageSourceFile": "src/main/ets/generated/RouterBuilder.ets",
      "registerFunction": "addressExchangeViewRegister"
    }
  ]
}

这个路由表和系统路由表的最大差别就是没有了buildFunction字段,而多了registerFunction字段。

它是做什么用的呢?

      import(`${DynamicsRouter.config.libPrefix}/${routerInfo.pageModule}`)
        .then((module: ESObject) => {
          // 通过路由注册方法注册路由
          module[routerInfo.registerFunction!](routerInfo);
          // TODO:知识点:在路由模块的动态路由.pushUri()中调用拦截方法,此处必须等待动态路由加载完成后再进行拦截,否则页面加载不成功,导致无法注册拦截的函数,出现首次拦截失效。
          if (Interceptor.interceptor(name, param)) {
            return;
          }
          // 跳转页面
          DynamicsRouter.navPathStack.pushPath({ name: name, param: param });
        })
        .catch((error: BusinessError) => {
          logger.error(`promise import module failed, error code:${error.code}, message:${error.message}`);
        });

在模块按需加载成功后,会调用module对象中的registerFunction方法完成页面路由注册。

可以看到,案例库方案相对于系统方案少了编写builder方法和配置路由表的步骤,将这些操作交给hvigor插件去完成。

代码生成+装饰器:@fw/router方案

无论是官方方案和案例库方案,都要依赖路由表。那么到底有没有办法不依赖路由表呢?

有的。

我们上面说到,struct可以添加自定义装饰器,但自定义装饰器不会触发执行;但是class的装饰器却是可以触发执行的。

所以,可以通过class装饰器完成路由的自动注册。而这就是https://gitee.com/FantasyWind/fwrouter@fw/router的实现方案。

因为struct装饰器不能自动执行,所以@fw/router也是借用hvigor插件扫描struct装饰器并生成模板代码。

我们来看一下它生成的页面路由代码。

// auto-generated
import { RouterClassProvider } from '@fw/router/Index';
import { TestDestination } from '../pages/TestPage'

@Builder
function testDestinationBuilder(params: ESObject) {
  TestDestination({ params: params });
}

@RouterClassProvider({ routeName: 'testPage', builder: wrapBuilder(testDestinationBuilder) })
export class TestDestinationProvider {
}

我们又看到了熟悉的builder方法,不过和案例库方案不同的是,这里多了TestDestinationProvider类,而它有个装饰器@RouterClassProvider

@RouterClassProvider实现了什么功能呢?

export function RouterClassProvider(options: RouterClassProviderOptions) {
  return (target: ESObject) => {
    RouterManagerForNavigation.getInstance().registerBuilder(options.routeName, options.builder)
  }
}

该装饰器在执行时完成了页面路由的注册。更巧的是这个装饰器在模块被动态import时就会触发执行,因此我们既不需要路由表,也不需要在import之后手动调用注册函数。

不过该方案也存在一些缺点,比如目前(api12),类装饰器在hsp包中无法触发执行,因此只能在hap和har中使用。

总结

看完整篇文章我们看到,Navigation路由管理方案之所以变成现在的样子,实在是开发语言存在的诸多限制所导致。

  1. struct不支持自定义装饰器;
  2. Navigation跳转不支持直接动态实例化@Component;(因为@Component struct没有类型,只能是ESObject,Navigation.navDesination不支持ESObject)所以只能是用wrapBuilder包装@Builder;
  3. 不支持反射,动态实例化,@Builder不能动态创建组件,一个Component必须对应一个@Builder;

哪怕官方能小小地放开一个口子,Navigation页面的管理都不会这么复杂[捂脸]。

只能希望官方能尽快优化一下使用方式吧。

Logo

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

更多推荐