一、前言
最近在写状态管理相关的代码,发现 HarmonyOS 的 UIUtils 这个工具类还挺实用的。它主要解决一些状态管理框架在使用过程中遇到的边界问题,比如代理对象、V1/V2 混用、数据绑定这些场景。
今天顺手整理一下它的几个核心功能,方便以后查。
该系列依旧会带着大家,了解,开阔一些不怎么热门的API,也可能是偷偷被更新的API,也可以是好玩的,藏在官方文档的边边角角~当然也会有一些API,之前是我们辛辛苦苦的手撸代码,现在有一个API能帮我们快速实现的,希望大家能找宝藏。
如果您有任何疑问、对文章写的不满意、发现错误、想吐槽或者有更好的想法,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏
二、UIUtil
状态管理框架在背后做了很多工作,比如给对象加代理、自动追踪变化、触发 UI 更新等等。但有时候我们需要绕过这些机制,或者在不同版本的状态管理之间做转换,这时候 UIUtils 就派上用场了。
它提供的功能不算多,但每个都挺有针对性的。下面我们一个个看。
三、getTarget:拿到原始对象
这个功能解决什么问题呢?当你把一个普通对象赋值给 @State 或者 @Local 这样的状态变量时,框架会把它包装成代理对象。有时候你需要拿到原始对象做比较,或者传给某些不接受代理对象的 API,这时候就需要 getTarget。
1class NonObservedClass { 2 name: string = 'Tom'; 3} 4 5let nonObservedClass: NonObservedClass = new NonObservedClass(); 6 7@Entry 8@Component 9struct Index { 10 @State someClass: NonObservedClass = nonObservedClass; 11 12 build() { 13 Column() { 14 // 直接比较是 false,因为 this.someClass 是代理对象 15 Text(`this.someClass === nonObservedClass: ${this.someClass === nonObservedClass}`) // false 16 17 // 用 getTarget 拿到原始对象,就能比较了 18 Text(`UIUtils.getTarget(this.someClass) === nonObservedClass: ${UIUtils.getTarget(this.someClass) === 19 nonObservedClass}`) // true 20 } 21 } 22} 23
实际开发中,这种场景其实不算特别常见,但遇到的时候确实能解决问题。比如你要把状态对象序列化,或者传给第三方库,可能就需要先拿到原始对象。
1、V2 状态管理的限制
需要注意的是,在 V2 状态管理中,getTarget 有个限制。状态管理 V2 装饰器会为装饰的变量生成 getter 和 setter 方法,同时为原有变量名添加 __ob_ 前缀。出于性能考虑,getTarget 接口不会对 V2 装饰器生成的前缀进行处理。
也就是说,如果你向 getTarget 传入 @ObservedV2 装饰的类对象实例,返回的对象依旧为对象本身,且被 @Trace 装饰的属性名仍有 __ob_ 前缀。
这个前缀不影响对象属性的 getter 和 setter 方法使用,但如果你需要序列化,可能会遇到问题。比如:
1@ObservedV2 2class FormDataClassV2 { 3 @Trace name: string = '默认名称'; 4 @Trace price: number = 0; 5} 6 7@Entry 8@ComponentV2 9struct FormDataClassPage { 10 @Local data: FormDataClassV2 = new FormDataClassV2(); 11 12 build() { 13 Column() { 14 Button('序列化') 15 .onClick(() => { 16 // 序列化后会有 __ob_ 前缀 17 console.info('序列化原始值:', JSON.stringify(this.data)); 18 // 输出类似:{"__ob_name":"默认名称","__ob_price":0} 19 }); 20 } 21 .height('100%') 22 .width('100%'); 23 } 24} 25
2、解决方案
如果涉及序列化与反序列化,有两种方案可以去除前缀:
方案一:创建新类转换
创建一个新的类,类中属性名和原来的对象相同,用原来对象的值来初始化新类的对象:
1@ObservedV2 2class FormDataClassV2 { 3 @Trace name: string = '默认名称'; 4 @Trace price: number = 0; 5} 6 7class FormDataClass { 8 name: string = ''; 9 price: number = 0; 10 11 constructor(v: FormDataClassV2) { 12 this.name = v.name; 13 this.price = v.price; 14 } 15} 16 17@Entry 18@ComponentV2 19struct FormDataClassPage { 20 @Local data: FormDataClassV2 = new FormDataClassV2(); 21 22 build() { 23 Column() { 24 Button('序列化') 25 .onClick(() => { 26 console.info('序列化原始值:', JSON.stringify(this.data)); 27 // 转换后序列化,没有前缀 28 console.info('序列化转换后:', JSON.stringify(new FormDataClass(this.data))); 29 }); 30 } 31 .height('100%') 32 .width('100%'); 33 } 34} 35
这种方案比较稳妥,类型安全,但需要额外维护一个类。
方案二:字符串替换
如果序列化后 __ob_ 前缀会导致反序列化异常,可以考虑采用修改序列化后的字符串的方式,去除 __ob_ 前缀:
1@ObservedV2 2class FormDataClassV2 { 3 @Trace name: string = '默认名称'; 4 @Trace price: number = 0; 5} 6 7@Entry 8@ComponentV2 9struct FormDataClassPage { 10 @Local data: FormDataClassV2 = new FormDataClassV2(); 11 12 build() { 13 Column() { 14 Button('序列化') 15 .onClick(() => { 16 console.info('序列化原始值:', JSON.stringify(this.data)); 17 // 用正则替换去除前缀 18 let newData = JSON.stringify(this.data).replace(/__ob_/g, ''); 19 console.info('序列化过滤后:', newData); 20 }); 21 } 22 .height('100%') 23 .width('100%'); 24 } 25} 26
这种方案简单直接,但要注意如果属性名本身包含 __ob_ 字符串,也会被替换掉。所以如果数据比较复杂,建议用方案一。
总的来说,__ob_ 前缀是 V2 状态管理的实现细节,大部分情况下不影响使用。只有在序列化场景下才需要注意这个问题。
四、makeObserved:让普通数据可观察
这个功能在 V2 的状态管理中比较常用。有时候你拿到的是普通对象(比如从 JSON.parse 来的,或者普通 class 实例),但你想让它变成可观察的,这样修改属性时 UI 能自动更新。
1class NonObservedClass { 2 name: string = 'Tom'; 3} 4 5@Entry 6@ComponentV2 7struct Index { 8 // 用 makeObserved 包装后,修改 name 会触发 UI 更新 9 observedClass: NonObservedClass = UIUtils.makeObserved(new NonObservedClass()); 10 11 // 普通对象,修改不会触发更新 12 nonObservedClass: NonObservedClass = new NonObservedClass(); 13 14 build() { 15 Column() { 16 Text(`observedClass: ${this.observedClass.name}`) 17 .onClick(() => { 18 this.observedClass.name = 'Jane'; // 会刷新 19 }) 20 Text(`nonObservedClass: ${this.nonObservedClass.name}`) 21 .onClick(() => { 22 this.nonObservedClass.name = 'Jane'; // 不会刷新 23 }) 24 } 25 } 26} 27
注意,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 包装一下:
1@Observed 2class ObservedClass { 3 name: string = 'Tom'; 4} 5 6@Entry 7@Component 8struct CompV1 { 9 @State observedClass: ObservedClass = new ObservedClass(); 10 11 build() { 12 Column() { 13 Text(`@State observedClass: ${this.observedClass.name}`) 14 .onClick(() => { 15 this.observedClass.name = 'State'; // 刷新 16 }) 17 // 包装后传给 V2 组件,V2 就能观察到第一层的变化了 18 CompV2({ observedClass: UIUtils.enableV2Compatibility(this.observedClass) }) 19 } 20 } 21} 22 23@ComponentV2 24struct CompV2 { 25 @Param observedClass: ObservedClass = new ObservedClass(); 26 27 build() { 28 Text(`@Param observedClass: ${this.observedClass.name}`) 29 .onClick(() => { 30 this.observedClass.name = 'Param'; // 刷新 31 }) 32 } 33} 34
注意,V2 只能观察到第一层的变化。如果 ObservedClass 内部还有嵌套对象,修改嵌套对象的属性,V2 是观察不到的。
2、makeV1Observed:包装成 V1 可观察对象
这个功能和 makeObserved 类似,但包装出来的是 V1 的可观察对象。主要用在需要初始化 @ObjectLink 的场景。
1class Outer { 2 outerValue: string = 'outer'; 3 inner: Inner; 4 5 constructor(inner: Inner) { 6 this.inner = inner; 7 } 8} 9 10class Inner { 11 interValue: string = 'inner'; 12} 13 14@Entry 15@Component 16struct Index { 17 // makeV1Observed 的返回值可以初始化 @ObjectLink 18 @State outer: Outer = new Outer(UIUtils.makeV1Observed(new Inner())); 19 20 build() { 21 Column() { 22 Child({ inner: this.outer.inner }) 23 } 24 .height('100%') 25 .width('100%') 26 } 27} 28 29@Component 30struct Child { 31 @ObjectLink inner: Inner; 32 33 build() { 34 Text(`${this.inner.interValue}`) 35 .onClick(() => { 36 this.inner.interValue += '!'; 37 }) 38 } 39} 40
makeV1Observed 不支持 collections 类型和 @Sendable 修饰的 class,也不支持 V2 的数据和 makeObserved 的返回值。如果传了不支持的参数,会直接返回原对象,不会报错。
六、makeBinding:创建数据绑定
这个功能在写 @Builder 函数时比较有用。有时候你想让 @Builder 接收一个可读或可写的数据绑定,而不是直接传值,这时候可以用 makeBinding 创建。
1、只读绑定
如果 @Builder 的参数类型是 Binding<T>,你可以用 makeBinding 创建一个只读绑定:
1@Builder 2function CustomButton(num1: Binding<number>) { 3 Row() { 4 Button(`Custom Button: ${num1.value}`) 5 .onClick(() => { 6 // num1.value += 1; 会报错,Binding 类型不支持修改 7 }) 8 } 9} 10 11@Entry 12@ComponentV2 13struct CompV2 { 14 @Local number1: number = 5; 15 16 build() { 17 Column() { 18 Text('parent component') 19 // 每次访问 .value 时,会重新执行 getter 函数,拿到最新值 20 CustomButton( 21 UIUtils.makeBinding<number>( 22 () => this.number1 // GetterCallback 23 ) 24 ) 25 } 26 } 27} 28
2、可写绑定
如果需要双向绑定,可以传两个参数:getter 和 setter:
1@Builder 2function CustomButton(num2: MutableBinding<number>) { 3 Row() { 4 Button(`Custom Button: ${num2.value}`) 5 .onClick(() => { 6 // MutableBinding 支持修改 7 num2.value += 1; 8 }) 9 } 10} 11 12@Entry 13@ComponentV2 14struct CompV2 { 15 @Local number2: number = 10; 16 17 build() { 18 Column() { 19 Text('parent component') 20 CustomButton( 21 UIUtils.makeBinding<number>( 22 () => this.number2, // GetterCallback 23 (val: number) => { 24 this.number2 = val; // SetterCallback,修改时会自动调用 25 } 26 ) 27 ) 28 } 29 } 30} 31
注意,如果创建 MutableBinding 时没传 setter,修改 .value 会报运行时错误。所以需要双向绑定时,setter 是必须的。
七、addMonitor 和 clearMonitor:动态监听
在 V2 的状态管理中,你可以用 @Monitor 装饰器监听状态变化。但有时候你需要动态添加或删除监听,这时候可以用 addMonitor 和 clearMonitor。
1@ObservedV2 2class ObservedClass { 3 @Trace name: string = 'Tom'; 4 5 onChange(mon: IMonitor) { 6 mon.dirty.forEach((path: string) => { 7 console.info(`ObservedClass property ${path} change from ${mon.value(path)?.before} to ${mon.value(path)?.now}`); 8 }); 9 } 10 11 constructor() { 12 // 在构造方法里添加监听,isSynchronous: true 表示同步回调 13 UIUtils.addMonitor(this, 'name', this.onChange, { isSynchronous: true }); 14 } 15} 16 17@Entry 18@ComponentV2 19struct Index { 20 @Local observedClass: ObservedClass = new ObservedClass(); 21 22 build() { 23 Column() { 24 Text(`name: ${this.observedClass.name}`) 25 .fontSize(20) 26 .onClick(() => { 27 this.observedClass.name = 'Jack'; 28 this.observedClass.name = 'Jane'; 29 }) 30 } 31 } 32} 33
addMonitor 只支持 @ComponentV2 和 @ObservedV2 实例,如果传了不支持的类型会抛运行时错误。
删除监听用 clearMonitor:
1Button('clear monitor') 2 .onClick(() => { 3 // 删除指定路径的指定监听函数 4 UIUtils.clearMonitor(this.observedClass, 'age', this.observedClass.onChange); 5 6 // 如果不传 monitorCallback,会删除该路径的所有监听函数 7 // UIUtils.clearMonitor(this.observedClass, 'age'); 8 }) 9
八、同步刷新:applySync、flushUpdates、flushUIUpdates
这三个方法都是用来同步刷新状态变化的,但作用范围不同。
1、applySync:刷新闭包内的修改
applySync 接收一个闭包函数,只刷新闭包内的状态修改:
1Button('change size') 2 .onClick(() => { 3 // 闭包内的修改会同步执行,包括更新 @Computed、@Monitor 回调和重新渲染 UI 4 UIUtils.applySync(() => { 5 this.w = 100; 6 this.h = 100; 7 this.message = 'Hello World'; 8 }); 9 10 // 动画从刷新后的状态开始 11 this.getUIContext().animateTo({ 12 duration: 1000 13 }, () => { 14 this.w = 200; 15 this.h = 200; 16 this.message = 'Hello ArkUI'; 17 }); 18 }) 19
2、flushUpdates:刷新所有之前的修改
flushUpdates 会同步刷新调用之前所有的状态修改:
1Button('change size') 2 .onClick(() => { 3 this.w = 100; 4 this.h = 100; 5 this.message = 'Hello World'; 6 7 // 刷新之前所有的修改 8 UIUtils.flushUpdates(); 9 10 // 动画从刷新后的状态开始 11 this.getUIContext().animateTo({ 12 duration: 1000 13 }, () => { 14 this.w = 200; 15 this.h = 200; 16 this.message = 'Hello ArkUI'; 17 }); 18 }) 19
3、flushUIUpdates:只刷新 UI,不执行计算和回调
flushUIUpdates 只同步标脏 UI 节点,不会执行 @Computed 计算和 @Monitor 回调:
1Button('change size') 2 .onClick(() => { 3 this.w = 100; 4 this.h = 100; 5 this.message = 'Hello World'; 6 7 // 只刷新 UI,不执行计算和回调 8 UIUtils.flushUIUpdates(); 9 10 // 动画从刷新后的状态开始 11 this.getUIContext().animateTo({ 12 duration: 1000 13 }, () => { 14 this.w = 200; 15 this.h = 200; 16 this.message = 'Hello ArkUI'; 17 }); 18 }) 19
这三个方法在动画场景下比较有用。比如你想在动画开始前确保 UI 已经更新到某个状态,就可以用它们。
注意,这三个方法都不能在 @Computed 里调用,flushUpdates 和 flushUIUpdates 也不能在 @Monitor 回调里调用。
九、总结
UIUtils 提供的功能看着不多,但每个都挺实用的(非常实用了,属于是)。主要解决这些场景:
- 代理对象问题:用
getTarget拿到原始对象 - 普通数据变可观察:用
makeObserved或makeV1Observed - V1/V2 混用:用
enableV2Compatibility让 V1 状态在 V2 中可观察 - 数据绑定:用
makeBinding创建Binding或MutableBinding - 动态监听:用
addMonitor和clearMonitor管理监听 - 同步刷新:用
applySync、flushUpdates、flushUIUpdates控制刷新时机
遇到状态管理的边界问题时,确实能帮上忙。建议先了解这些功能的存在,需要的时候再查文档。用时不慌~~~
十、最后
如果您有任何疑问、对文章写的不满意、发现错误、想吐槽或者有更好的想法,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏
谢谢读者姥爷
《HarmonyOS一杯冰美式的时间 -- UIUtils基础功能》 是转载文章,点击查看原文。
