一、前言
最近在写状态管理相关的代码,发现 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 装饰器监听状态变化。但有时候你需要动态添加或删除监听,这时候可以用 addMonitor 和 clearMonitor。
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 里调用,flushUpdates 和 flushUIUpdates 也不能在 @Monitor 回调里调用。
九、总结
UIUtils 提供的功能看着不多,但每个都挺实用的(非常实用了,属于是)。主要解决这些场景:
- 代理对象问题 :用
getTarget拿到原始对象 - 普通数据变可观察 :用
makeObserved或makeV1Observed - V1/V2 混用 :用
enableV2Compatibility让 V1 状态在 V2 中可观察 - 数据绑定 :用
makeBinding创建Binding或MutableBinding - 动态监听 :用
addMonitor和clearMonitor管理监听 - 同步刷新 :用
applySync、flushUpdates、flushUIUpdates控制刷新时机
遇到状态管理的边界问题时,确实能帮上忙。建议先了解这些功能的存在,需要的时候再查文档。用时不慌~~~
十、最后
如果您有任何疑问、对文章写的不满意、发现错误、想吐槽或者有更好的想法,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏
谢谢读者姥爷