HarmonyOS一杯冰美式的时间 -- UIUtils基础功能

一、前言

最近在写状态管理相关的代码,发现 HarmonyOS 的 UIUtils 这个工具类还挺实用的。它主要解决一些状态管理框架在使用过程中遇到的边界问题,比如代理对象、V1/V2 混用、数据绑定这些场景。

今天顺手整理一下它的几个核心功能,方便以后查。

该系列依旧会带着大家,了解,开阔一些不怎么热门的API,也可能是偷偷被更新的API,也可以是好玩的,藏在官方文档的边边角角~当然也会有一些API,之前是我们辛辛苦苦的手撸代码,现在有一个API能帮我们快速实现的,希望大家能找宝藏。

如果您有任何疑问、对文章写的不满意、发现错误、想吐槽或者有更好的想法,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏

二、UIUtil

状态管理框架在背后做了很多工作,比如给对象加代理、自动追踪变化、触发 UI 更新等等。但有时候我们需要绕过这些机制,或者在不同版本的状态管理之间做转换,这时候 UIUtils 就派上用场了。

它提供的功能不算多,但每个都挺有针对性的。下面我们一个个看。

三、getTarget:拿到原始对象

这个功能解决什么问题呢?当你把一个普通对象赋值给 @State 或者 @Local 这样的状态变量时,框架会把它包装成代理对象。有时候你需要拿到原始对象做比较,或者传给某些不接受代理对象的 API,这时候就需要 getTarget

typescript 复制代码
class NonObservedClass {
  name: string = 'Tom';
}

let nonObservedClass: NonObservedClass = new NonObservedClass();

@Entry
@Component
struct Index {
  @State someClass: NonObservedClass = nonObservedClass;

  build() {
    Column() {
      // 直接比较是 false,因为 this.someClass 是代理对象
      Text(`this.someClass === nonObservedClass: ${this.someClass === nonObservedClass}`) // false
      
      // 用 getTarget 拿到原始对象,就能比较了
      Text(`UIUtils.getTarget(this.someClass) === nonObservedClass: ${UIUtils.getTarget(this.someClass) ===
        nonObservedClass}`) // true
    }
  }
}

实际开发中,这种场景其实不算特别常见,但遇到的时候确实能解决问题。比如你要把状态对象序列化,或者传给第三方库,可能就需要先拿到原始对象。

1、V2 状态管理的限制

需要注意的是,在 V2 状态管理中,getTarget 有个限制。状态管理 V2 装饰器会为装饰的变量生成 getter 和 setter 方法,同时为原有变量名添加 __ob_ 前缀。出于性能考虑,getTarget 接口不会对 V2 装饰器生成的前缀进行处理。

也就是说,如果你向 getTarget 传入 @ObservedV2 装饰的类对象实例,返回的对象依旧为对象本身,且被 @Trace 装饰的属性名仍有 __ob_ 前缀。

这个前缀不影响对象属性的 getter 和 setter 方法使用,但如果你需要序列化,可能会遇到问题。比如:

typescript 复制代码
@ObservedV2
class FormDataClassV2 {
  @Trace name: string = '默认名称';
  @Trace price: number = 0;
}

@Entry
@ComponentV2
struct FormDataClassPage {
  @Local data: FormDataClassV2 = new FormDataClassV2();

  build() {
    Column() {
      Button('序列化')
        .onClick(() => {
          // 序列化后会有 __ob_ 前缀
          console.info('序列化原始值:', JSON.stringify(this.data));
          // 输出类似:{"__ob_name":"默认名称","__ob_price":0}
        });
    }
    .height('100%')
    .width('100%');
  }
}

2、解决方案

如果涉及序列化与反序列化,有两种方案可以去除前缀:

方案一:创建新类转换

创建一个新的类,类中属性名和原来的对象相同,用原来对象的值来初始化新类的对象:

typescript 复制代码
@ObservedV2
class FormDataClassV2 {
  @Trace name: string = '默认名称';
  @Trace price: number = 0;
}

class FormDataClass {
  name: string = '';
  price: number = 0;

  constructor(v: FormDataClassV2) {
    this.name = v.name;
    this.price = v.price;
  }
}

@Entry
@ComponentV2
struct FormDataClassPage {
  @Local data: FormDataClassV2 = new FormDataClassV2();

  build() {
    Column() {
      Button('序列化')
        .onClick(() => {
          console.info('序列化原始值:', JSON.stringify(this.data));
          // 转换后序列化,没有前缀
          console.info('序列化转换后:', JSON.stringify(new FormDataClass(this.data)));
        });
    }
    .height('100%')
    .width('100%');
  }
}

这种方案比较稳妥,类型安全,但需要额外维护一个类。

方案二:字符串替换

如果序列化后 __ob_ 前缀会导致反序列化异常,可以考虑采用修改序列化后的字符串的方式,去除 __ob_ 前缀:

typescript 复制代码
@ObservedV2
class FormDataClassV2 {
  @Trace name: string = '默认名称';
  @Trace price: number = 0;
}

@Entry
@ComponentV2
struct FormDataClassPage {
  @Local data: FormDataClassV2 = new FormDataClassV2();

  build() {
    Column() {
      Button('序列化')
        .onClick(() => {
          console.info('序列化原始值:', JSON.stringify(this.data));
          // 用正则替换去除前缀
          let newData = JSON.stringify(this.data).replace(/__ob_/g, '');
          console.info('序列化过滤后:', newData);
        });
    }
    .height('100%')
    .width('100%');
  }
}

这种方案简单直接,但要注意如果属性名本身包含 __ob_ 字符串,也会被替换掉。所以如果数据比较复杂,建议用方案一。

总的来说,__ob_ 前缀是 V2 状态管理的实现细节,大部分情况下不影响使用。只有在序列化场景下才需要注意这个问题。

四、makeObserved:让普通数据可观察

这个功能在 V2 的状态管理中比较常用。有时候你拿到的是普通对象(比如从 JSON.parse 来的,或者普通 class 实例),但你想让它变成可观察的,这样修改属性时 UI 能自动更新。

typescript 复制代码
class NonObservedClass {
  name: string = 'Tom';
}

@Entry
@ComponentV2
struct Index {
  // 用 makeObserved 包装后,修改 name 会触发 UI 更新
  observedClass: NonObservedClass = UIUtils.makeObserved(new NonObservedClass());
  
  // 普通对象,修改不会触发更新
  nonObservedClass: NonObservedClass = new NonObservedClass();

  build() {
    Column() {
      Text(`observedClass: ${this.observedClass.name}`)
        .onClick(() => {
          this.observedClass.name = 'Jane'; // 会刷新
        })
      Text(`nonObservedClass: ${this.nonObservedClass.name}`)
        .onClick(() => {
          this.nonObservedClass.name = 'Jane'; // 不会刷新
        })
    }
  }
}

注意,makeObserved 支持的类型还挺多的:普通 class、JSON.parse 返回的对象、Array、Map、Set、Date,还有 collections 里的类型。但如果是 @Sendable 修饰的 class,就不支持了。

五、V1 和 V2 混用的问题

HarmonyOS 的状态管理有两个版本,V1 和 V2。如果你在维护老项目,或者需要逐步迁移,可能会遇到混用的情况。这时候有两个方法能帮上忙。这个如果不是天天刷这个文档,我相信大家一定没有注意到的,嘿嘿

1、enableV2Compatibility:让 V1 状态在 V2 组件中可观察

如果你在 @Component(V1)里定义了 @State,然后想传给 @ComponentV2(V2)使用,直接用是不行的。V2 组件观察不到 V1 状态的变化。

这时候可以用 enableV2Compatibility 包装一下:

typescript 复制代码
@Observed
class ObservedClass {
  name: string = 'Tom';
}

@Entry
@Component
struct CompV1 {
  @State observedClass: ObservedClass = new ObservedClass();

  build() {
    Column() {
      Text(`@State observedClass: ${this.observedClass.name}`)
        .onClick(() => {
          this.observedClass.name = 'State'; // 刷新
        })
      // 包装后传给 V2 组件,V2 就能观察到第一层的变化了
      CompV2({ observedClass: UIUtils.enableV2Compatibility(this.observedClass) })
    }
  }
}

@ComponentV2
struct CompV2 {
  @Param observedClass: ObservedClass = new ObservedClass();

  build() {
    Text(`@Param observedClass: ${this.observedClass.name}`)
      .onClick(() => {
        this.observedClass.name = 'Param'; // 刷新
      })
  }
}

注意,V2 只能观察到第一层的变化。如果 ObservedClass 内部还有嵌套对象,修改嵌套对象的属性,V2 是观察不到的。

2、makeV1Observed:包装成 V1 可观察对象

这个功能和 makeObserved 类似,但包装出来的是 V1 的可观察对象。主要用在需要初始化 @ObjectLink 的场景。

typescript 复制代码
class Outer {
  outerValue: string = 'outer';
  inner: Inner;

  constructor(inner: Inner) {
    this.inner = inner;
  }
}

class Inner {
  interValue: string = 'inner';
}

@Entry
@Component
struct Index {
  // makeV1Observed 的返回值可以初始化 @ObjectLink
  @State outer: Outer = new Outer(UIUtils.makeV1Observed(new Inner()));

  build() {
    Column() {
      Child({ inner: this.outer.inner })
    }
    .height('100%')
    .width('100%')
  }
}

@Component
struct Child {
  @ObjectLink inner: Inner;

  build() {
    Text(`${this.inner.interValue}`)
      .onClick(() => {
        this.inner.interValue += '!';
      })
  }
}

makeV1Observed 不支持 collections 类型和 @Sendable 修饰的 class,也不支持 V2 的数据和 makeObserved 的返回值。如果传了不支持的参数,会直接返回原对象,不会报错。

六、makeBinding:创建数据绑定

这个功能在写 @Builder 函数时比较有用。有时候你想让 @Builder 接收一个可读或可写的数据绑定,而不是直接传值,这时候可以用 makeBinding 创建。

1、只读绑定

如果 @Builder 的参数类型是 Binding<T>,你可以用 makeBinding 创建一个只读绑定:

typescript 复制代码
@Builder
function CustomButton(num1: Binding<number>) {
  Row() {
    Button(`Custom Button: ${num1.value}`)
      .onClick(() => {
        // num1.value += 1; 会报错,Binding 类型不支持修改
      })
  }
}

@Entry
@ComponentV2
struct CompV2 {
  @Local number1: number = 5;

  build() {
    Column() {
      Text('parent component')
      // 每次访问 .value 时,会重新执行 getter 函数,拿到最新值
      CustomButton(
        UIUtils.makeBinding<number>(
          () => this.number1 // GetterCallback
        )
      )
    }
  }
}

2、可写绑定

如果需要双向绑定,可以传两个参数:getter 和 setter:

typescript 复制代码
@Builder
function CustomButton(num2: MutableBinding<number>) {
  Row() {
    Button(`Custom Button: ${num2.value}`)
      .onClick(() => {
        // MutableBinding 支持修改
        num2.value += 1;
      })
  }
}

@Entry
@ComponentV2
struct CompV2 {
  @Local number2: number = 10;

  build() {
    Column() {
      Text('parent component')
      CustomButton(
        UIUtils.makeBinding<number>(
          () => this.number2, // GetterCallback
          (val: number) => {
            this.number2 = val; // SetterCallback,修改时会自动调用
          }
        )
      )
    }
  }
}

注意,如果创建 MutableBinding 时没传 setter,修改 .value 会报运行时错误。所以需要双向绑定时,setter 是必须的。

七、addMonitor 和 clearMonitor:动态监听

在 V2 的状态管理中,你可以用 @Monitor 装饰器监听状态变化。但有时候你需要动态添加或删除监听,这时候可以用 addMonitorclearMonitor

typescript 复制代码
@ObservedV2
class ObservedClass {
  @Trace name: string = 'Tom';

  onChange(mon: IMonitor) {
    mon.dirty.forEach((path: string) => {
      console.info(`ObservedClass property ${path} change from ${mon.value(path)?.before} to ${mon.value(path)?.now}`);
    });
  }

  constructor() {
    // 在构造方法里添加监听,isSynchronous: true 表示同步回调
    UIUtils.addMonitor(this, 'name', this.onChange, { isSynchronous: true });
  }
}

@Entry
@ComponentV2
struct Index {
  @Local observedClass: ObservedClass = new ObservedClass();

  build() {
    Column() {
      Text(`name: ${this.observedClass.name}`)
        .fontSize(20)
        .onClick(() => {
          this.observedClass.name = 'Jack';
          this.observedClass.name = 'Jane';
        })
    }
  }
}

addMonitor 只支持 @ComponentV2@ObservedV2 实例,如果传了不支持的类型会抛运行时错误。

删除监听用 clearMonitor

typescript 复制代码
Button('clear monitor')
  .onClick(() => {
    // 删除指定路径的指定监听函数
    UIUtils.clearMonitor(this.observedClass, 'age', this.observedClass.onChange);
    
    // 如果不传 monitorCallback,会删除该路径的所有监听函数
    // UIUtils.clearMonitor(this.observedClass, 'age');
  })

八、同步刷新:applySync、flushUpdates、flushUIUpdates

这三个方法都是用来同步刷新状态变化的,但作用范围不同。

1、applySync:刷新闭包内的修改

applySync 接收一个闭包函数,只刷新闭包内的状态修改:

typescript 复制代码
Button('change size')
  .onClick(() => {
    // 闭包内的修改会同步执行,包括更新 @Computed、@Monitor 回调和重新渲染 UI
    UIUtils.applySync(() => {
      this.w = 100;
      this.h = 100;
      this.message = 'Hello World';
    });
    
    // 动画从刷新后的状态开始
    this.getUIContext().animateTo({
      duration: 1000
    }, () => {
      this.w = 200;
      this.h = 200;
      this.message = 'Hello ArkUI';
    });
  })

2、flushUpdates:刷新所有之前的修改

flushUpdates 会同步刷新调用之前所有的状态修改:

typescript 复制代码
Button('change size')
  .onClick(() => {
    this.w = 100;
    this.h = 100;
    this.message = 'Hello World';
    
    // 刷新之前所有的修改
    UIUtils.flushUpdates();
    
    // 动画从刷新后的状态开始
    this.getUIContext().animateTo({
      duration: 1000
    }, () => {
      this.w = 200;
      this.h = 200;
      this.message = 'Hello ArkUI';
    });
  })

3、flushUIUpdates:只刷新 UI,不执行计算和回调

flushUIUpdates 只同步标脏 UI 节点,不会执行 @Computed 计算和 @Monitor 回调:

typescript 复制代码
Button('change size')
  .onClick(() => {
    this.w = 100;
    this.h = 100;
    this.message = 'Hello World';
    
    // 只刷新 UI,不执行计算和回调
    UIUtils.flushUIUpdates();
    
    // 动画从刷新后的状态开始
    this.getUIContext().animateTo({
      duration: 1000
    }, () => {
      this.w = 200;
      this.h = 200;
      this.message = 'Hello ArkUI';
    });
  })

这三个方法在动画场景下比较有用。比如你想在动画开始前确保 UI 已经更新到某个状态,就可以用它们。

注意,这三个方法都不能在 @Computed 里调用,flushUpdatesflushUIUpdates 也不能在 @Monitor 回调里调用。

九、总结

UIUtils 提供的功能看着不多,但每个都挺实用的(非常实用了,属于是)。主要解决这些场景:

  1. 代理对象问题 :用 getTarget 拿到原始对象
  2. 普通数据变可观察 :用 makeObservedmakeV1Observed
  3. V1/V2 混用 :用 enableV2Compatibility 让 V1 状态在 V2 中可观察
  4. 数据绑定 :用 makeBinding 创建 BindingMutableBinding
  5. 动态监听 :用 addMonitorclearMonitor 管理监听
  6. 同步刷新 :用 applySyncflushUpdatesflushUIUpdates 控制刷新时机

遇到状态管理的边界问题时,确实能帮上忙。建议先了解这些功能的存在,需要的时候再查文档。用时不慌~~~

十、最后

如果您有任何疑问、对文章写的不满意、发现错误、想吐槽或者有更好的想法,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏

谢谢读者姥爷

相关推荐
行者964 小时前
Flutter与OpenHarmony跨平台分享组件深度实践
flutter·harmonyos·鸿蒙
行者964 小时前
Flutter跨平台开发在OpenHarmony上的评分组件实现与优化
开发语言·flutter·harmonyos·鸿蒙
90后的晨仔6 小时前
HarmonyOS 多模块项目中的公共库治理与最佳实践
harmonyos
lili-felicity9 小时前
React Native 鸿蒙跨平台开发:LayoutAnimation 实现鸿蒙端按钮点击的缩放反馈动画
react native·react.js·harmonyos
哈__11 小时前
React Native 鸿蒙跨平台开发:Dimensions 屏幕尺寸获取
react native·华为·harmonyos
奋斗的小青年!!12 小时前
Flutter跨平台开发适配OpenHarmony:手势识别实战应用
flutter·harmonyos·鸿蒙
搬砖的kk13 小时前
Cordova 适配鸿蒙系统(OpenHarmony) 全解析:技术方案、环境搭建与实战开发
华为·开源·harmonyos
不爱吃糖的程序媛13 小时前
OpenHarmony 通用C/C++三方库 标准化鸿蒙化适配
c语言·c++·harmonyos
程序猿追13 小时前
鸿蒙PC应用开发深度实战:一次开发、多端适配的沉浸式音乐播放器迁移实践
华为·harmonyos