前情回顾:

HarmonyOS-ArkUI Navigation (导航组件)-二 Router,Navigation, HMRouter 的区别_hmrouter 折叠屏分栏-CSDN博客

HarmonyOS-ArkUI Navigation (导航组件)-第一部分_navigation跟tabs-CSDN博客

现如今鸿蒙 App 应用的原生页面跳转分为两大方式

  • 原生 router 进行跳转,现华为已经不对其维护,并推荐迁移至 Navigation 来进行原生页面的导航。
  • Navigation 进行跳转,其中包括
    • 用系统提供的 Navigation 来处理页面跳转。
    • 用第三方路由框架(底层是 Navigation 实现),来处理页面跳转。

对于 Router, Navigation, HMRouter 的差异详见文章, 以及 HMRouter 是什么,详见:HarmonyOS-ArkUI Navigation (导航组件)-二 Router,Navigation, HMRouter 的区别_hmrouter 折叠屏分栏-CSDN博客

HMRouter

HMRouter 便是我们上文中说到的, 基于 Navigation 实现的一个比较知名的第三方路由框架。

如果您的项目是开始启项时就采取 HMRouter 来研发,那么很可能一切顺遂,遇见的问题也不会很复杂。

但是如果您的项目是从原生的 router 迁移到 HMRouter,可能会存在一些兼容问题,且处理起来难度稍微高一些。这也是我写这篇文章的初衷--帮助大家少踩坑。同时这片文章也相当于我的复盘,希望可以帮助到大家。

我在这里重点讲下,原生 router迁移至 HMRouter 实现的过程中遇见的问题以及解决方案。

HMRouter 使用方式

HMRouter 的使用方式我们不做过多概述了,这个可以通过官网: ohrouter/README.md-代码预览-ohrouter:基于 OpenHarmony 的应用内页面路由框架项目 - AtomGit

来进行查阅,并可以根据其描述实现一个简单的案例。

HMRouter 原理概述

HMRouter 是在 Navigation 的基础上实现的,可以提前了解下 Navigation 的使用方式,尤其是可以感受一下 Navigation 用起来有多麻烦。。而 HMRouter 可以很大程度上解决用户在工程实现的时候的书写麻烦问题。比如

  • 您不用写全局路由配置文件了,只需要在新的页面上加@HMRouter注解,配置好 pageUrl 参数就可以搞定。
  • 您也不用写子导航页的配置函数入口了,这种模板类的方法本不应该存在。写起来就烦。
  • 您也不用在每个子页面写 NavDestination 组件了,这种也是很模板化的,HMRouter 都做了,您可以将您的页面build 方法里,就像一个普通页面来实现。

那么 HMrouter 是怎么实现这些能力的呢? HarmonyOS-ArkUI Navigation (导航组件)-二 Router,Navigation, HMRouter 的区别_hmrouter 折叠屏分栏-CSDN博客 这篇文章其实讲过一些,可以先参考下。

其实核心就是两个

  • 编译时注解技术, 搞定您在组件上加 @HMTRouter 注解,就动态的生成路由表了,您自己不用手动跑路由表注册信息了。
  • 动态 import 技术,搞定您调用跳转逻辑时可以跳转到目标页面。当您执行跳转的时候,肯定是从路由表信息里面查询目标文件的,但是目标文件并没有被加载到Ark 虚拟机里,所以就用代码动态加载进去,这样才能顺利完成实例的创建,动态的组件函数入口生成即调用能力。

HMRouter编译踩坑记录

HMRouter注解的pageUrl 参数不可以是 static 修饰的参数,尽量是 const 修饰的参数,否则编译会遇见问题。

错误案例展示

@HMRouter({pageUrl : HomePage.pageName})
export struct HomePage {
	static readonly pageName = 'HomePage' //static 修饰的内容,无法被编译插件更新到动态生成的临时路由表中,导致编译插件报错
	build(){
		...
	}
}
	

更正方法, 将名字改为 const,或者直接用字符串字面量(强烈不建议!不易维护)

const PAGEURL = 'HomePage' 
@HMRouter({pageUrl : PAGEURL}) // 改动,采用 const 修饰的变量
export struct HomePage {
	static readonly pageName = PAGEURL 
	build(){
		...
	}
}

或者下面的方式也可以。


@HMRouter({pageUrl : 'HomePage'}) // 改动,采用字符串字面量
export struct HomePage {
	static readonly pageName = HomePage 
	build(){
		...
	}
}

HMRouter 编译报错ERR_DUPLICATE_NAME (40000001)

这个原因其实很简单,就是您的项目里存在两个一模一样pageUrl 的页面。但是如果这么简单我也不会写到这篇文章里。因为我遇见的问题是:

我的工程里的确明确就是一份!全局只有一份可还是报错。 楼主在这里提供一个思路:您的工程如果存在某些编译时中间目录,或者编译时生成了一个目录,这个目录中因为某些插件的实现方式涉及到修改原文件,但是完工后并没有删除这个临时生成的文件,则同样会产生报错!这个是一个比较隐晦的坑。原因在于:HMRouter 的插件在编译前扫描的目录,如果不做指定,那就是会扫全部的目录,报错隐藏文件夹的内容同样会扫描。这个时候如果某个隐藏文件夹存在一份一样的,那么就会出问题,同时也比较难排查。

HMRouter 与 router 生命周期衔接问题及解决方案

我们在做迁移的时候,一定会考虑到二者生命周期是否存在什么差异。二者的确在生命周期上存在差异,如图:

router

备注

HMRouter

备注

aboutToAppear

组件即将显示,未渲染

组件实例已建好,但 build 还没执行、UI 还没开始渲染到屏幕

HMRouter 尽管此时回调粒度更为细致, 但是这些环节外部不可改动, 此时 HMRouter 的 aboutToAppeare 调用时机与 router 调用时机,在需求上可视为没有差异. 不会影响我们实际的代码逻辑.

onPrepare

在拦截器执行后,路由栈真正push前触发

onWillAppear

NavDestination创建后,挂载到组件树之前执行,在该方法中更改状态变量会在当前帧显示生效

onReady

在即将构件子组件时触发此回调

onAppear

NavDestination组件挂载到组件树时执行

onWillShow

布局显示之前执行,此时页面不可见(应用切换到前台不会触发)。

aboutToAppear

实际的打印顺序

onPageShow

即将显示我们描述的界面时回调 会被重复调用

onShown

布局显示之后执行,此时页面已完成布局

会被重复调用

做适配, 当 HMrouter 回调 onShown 的时候, 我们调用原先 onPageShown实现的逻辑.

onActive

NavDestination处于激活态(处于栈顶可操作,且上层无特殊组件遮挡)触发。

onBackPress

onBackPressed

在路由组件绑定的页面栈中存在内容时,此回调生效。当点击返回键时,触发该回调。返回值为true时,表示重写返回键逻辑,false时,表示回退到上一个页面。

做适配, 当 HMrouter 回调 onBackPressed 的时候, 我们调用原先 onBackPress 的逻辑.

onWillHide

NavDestination组件触发隐藏之前执行(应用切换到后台不会触发)。

onInactive

NavDestination组件处于非激活态(处于非栈顶不可操作,或处于栈顶时上层有特殊组件遮挡)触发。

onPageHide

会被重复调用

onHidden

NavDestination组件触发隐藏后执行(非栈顶页面push进栈,栈顶页面pop出栈或应用切换到后台)。会被重复调用

做适配, 当 HMrouter 回调 onHidden 的时候, 我们调用原先 onPageHide 的逻辑.

onWillDisappear

NavDestination组件即将销毁之前执行,如果有转场动画,会在动画前触发(栈顶页面pop出栈)

onDisAppear

通用生命周期事件,NavDestination组件从组件树上卸载销毁时执行

aboutToDisappear

aboutToDisappear

无需修改,API一致, 改为 HMRouter 之后也会调用到这个逻辑

思考:对于生命周期而言,实际上如果项目是严格按照 MVVM 架构设计的,将业务逻辑与界面逻辑进行解耦,那么我们直接利用 HMRouter 提供的生命周期注解,实现自己的生命周期类,甚至让我们的 ViewModel 也具备生命周期的能力,这些都是比较好搞定的。

但是呢,现实中的工程,尤其是维护时间比较长的大型工程,如果是较早期接入鸿蒙生态的工程,早年很可能因为鸿蒙早期对开发者提供的API 能力限制,以及各种现实中的原因,导致开发者不得不妥协,采取在 View 中实现部分业务逻辑。代码存在耦合。那么这种直接利用 HMRouter 生命周期注解的方式就不可取的。我们则必须用一种方式,使得 HMRouter 集成之后,原先的 Page 变为组件,生命周期正常调用。原先Page 的代码逻辑尽量不要动。

难道,我们要在每个 Page 中,实现一个 HMRouter 的监听,然后当监听到onShown生命周期的时候,就手动调用当前组件的 onPageShow() ? 假如我们的页面存在非常多的话,难道要在每个页面中都实现一份?这不可取!即使你用 AI 帮你搞定这些其实也不可取!ArkUI 中的组件是 struct 并不是 class,不存在什么继承能力,只能写一百多份模板方法。

借鉴@HMRouter 插件的实现原理,实现自己的生命周期绑定注解插件

实现效果为:

@TransLifeCycle() //自研注解:此注解加上,自动将 HMRouter 相关生命周期迁移到原 router 的 Page 老逻辑上。 如 onShown 的时候调用 onPageShown()  这样原先的生命周期调用流程就不会被漏调了。
@HMRouter({ pageUrl: 'HomePage' })
@Component
export struct HomePage {
	build(){...}
}

如果我们迁移完之后,原先的@Entry 修饰的 Page,更换为@Component之后,老流程不受影响,只需要加一个@TransLifeCycle那么迁移将会变得简洁的多。那么我们怎么用这个注解完成这个能力呢?

如果对注解不太了解,可以查看:HarmonyOS:ArkTS四类自定义注解区别-CSDN博客

经过我的调研我是发现,仅仅采用类注解的方式,是不行的,因为类装饰器它原则上是不能装饰 struct 的,尽管编译不报错,但是在类加载的时候我们实现的装饰器是不能如期调用的。 包括@HMRouter 这个注解本身,其实也是靠其团队提供的编译插件才能搞定自动生成注册路由配置。所以问题变为了:

  • 实现一个编译插件假如到编译的工作流中
  • 插件大致的能力是,全局扫描TransLifeCycle注解存在处, 如果存在,在 aboutToAppeare aboutToDisappeare处写入插桩代码。
    • 插桩代码逻辑为:收集当前组件的生命周期,为期注册 HMRouter 的生命周期方法,当对应生命周期来时,自动拿出存储的组件句柄,调用其原始方法。
    • 当组件消失的时候需要反注册,避免内存泄露问题。

注册的核心逻辑伪代码如下:

const cb = (_ctx: HMLifecycleContext): boolean | void => {
		if(HMLifecycleState.onShown === state){
        const page = 从数据中找到对应的句柄方法()
        page?.onPageShow?.()
        return true // 一定要适当的 return 布尔值,否则 HMRouter 会将这个事件分发到其全部存储的 cb, 就会出现所有页面都感知到生命周期
    }
	  if (HMLifecycleState.onHidden === state) {
				const page = 从数据中找到对应的句柄方法()
          page?.onPageShow?.()
        return true // 一定要适当的 return 布尔值,否则 HMRouter 会将这个事件分发到其全部存储的 cb, 就会出现所有页面都感知到生命周期
		}
		if (HMLifecycleState.onBackPressed === state) {
			const page = 从数据中找到对应的句柄方法()
			const r = page?.onBackPress?.()
			return 'boolean' === typeof r ? r : false
		}
}
    callbackMap.set(state, cb)
    owner.addObserver(state, cb)

Navigation 对其导航栈的同帧渲染批量合并之殇

这个同帧渲染批量合并简而言之就是, 如果你在两个帧渲染之间,做了几步甚至几百步往导航栈push pop 等操作,但是花拳绣腿一堆,导致的结果却是, 操作之后最终的导航栈样子跟你没有操作前一个样。这个时候就会出现下次渲染的时候,“批量合并”的底层机制。比如, 渲染第一帧之后, 你迅速的将 A 界面 back, 再开一个新的 A 界面, 再 back, 再开一个新的 A (这种导航方式往往让开发者费解为什么操作这么骚。 但是我经历过。。。),这个期间,到下一帧渲染的时候, navigation 会检测到, 栈里没发生变化。它就认为当前就是结果。至少不会给你往组件里remove 掉老 A 的界面,重新挂新 A 节点。这个环节是会合并的。这个问题我印象深刻,因为我用了半天才搞定这个问题。

注意:我们现在做的是 Router 往navigation 迁移的场景,这就意味着,你没法改动之前你觉得不合理的导航操作,并且不能改动已经写好的操作,只能兼容。批量合并本身是华为的一种优化手段,避免我们的组价重复布局。但是却往往在迁移的时候,影响到老逻辑的跳转。

接下来是对这个问题的阐述以及解决方案

  • A 页面在 aboutToApear 的时候发现目前的参数不对,就立即向另外一个模块找参数。找到了参数之后就立即 back 掉自己,然后开启一个新的 A,对,是新的 A
  • 上述已经是两年前的代码了,没办法改。那么这样的话我们只能看看怎么不影响以前,去改这个问题
  • 问题出在 ArkUI navigation 别出心裁搞出来的帧渲染的时候栈合并机制。是这样的
    • 你急速的 back 在 push, 是不是原本就是 XXXXXA 变为 XXXXXX 再变为 XXXXXA
    • 如果这个速度比帧渲染间隔还快的话 ,那么在下一次帧渲染的时候,navigation 只会根据最后的目的样式来校准。 他会发现,,没变!对,就是没有发生变化。于是没有处理。
    • 这样下来你会发现, 老 A 还在 ,因为没变嘛。 新 A 人家也不知道有新 A 就实际上没有渲染这个 新 A 的组件。
  • 对就是这么简单的事情。你要不动之前的代码来解决,因为已经是屎山了你没办法确保你改的就是安全的。

核心的解决方案实际上还是蛮考验功底的。因为核心的问题在于帧渲染的时候,出现合并的现象。那么你的修改的宗旨其实也是,尽量让这两个操作,或者以后的每个操作,精准到卡到不同的帧。这样大家都是单拎出来的指令了。也不会出现因为太快而合并的问题了。

解决思路:严格保障每条跳转指令的次序是串行,并且是前面执行完之后后面再执行,并且二者的执行间隔最好是 两帧及以上。

将导航的操作命令记录到一个队列里, 然后监听帧渲染完成的时机,每一帧渲染完成,消费一个命令。确保每个命令的执行一定是不在同一个帧中即可。

另外如果觉得监听帧渲染事件不太适应当前项目结构的话,也可以采取微任务 Promise 或者宏任务 setTimeout 的方式,每当上个跳转任务执行完,若干毫秒后再执行另外的任务。只要能确保执行有间隔,就可以。

Navigation 的 index 计数开始值为 0

在做老的导航迁移到新的导航的时候,我们有时候会用到页面在栈中的索引值。老的导航和 HMRouter(navigation)的页面索引获取规则是不一样的

  • router.getState().index 是从 1 开始计的
  • 而 navigation 的索引是是从 0 开始计的

如果你的逻辑中涉及到这个索引值的维护,或者用到了索引值去查询信息之类的操作。要注意这个点。

router.getState().name 获取的是文件的名称,而非 pageUrl 设置的名称

这点需要我们格外注意。老的 router.getState().name 获取的名称是文件名!!!

但是 HMRouter 或者 navigation 是无法拿到文件名的。只能拿到您在注解里设置的 pageUrl 参数的值!

如果您的原工程靠这个router.getState().name拿到的文件名去做事情了,或者拿它就是为了获取一个文件名,并将这个文件名真真正正应用到文件名才能达成的逻辑中了, 是要看一下影响的。

建议最好将 HMRouter 的 pageUrl 指定为文件名本身,减少影响。

三方带页面的 SDK 兼容

项目中大概率会存在很多三方 SDK 的使用,如果这种 SDK 中存在页面,那么他们内部的页面实现机制,也是需要考虑在内的。

  • 如果三方 SDK 内部的页面是用 router 实现的,那么我们要注意处理他们是不是会有一些逻辑,让我们打开自己应用原生页面,此时容易出问题,就是我们的页面会打开,但是会被压在这个 SDK 页面下方。此时应当考虑被打开的页面是否要保留 Page 的方式出现。
  • 如果三方 SDK 内部的页面是用 Navigation 来实现的,那么我们也要为他们提供路由表跳转入口,来进行适配,使得其跳转行为纳入在我们的跳转栈体系内,

对于第二种情况,官方提供了适配方式,不过这里边存在一些坑!下方为官方提供的兼容文档:

ohrouter/docs/Migration.md-代码预览-ohrouter:基于 OpenHarmony 的应用内页面路由框架项目 - AtomGit

下方代码为官方提供的,兼容第三方 SDK 的核心逻辑,但是呢,这里面存在一个问题就是,您只能通过自己实现一个新的Navigation,绑定一个routerMap(),这样 routerMap() 入口才能被正常调用!而如果您想在之前写的HMNavigation的主页面,来嵌入自定义路由表routerMap,或者干脆让其全部调用您自己实现的routerMap,这个HMNavigation页面是不支持的,表现特征为routerMap()不被调用。原因是 HMRouter 内部自己维护的 routerMap,已经改了核心的调用链路。我们改不动。但是写新的Navigation往往会使得我们的应用内部存在两个路由栈!两个路由栈实际上会存在很多坑。这点我认为这里HMRouter 做的并不是很好。

下方代码为官方提供的解决兼容三方 SDK 页面的核心逻辑:

@Builder
export function routerMap(name: string, param: ESObject) {
  if (name === 'CustomPage') {
    CustomPage();
  } else if (HMRouterMgr.getPageBuilderByUrl(name)) {
    // 对于已通过@HMRouter注册的页面,使用HMRouter处理
    HMRouterMgr.getPageBuilderByUrl(name)?.builder(name, param);
  }
}

@HMRouter({
  pageUrl: 'MainPage'
})
@Component
export struct MainPage {
  private helper?: NavigationHelper;

  aboutToAppear(): void {
    this.helper = new NavigationHelper('NavigationId', this);
  }

  aboutToDisappear(): void {
    this.helper?.destroy();
  }

  build() {
    Navigation(this.helper?.pathStack) {
      // 页面内容
    }
    .id(this.helper?.navigationId)
    .navDestination(routerMap) // 自定义路由表
  }
}

如果您的 HMRouter 被迫存在两个路由栈,影响

  • HMRouter生命周期的回调,假设存在两个路由栈,A 与 B,其中 B 在 A 的上方,目前只会回调活跃的那个栈,也就是 B,当活跃的那个栈里没有数据的时候,不活跃的那个栈的注册的回调,是不被调用的。 这里面存在一个关于回调衔接的问题。需要我们慎重处理。

点击外链进入您的应用,或者根据意图进入您的应用跳转情况

这个是一个需要回归的点。技术上没有什么难度,但是介于其跳转方式很特殊,建议考虑下。

系统方面,华为平行视界兼容

如果您的应用支持平行视界,那么当您的应用从 router 改为 HMRouter 的时候,在平板,双折叠,阔折叠,三折叠上的展示可能会受到影响,比如双折叠展开的时候尽管分屏了,但是所有页面都在左侧展示。此时需要参考文档中心 进行适配。采用的方式是 navigation,因为 HMRouter 本质上就是 Navigation 来实现的。

Logo

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

更多推荐