纯血鸿蒙Next 开发经验技巧(1)

1,069 阅读9分钟

一.鸿蒙各个屏幕尺寸适配

我们需要对手机、平板等适配时,可以使用其中的“断点”概念完成适配。例如使用GridRow和GridCol等。

我们先定义断点处的大小,例如下面的YYSBreakPoints数组,包含两个元素,那么就将设备分为了三个区间。

1.1 我们先定义好如下的适配类。

export class UIConstants {
static XS = "xs"
static SM = "sm"
static MD = "md"
static LG = "lg"
static XL = "xl"
static XXL = "xxl"
static YYSBreakPoints: string[] = ["800vp", "1600vp"]
}

1.2 我们在实际的编码处将如下的代码加入,那么它就会监听到或者说检测到当前设备的大小,从而触发我们编写的逻辑,其中的“this.deviceWidth”当然是我们当前类中具有状态的值,从而改变布局尺寸。

@Builder
GridFitSize() {
GridRow({
 breakpoints: { value: UIConstants.YYSBreakPoints },
}) {
 GridCol().height(1).width(1)
}.onBreakpointChange((breakpoints: string) => {
 if (breakpoints == UIConstants.XS || breakpoints == UIConstants.SM) {
   /// 小设备.
   this.deviceWidth = 屏幕宽度 / 3 
 } else if (breakpoints == UIConstants.LG) {
   /// 大设备.
   this.deviceWidth = 屏幕宽度 / 7.5
 } else {
   /// 超大设备.
   this.deviceWidth = 屏幕宽度 / 11.5
 }
}).height(1).width(1)
}

二.获取组件在设备中相对于屏幕的x坐标

其实获取到组件的相关大小尺寸x,y的坐标都是可以的,我这里只是做一个示范,因为这里有坑.

2.1 我们首先要为我们要获取相关尺寸信息的组件设置一个唯一的id,这个id为组件的通用属性.我们先设置好了,才能通过此id获取到组件的尺寸信息.

2.2 然后通过id获取到componentUtils.ComponentInfo

let position: componentUtils.ComponentInfo =
this.getUIContext()
  .getComponentUtils()
  .getRectangleById('你事先设置好的组件id')

2.3 最后我们获取x坐标,screenOffset属性为相对于屏幕的坐标信息,它还有localOffset是相对于父视图组件的等。这里有个坑,原因不明,它所拿到的坐标好像并不是最真实的,需要我们进行一个转化。代码如下:

let result:number = position.screenOffset.x * (this.deviceWidth / position.size.width)

我们上面在第二歩获取到的position拿到后,我们通过2.3进行转化,this.deviceWidth是我们的这个组件宽度(假设我们写死了这个组件的宽度),除以position.size.width后在乘以position.screenOffset.x才能拿到真正的相对于手机屏幕的x轴坐标.

三.关于视图的布局经验.

3.1 如果你一直使用Row\Column等线性布局,使用'100%'可能会超出去,那么我们可以这样写:

.width('calc(100% - 32vp)')

使用calc进行计算,这会比较方便,经常用到。

3.2 热区设置:关于热区的操作鸿蒙提供了比较方便的API,这是我在其他移动端技术栈中都没有遇到过的,可能会比较方便,因为我并没有实践。

responseRegion(value: Array | Rectangle)

它后面是一个数组,可以设置好几个热区,但是从官方的解释中,它并没有说可以扩大热区。

扩大热区的另外一个办法是为组件设置padding,但是你要算好它的宽和高,与padding相互配合,然后去实现它的onClick方法,这是比较常见的,在其他技术栈中也适用的方法。

3.3 .layoutWeight(1) 它也比较重要,从官方文档中,你可能完全读不出来它在实际开发中的大用。它被常用于在线性布局中,比如Row\Colum中,.layoutWeight(1)这样设置为1后,它可以让它所设置的组件,在主轴方向上占满剩余的空间,具体的其他使用可以查看官方文档

3.4 再啰嗦一条总结:flexGrow(0) : 用于弹性布局,占用剩余空间,1表示占满,设置为0表示保留自身大小。layoutWeight: 用于线性布局,占用剩余空间,1表示占满

四.关于鸿蒙MVVM的实践

鸿蒙与Flutter不太一样,它不能在一个viewModel中进行状态管理,只能在viewModel处理完逻辑后将数据回调到@Component或者@Entry中,然后在它们中进行状态管理才行.要将一些逻辑放在viewModel中我实践中出来就两个方式

1.重写class的构造方法,以回调函数作为此构造方法的参数,给到viewModel的回调方法属性中。

2.不通过构造函数,直接在viewModel中定义回调函数属性,在视图层给回调函数给值。

 
type HaveDataCallback = (hasData: boolean) => void;

/// 有多个参数的话,可以使用Class或者interface整合起来.  
declare interface PersonParam {
  address?: string | null,
  hasDataCallback?: HaveDataCallback
}

export class PersonPageViewModel{
    public getNameCallback?: (userName: string) => void
    public refreshDataCallback?: () => void
    hasDataCallback?: HaveDataCallback
    
    constructor(personParam?: PersonParam,) {
        /// 方式一:通过重写constructor构造函数实现,个人觉得不够优雅.
        this.hasDataCallback = personParam?.hasDataCallback
    }
    
 }

方式二:直接在component中给值(比较优雅)

@Entry
@Component
export struct PersonPage{
  /// 这里我们假设此时PersonPageViewModel并没有重写constructor构造方法,好让语法通过. 
  private viewModel: PersonPageViewModel = new PersonPageViewModel()
   
  async aboutToAppear(): Promise<void> {
      /// 顺带演示如何获取路由参数
      let params: ESObject = router.getParams() as ESObject
      if (!params) {
          return
      }
      await this.viewModel.getPersonInfo(params)
      /// 配置viewModel的路由回调,关键就在此处。
      this.configViewModelCallback()
   }
 
 configViewModelCallback(){
    this.viewModel.refreshDataCallback = ()=>{
      /// 配置viewModel中的refreshDataCallback回调
 
    }
    this.viewModel.getNameCallback = (userName: string) => {
      /// 初始化viewModel中的getNameCallback回调.
    }
 } 
 
}
 

五.组件化与单例内部类

鸿蒙中的组件化是通过动态共享包来实现的,也就是官方文档中的“分包”概念,项目中的不同开发者依据自己的开发模块创建不同的动态共享包,然后进行任务开发。但是!!这里有个坑,比如有了单例内部类,按照道理说在项目中此单例只会初始化一次,但是在分包时,比如上一个包使用了这个单例内部类,然后路由到另外一个分包时,此单例会被重新创建,导致数据的变更,解决方法是使用AppStorage对此单例进行存储,这样在不同分包中进行路由跳转时,就能够保证此单例还是这个对象,并没有被重新创建。代码如下:

  export class DataInfoManger {
       
   private static instance: DataInfoManger | null

   /// 私有化,防止外部调用.
   private constructor() {}
       
  /// 获取单例 DataInfoManger
  /// 如果不使用AppStorage的话,在跨包路由的时候可能会发生重建.
  static getInstance(): DataInfoManger {
      if (DataInfoManger.instance == null) {
        DataInfoManger.instance = AppStorage.get<DataInfoManger>('DataInfoManger') as DataInfoManger;
      if (!DataInfoManger.instance) {
        DataInfoManger.instance = new DataInfoManger();
        AppStorage.setOrCreate('DataInfoManger', DataInfoManger.instance);
        }
      }
      return DataInfoManger.instance
   }

  }

六.@Builder按值传递问题

在官方的解释中,我们知道这个装饰器默认是按照值传递的,所以你没有注意到这一点的话,在平时的开发中你会发现,我的逻辑和值都是正确的,为什么将值传递进@Builder修饰的方法中,为什么UI并没有变化,因为它是按照值传递的,并不会引起UI的刷新。 这是官方的文档截图:

WechatIMG260.jpg

只有按照引用的方式传递值,才会触发动态渲染刷新,切记,切记!

/// 1.按照引用传递,好像也可以使用interface,但是一般用class
class ItemUpdateData {
  title: string = '';
  isSelected: boolean = false;
  index: number = 0
}

/// 顺带演示一下如何定义一个回调函数类型.
export type ItemDataCallBack = (type:string) => void
    
/// 2.此时我们调用这个setupItem的自定义构建函数时,传人的ItemUpdateData对象才会引起UI的刷新.    
@Builder
setupItem(itemData: ItemUpdateData) {
    Column(){
      xxxxxx
    }
}

七.实现一个图片从上到下按照一条直线一直运动的动画

我的思路是:不使用诸如贝赛儿曲线等,通过创建一个AnimatorResult对象,不断的改变一个Image组件的margin的top属性来达到效果,示范代码如下:

    /// 1.定义一个私有的AnimatorResult对象
    private imageAnimator: AnimatorResult | undefined = undefined
    /// 2.定义图片的外间距,并作为状态管理.
    @State imageMarginTop: number = 0
    
    /// 3.组件出现前就创建AnimatorResult对象
    aboutToAppear(): void {
      this.createImageAnimation()
    }
    
    build(){
       Stack() {
          /// xxxx 其他布局代码
          /// 4.要做动画的Image组件
          Image($r('app.media.your_header'))
            .width(10).height(16.25)
            .margin({ left: 0, top: this.imageMarginTop })
       }.align(Alignment.Top)
        .onAppear(() => {
           /// 5.在组件的onAppear生命周期中,调用执行动画的API
           this.imageAnimator?.play()
        })
    }
    
    /// 6.创建动画AnimatorResult对象
    createImageAnimation() {
      /// 属性具体意思可以去官方文档查阅
      this.imageAnimator = Animator.create({
        duration: 2000,
        easing: "linear",
        delay: 0,///动画延时播放时长
        fill: "backwards",/// 动画将在animation-delay期间应用第一个关键帧中定义的值。即执行完毕再从头开始.
        direction: "normal",
        iterations: -1,/// 动画播放次数。设置为0时不播放,设置为-1时无限次播放。
        begin: 0,
        end: 50
      })
      this.imageAnimator!.onFrame = (value: number) => {
        /// 在动画执行期间不断改变Image组件的margin的top属性
        this.imageMarginTop = value
      }
    }
    

八.布局回调.

只有在@entry中,才会有onPageShow !!! 我发现很多人以为@component也会走onPageShow生命周期回调,其实只有在struct被@entry修饰时,才会走onPageShow回调,也就是布局完成的回调。如果说本struct只被@component修饰,是不会走的,那会走什么呢?当完成布局时,会走布局回调!

/// 监听页面渲染.
private layoutListener: inspector.ComponentObserver = 
   this.getUIContext().getUIInspector().createComponentObserver('layoutListener')
   
/// 页面出现进行监听
aboutToAppear(): void {
 this.layoutListener.on('layout', this.layoutFinish.bind(this))
}
   
/// 页面消失取消监听
aboutToDisappear(): void {
 this.layoutListener.off('layout', this.layoutFinish.bind(this))
}    

/// 完成执行此函数.
layoutFinish(){

}
   

九:方法调用

方法调用有什么好说的?这可能是我遇到的一个问题吧,是一个小细节,它与js和dart语言有点不一样,所以当时开发的时候导致了我的错误。

    /// 1.比如说我定义了一个变量,注意它是联合类型的.
    private name?:string | null = null  
    
    /// 2.我再定义一个方法
    useName(name:string){
    
    }
    
    /// 3.我在调用这个方法的时候,我这样写
    if (this.name != null){
       this.useName(this.name)
    }
    
    上面这样写程序会直接崩溃,系统报入参类型不正确。即使你在外面判断了this.name是否为空,这都不起作用。
    
    一个解决办法是,在if条件判空之后,使用感叹号强制解包.另外一个办法是把我们声明的属性类型改为此方法相同的类型.
    

十.LazyForEach的刷新

这里并无代码演示,只是提醒一下。我们都知道它是懒加载,可以加载一个数组等内容。但是,我们需要注意的是它的Key,也就是它后面的一个参数。它的参数与构建UI的参数是一致的,系统默认是有值的,它让我们返回一个字符串,当这个字符串值没有改变的时候,我们构建的Item的UI是不会刷新的,但是当我们使用状态管理的一些变量作为返回值时,只要此返回的Key值发生改变,那么对应的Item的UI也会刷新。

好了,集齐10条,收工!