鸿蒙中hmrouter路由的使用(官方文档的补充与填坑!)

1,816 阅读8分钟
整体功能图

分层架构主要依托于路由解耦,因此路由是组件化或模块化的重中之重,本文重点探讨路由以及路由中的问题,以及官方文档缺少的模糊不清的问题详解

本文原文地址

demo地址请看原文

路由的官方文档

image.png

路由的基础使用

路由安装 & 工程配置

  1. 【新建工程】创建工程,例如工程名称为: myhmrouter
  2. 【安装】在工程根目录下(壳工程下)安装hmrouter组件依赖,执行命令:ohpm install @hadss/hmrouter
  3. 【配置】修改工程的hvigor/hvigor-config.json文件,加入路由编译插件
  4.  {
       "dependencies": {
         "@hadss/hmrouter-plugin": "^1.0.0-rc.10"
       },
     } 
    
  5. 【配置】修改entry模块的hvigorfile.ts , 路径:entry/hvigorfile.ts
  6.  import { hapTasks } from '@ohos/hvigor-ohos-plugin';
     import { hapPlugin } from '@hadss/hmrouter-plugin';
    
     export default {
       system: hapTasks,
       plugins: [hapPlugin()] // 使用HMRouter标签的模块均需要配置,与模块类型保持一致
     } 
    
  7. 【初始化】在UIAbility或者启动框架AppStartup中初始化路由框架
  8. export default class EntryAbility extends UIAbility {
      onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
        HMRouterMgr.init({
          context: this.context
        })
      }
    } 
    
  9. 【路由入口】HMRouter依赖系统Navigation能力, 所以必须在页面中定义一个HMNavigation容器
  10. 在entry模块Index.ets中定义HMNavigation容器
  11. class NavModifier extends AttributeUpdater<NavigationAttribute> {
      initializeModifier(instance: NavigationAttribute): void {
        instance.mode(NavigationMode.Stack);
         // instance.hideNavBar(true) //这句会导致底部导航不展示
        instance.navBarWidth('100%')
        instance.hideTitleBar(true)
        instance.hideToolBar(true)
      }
    }
    
    
    @Preview({ deviceType: 'phone' })
    @Entry
    @Component
    struct Index {
      @State currentIndex: number = 0
    
      @Builder
      tabBuilder(title: string, targetIndex: number, selectedImg: Resource, normalImg: Resource) {
        Column() {
          Image(this.currentIndex === targetIndex ? selectedImg : normalImg)
            .size({ width: 25, height: 25 })
            .padding(3)
          Text(title)
            .fontColor(this.currentIndex === targetIndex ? '#232323' : '#8e8e8e')
            .fontSize(10)
            .margin({ top: 5 })
        }
        .width('100%')
        .height(50)
        .justifyContent(FlexAlign.Center)
      }
    
      modifier: NavModifier = new NavModifier();
    
      build() {
        Column() {
          HMNavigation({
            navigationId: "AppNavigationID",
            options: {
              // 设置动画样式
              standardAnimator: HMDefaultGlobalAnimator.STANDARD_ANIMATOR,
              // 设置弹框动画样式
              dialogAnimator: HMDefaultGlobalAnimator.DIALOG_ANIMATOR,
              // 设置页面navigation的参数,标题栏,工具栏,bar那些
              modifier: this.modifier
            }
          }) {
            Tabs({ barPosition: BarPosition.End }) {
              TabContent() {
                Text("首页 page")
              }
              .tabBar(this.tabBuilder('首页', 0, $r('app.media.icon_mubiao_filled'), $r('app.media.icon_mubiao')))
    
              TabContent() {
                Text("商城 page")
              }
              .tabBar(this.tabBuilder('商城', 1, $r('app.media.icon_daiban_filled_black86'),
                $r('app.media.icon_daiban_black86')))
    
              TabContent() {
                Text("购物 page")
              }
              .tabBar(this.tabBuilder('购物', 2, $r('app.media.icon_kehu_filled'), $r('app.media.icon_kehu')))
    
              TabContent() {
                Text("活动 page")
              }
              .tabBar(this.tabBuilder('活动', 3, $r('app.media.icon_koubei_filled'), $r('app.media.icon_koubei')))
    
              TabContent() {
                Text("我的 page")
              }
              .tabBar(this.tabBuilder('我的', 4, $r('app.media.icon_wode24_filled_black86'),
                $r('app.media.icon_wode24_black86')))
            }
            .onChange((index: number) => {
              this.currentIndex = index
            })
            .barMode(BarMode.Fixed)
            .scrollable(false)
            .animationMode(AnimationMode.NO_ANIMATION)
          }
        } .height('100%')
        .width('100%')
      }
    } 
    

至此,路由已配置完成 预览器看不到效果 使用模拟器或者真机看效果

路由开发规范

  1. 除了entry模块外,所有页面都应该定义在ets/components文件夹下
  2. 拦截器应该定义在ets/interceptors文件夹下
  3. 自定义动画应定义在ets/animations文件夹下
  4. 退出登录返回登录页面是否要清栈,可以使用HMRouterMgr.getPathStack(navigationId)?.clear(),然后跳转登录页面

路由协议

路由协议应该和现有的安卓或iOS的路由定义协议保持一致,形如:hhy://user/getImageCode 标准格式就是scheme名称://模块/具体功能

路由跳转

使用HMRouterMgr的push方法进行跳转到目标页

HMRouterMgr.push({
 navigationId?: string; // 指定导航HMNavigation的navigationId
 pageUrl?: string; // push到哪个页面
 param?: ESObject; // 传参对象
 interceptors?: IHMInterceptor[]; // 指定路由拦截器
 animator?: IHMAnimator | boolean; // 指定跳转动画
 skipAllInterceptor?: boolean; // 是否跳过路由拦截
})

路由传参

跳转时使用param传参数 ,例如:

HMRouterMgr.push({
  pageUrl: "hhy://LoginPage",
  param: { targetUrl: info.targetName }, // 传递的参数可以为任意对象,字符串等基础类型、字典数组等,以及自定义类型对象
  skipAllInterceptor: true
})

目标页接收参数

@State param: HMPageParam = HMRouterMgr.getCurrentParam(HMParamType.all) as HMPageParam

路由回调

可以监听路由跳转和返回的回调事件

路由提供的基础监听能力
Button("监听跳转回调").onClick(e => {
  HMRouterMgr.push({
    pageUrl: "hhy://searchpage/abc",
    param: { msg: "监听跳转回调" },
    skipAllInterceptor: true
  }, {
    onResult(popInfo: HMPopInfo) { // 路由跳转后页面返回时调用
      console.info("The callback when page returns." + JSON.stringify(popInfo))
    },
    onArrival() { // 路由成功跳转后调用
      console.info("The callback when system push executed successfully.")
    },
    onLost() {  // 路由跳转失败后调用
      console.info("The callback when page could not be found.")
    }
  })
}).width('90%')
路由跳转返回时如何携带参数给上一个页面

路由pop方法可以携带参数给onResult,参数会放到popInfo中,参数可以为任意类型。

HMRouterMgr.pop({
  param:{"callbackdata":"this is callback data"} //这里的callbackdata会返回到上一个页面回调onResult中
})

特殊情况(如果用户是使用侧滑手势返回,则无法调用pop方法,此时路由无法携带参数给上一个页面。但是可以 通过传方法类型的参数来自定义实现)

// 定义回调方法
type RouterCallback <T> = (x: T) => void

// 跳转时将回调作为参数传递到下一页
HMRouterMgr.push({
  pageUrl: "hhy://searchpage",
  param: { msg: "监听跳转回调", callback:(data:RouterCallback<string>)=>{
        // 这里是目标页调用callback时的回调,data是传递过来的参数,data类型可以自定义,支持任意类型
  } },
  skipAllInterceptor: true
 }
}

// 自定义路由参数类型
interface RouterData {
  msg?:string,
  callback:RouterCallback<string>
}

// 目标页接收参数(上面已讲过)
@State param: HMPageParam = HMRouterMgr.getCurrentParam(HMParamType.all) as HMPageParam
@State data: RouterData = this.param.data as RouterData

// 页面消失的生命周期中执行回调方法,将数据传递回上一页
aboutToDisappear(): void {
  this.data.callback("this is search result")
}

方法路由的实现(路由中官方称为“服务”)

方法路由服务名和路由协议保持一致:scheme://模块/功能

基础用法(简单调用)
// 首先需要注册方法,方式:定义一个类,在类里面定义一个要执行的方法(即服务),使用HMService修饰
export class CustomService {
  @HMService({ serviceName: 'testConsole' })
  testConsole(): void {
    console.info('调用服务 testConsole')
  }
}
// 然后在用到的地方调用即可
Button("模拟方法路由(基础)").onClick(e => {
  HMRouterMgr.request('testConsole') // request里面的参数就是上面定义的方法名称(serviceName)
}).width('90%')

调用后同步获取返回值(常用)
// 流程和基础用法一样,先注册方法,这个方法有返回值(方法的参数和返回值可以为任意类型)
@HMService({ serviceName: 'testFunWithReturn' })
testFunWithReturn(param1: string, param2: string): string {
  return `${param1} ${param2}`
}

// 然后在用到的地方调用接收参数即可
Button("模拟方法路由(传参)").onClick(e => {
  let ret = HMRouterMgr.request('testFunWithReturn', 'p1', 'p2').data as string // ret就是服务返回的内容
}).width('90%')

调用后异步获取返回值
// 流程和基础用法一样,先注册方法,这个方法有异步的返回值(方法的参数和返回值可以为任意类型,异步的返回值可能来自网络请求或Promise等)
@HMService({ serviceName: 'testAsyncFun', singleton: true })
async asyncFunction(): Promise<string> {
  return new Promise((resolve) => {
    resolve('我是返回的内容')
  })
}

// 然后在用到的地方调用接收参数即可
Button("模拟方法路由(异步)").onClick(e => {
  HMRouterMgr.request('testAsyncFun').data.then((res: string) => {
    // 异步结果出来后会执行then方法,res就是异步获取的内容
  })
}).width('90%')

路由的高级用法

路由拦截

使用@HMInterceptor标签定义拦截器,并实现IHMInterceptor接口

@HMInterceptor({ interceptorName: 'JumpInfoInterceptor', global: true })
export class JumpInfoInterceptor implements IHMInterceptor {
  handle(info: HMInterceptorInfo): HMInterceptorAction {
    let connectionInfo: string = info.type === 'push' ? 'jump to' : 'back to';
    Logger.info(`${info.srcName} ${connectionInfo} ${info.targetName}`)
    return HMInterceptorAction.DO_NEXT;
  }
}

路由拦截的一个比较重要的功能是全局登录状态判断,如果用户没登录,自动跳转到登录页面

这里的global官方解释为是否为单例。怎么理解呢?

单例页面即表示在一个HMNavigation容器中,只有一个此页面(简单来说就是如果B页面是单例,A跳转B之后,B再跳转B是没反应的)。

路由拦截做登录

在entry的ets中创建interceptors文件夹,在里面创建LoginInterceptor.ets文件(这个拦截放到我的模块中也行)

import { HMInterceptor, HMInterceptorAction, HMInterceptorInfo, HMRouterMgr, IHMInterceptor } from "@hadss/hmrouter";

// HMInterceptor标记当前类做路由拦截,类要实现接口 IHMInterceptor,拦截后执行handle方法
@HMInterceptor({ priority:9, interceptorName: 'LoginInterceptor', global: true })
export class LoginInterceptor implements IHMInterceptor {
  isLogin: boolean = false

  handle(info: HMInterceptorInfo): HMInterceptorAction {

    if (this.isLogin) {
      let connectionInfo: string = info.type === 'push' ? 'jump to' : 'back to';
      if (info.srcName === "") { // 登录成功进来的,不做任何处理

      }
      console.log(`LoginInterceptor ${info.srcName} ${connectionInfo} ${info.targetName}`)
      return HMInterceptorAction.DO_NEXT
    }
    else { // 没登录跳转到登录页
      let connectionInfo: string = info.type === 'push' ? 'jump to' : 'back to';
      console.log(`LoginInterceptor push LoginPage  ${info.srcName} ${connectionInfo} ${info.targetName}`)
      HMRouterMgr.push({
        pageUrl: "hhy://LoginPage",
        param: { targetUrl: info.targetName },
        skipAllInterceptor: true
      })
      // 拦截结束,不再执行下一个拦截器,不再执行相关转场和路由栈操作
      return HMInterceptorAction.DO_REJECT;
    }
  }
}

当app启动进入首页的时候走到拦截器中,其他tab不会进入拦截器,如果要所有页面都经过拦截器,需要启动的时候做路由嵌套(后面实例中会介绍)

定义生命周期

使用@HMLifecycle标签定义生命周期处理器,并实现IHMLifecycle接口

@HMLifecycle({ lifecycleName: 'PageDurationLifecycle' })
export class PageDurationLifecycle implements IHMLifecycle {
  private time: number = 0;

  onShown(ctx: HMLifecycleContext): void {
    this.time = new Date().getTime();
  }

  onHidden(ctx: HMLifecycleContext): void {
    const duration = new Date().getTime() - this.time;
    Logger.info(`Page ${ctx.navContext?.pathInfo.name} stay ${duration}`);
  }
}

可用的生命周期:
onPrepare?(ctx: HMLifecycleContext): void;
onAppear?(ctx: HMLifecycleContext): void;
onDisAppear?(ctx: HMLifecycleContext): void;
onShown?(ctx: HMLifecycleContext): void;
onHidden?(ctx: HMLifecycleContext): void;
onWillAppear?(ctx: HMLifecycleContext): void;
onWillDisappear?(ctx: HMLifecycleContext): void;
onWillShow?(ctx: HMLifecycleContext): void;
onWillHide?(ctx: HMLifecycleContext): void;
onReady?(ctx: HMLifecycleContext): void;
onBackPressed?(ctx: HMLifecycleContext): boolean;

自定义转场动画

通过@HMAnimator标签定义转场动画,并实现IHMAnimator接口

@HMAnimator({ animatorName: 'liveCommentsAnimator' })
export class liveCommentsAnimator implements IHMAnimator {
  effect(enterHandle: HMAnimatorHandle, exitHandle: HMAnimatorHandle): void {
    // 入场动画
    enterHandle.start((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '100%'
    })
    enterHandle.finish((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '0'
    })
    enterHandle.duration = 500

    // 出场动画
    exitHandle.start((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '0'
    })
    exitHandle.finish((translateOption: TranslateOption, scaleOption: ScaleOption,
      opacityOption: OpacityOption) => {
      translateOption.y = '100%'
    })
    exitHandle.duration = 500
  }
}

使用动画时,目标页的路由传参animator即可

@HMRouter({ pageUrl: '/pageB', lifecycle: 'pageLifecycle', animator: 'pageAnimator' })
@Component
export struct PageB {
}

路由实战案例

为了app启动时就能路由拦截,实现app必须登录后才能使用的场景。下面是路由嵌套的两种实现方式

首页tabs框架搭建

0,先准备测试工程框架
创建首页模块(Static Library : HAR)

首页模块名就叫home,给首页模块MainPage改名为HomePage

注意: 许多人路由跳转不了,就是少了下面这一步!!!

将首页模块依赖到壳工程中 ohpm install features/home/

最后做路由配置,模块的hvigorfile.ts中配置harPlugin

页面展示几个跳转按钮

@HMRouter({ pageUrl: "hhy://homepage", singleton: true })
@Component
export struct HomePage {
  build() {
     Column({space: 5}){
       Button("跳转到搜索模块(不展示tab)").onClick(e=>{

       }).width('90%')
       Button("跳转到搜索模块(展示tab)").onClick(e=>{

       }).width('90%')
     }
    .width("100%")
    .height('100%')
  }
}
创建另一个模块

同样的步骤

1,创建一个搜索模块search(Static Library : HAR),MainPage改名为SearchPage,路由定义为

@HMRouter({ pageUrl: "hhy://homepage", singleton: true })

2,做路由配置,模块的hvigorfile.ts中配置harPlugin

3,将模块依赖到壳工程中 ohpm install features/search/

1,比较常用的方式
// Index页面中定义跟导航,指定首页AppPage
 HMNavigation({
    navigationId: "AppNavigationID",
    homePageUrl: "AppPage",
    options: {
      // 设置动画样式
      standardAnimator: HMDefaultGlobalAnimator.STANDARD_ANIMATOR,
      // 设置弹框动画样式
      dialogAnimator: HMDefaultGlobalAnimator.DIALOG_ANIMATOR,
      // 设置页面navigation的参数,标题栏,工具栏,bar那些
      modifier: this.modifier
    }
  })

// 创建首页 AppPage
build() {
    Column() {
      // 页面入口处定义导航,目的是让路由拦截,可以做全局登录功能
      HMNavigation({
        navigationId: "MainNavigationID",
        options: {
          standardAnimator: HMDefaultGlobalAnimator.STANDARD_ANIMATOR,
          dialogAnimator: HMDefaultGlobalAnimator.DIALOG_ANIMATOR,
          modifier: this.modifier
        }
      }) {
        Tabs({ barPosition: BarPosition.End }) {
          TabContent() {
            HomeMainPage()
          }
          .tabBar(this.tabBuilder('目标', 0, $r('app.media.icon_mubiao_filled'), $r('app.media.icon_mubiao')))
                    。。。。。。

        }
        .onChange((index: number) => {
          this.currentIndex = index
        })
        .barMode(BarMode.Fixed)
        .scrollable(false)
        .animationMode(AnimationMode.NO_ANIMATION)
      }
    }
    .height('100%')
    .width('100%')
  }

实现原理,app入口文件是Index,index中的HMNavigation指向AppPage,这一步是路由导航过去的,所以会执行路由拦截。tabs整体布局在AppPage中实现即可。

2,更灵活的控制每个tab页面导航的方式(用起来略显复杂)

AppPage中tab改为下面这段代码,实现根HMNavigation底下的tab又嵌套一个HMNavigation

TabContent() {
  HMNavigation({
    navigationId: "SubNavigationID",
    homePageUrl: "hhy://homepage",
    options: {
      // 设置动画样式
      standardAnimator: HMDefaultGlobalAnimator.STANDARD_ANIMATOR,
      // 设置弹框动画样式
      dialogAnimator: HMDefaultGlobalAnimator.DIALOG_ANIMATOR,
      // 设置页面navigation的参数,标题栏,工具栏,bar那些
      modifier: this.modifier
    }
  })
}

首页模块的HomePage页面实现的测试方法如下:

build() {
   Column({space: 5}){
     // 页面在tab上面盖着
     Button("跳转到搜索模块(页面在tab上面盖着)").onClick(e=>{
       HMRouterMgr.push({
         navigationId: "AppNavigationID",
         pageUrl: "hhy://searchpage",
         param: { username: "lichanghong" },
         skipAllInterceptor: true
       })
     }).width('90%')
     // 页面在tab下面
     Button("跳转到搜索模块(页面在tab下面)").onClick(e=>{
       HMRouterMgr.push({
         navigationId: "SubNavigationID",
         pageUrl: "hhy://searchpage",
         param: { username: "lichanghong" },
         skipAllInterceptor: true
       })
     }).width('90%')

     // 页面在tab下面
     Button("跳转到搜索模块(默认navigationId是不可控的,不建议").onClick(e=>{
       HMRouterMgr.push({
         pageUrl: "hhy://searchpage",
         param: { username: "lichanghong" },
         skipAllInterceptor: true
       })
     }).width('90%')
   }
  .width("100%")
  .height('100%')
}

运行起来看效果会发现,嵌套路由可以实现跳转的目标页是覆盖到tab上面还是下面,单层路由是做不到的。

真实开发场景中,我们可以不做路由嵌套,当有需要不去覆盖tab的场景的时候,再对个别功能做嵌套即可,这里仅介绍嵌套路由的特殊能力. 上面这种实现方案会让页面跳转更加灵活,但是每次跳转必须指定navigationId

跳转后页面在tab上

跳转后页面在tab下

不指定navigationId有问题

如何在页面中点击某个按钮切换tab页面

首先在页面中定义tab的当前index,通过使用@Consume双向监听index的变化,来触发tab的切换 index变化的监听 (在Tab组件页面实现如下代码)

export struct HomePage {
  @Consume currentIndex: number
  .....
  Button("切换tab").onClick(e => {
     this.currentIndex = 1
  }).width('90%')
}

index变化的监听 (在Tab组件页面实现如下代码)

@Provide @Watch("tabChange")  currentIndex: number = 0
private mTabController: TabsController = new TabsController()

tabChange(propname: string): void {
  this.mTabController.changeIndex(this.currentIndex)
}

重点是@Provide @Watch("tabChange"),通过Provide和其他组件保持双向绑定,在通过watch监听变动事件,从而实现自定义时机切换tab

问题汇总

路由导致如法热更新问题

原因是:路由会清除缓存导致无法热更新

解决方法:在项目根目录创建路由编译插件配置文件hmrouter_config.json,将saveGeneratedFile设置为true

注意: scanDir 下面的路径是路由插件扫描的路径,如果服务或者路由不好使,就确认下路由定义是否在这些目录下