一、概述
在声明式UI编程范式中,UI是应用程序状态的函数,应用程序状态的修改会更新相应的UI界面。ArkUI采用了MVVM模式,其中ViewModel将数据与视图绑定在一起,更新数据的时候直接更新视图。如下图所示:
ArkUI提供了一系列装饰器实现ViewModel的能力,如@Prop、@Link、@Provide、LocalStorage等。当自定义组件内变量被装饰器装饰时变为状态变量,状态变量的改变会引起UI的渲染刷新。
在ArkUI的开发过程中,如果没有选择合适的装饰器或合理的控制状态更新范围,可能会导致以下问题:
1. 状态和UI的不一致,如同一状态的界面元素展示的UI不同,或UI界面展示的不是最新的状态。
2. 非必要的UI视图刷新,如只修改局部组件状态时导致组件所在页面的整体刷新。
当用户与界面产生交互行为时,状态的修改是通过事件驱动处理的。事件的处理可以在应用的任何地方,如果没有进行适当的逻辑处理管理也会导致代码冗余和不利于维护。
二、合理选择装饰器
1、避免不必要的状态变量的使用
a、删除冗余的状态变量标记
状态变量的管理有一定的开销,应在合理场景使用,普通的变量用状态变量标记可能会导致性能劣化。
@Observed
class Translate {
translateX: number = 20;
}
@Entry
@Component
struct UnnecessaryState1 {
@State translateObj: Translate = new Translate(); // 同时存在读写操作,并关联了Button组件,推荐使用状态变量
buttonMsg = 'I am button'; // 仅读取变量buttonMsg的值,没有任何写的操作,直接使用一般变量即可
build() {
Column() {
Button(this.buttonMsg)
.onClick(() => {
animateTo({
duration: 50
}, () => {
this.translateObj.translateX = (this.translateObj.translateX + 50) % 150; // 点击时给变量translateObj重新赋值
})
})
}
.translate({
x: this.translateObj.translateX // 读取translateObj中的值
})
}
}
b、建议使用临时变量替换状态变量
状态变量发生变化时,ArkUI会查询依赖该状态变量的组件并执行依赖该状态变量的组件的更新方法,完成组件渲染的行为。通过使用临时变量的计算代替直接操作状态变量,可以使ArkUI仅在最后一次状态变量变更时查询并渲染组件,减少不必要的行为,从而提高应用性能。
@Entry
@Component
struct UnnecessaryState2 {
@State message: string = '';
appendMsg(newMsg: string) {
let message = this.message;
message += newMsg;
message += ';';
message += '<br/>';
this.message = message;
}
build() {
Column() {
Button('点击打印日志')
.onClick(() => {
this.appendMsg('操作临时变量');
})
.width('90%')
.backgroundColor(Color.Blue)
.fontColor(Color.White)
.margin({ top: 10 })
}
.justifyContent(FlexAlign.Start)
.alignItems(HorizontalAlign.Center)
.margin({ top: 15 })
}
}
2、最小化状态共享范围
在没有强烈的业务需求下,尽可能按照状态需要共享的最小范围选择合适的装饰器。应用开发过程中,按照组件颗粒度,状态一般分为组件内独享的状态和组件间需要共享的状态。
a、组件内独享的状态
组件内独享的状态的生命周期和组件同步,状态的定义和更新都在组件内,组件销毁,状态也随即消失。常见于界面UI元素数据,比如当前按钮是否可用、文字是否高亮等。组件内独享的状态使用@State装饰器,被@State装饰器修饰后状态的修改只会触发当前组件实例的重新渲染。
b、组件间需要共享的状态
组件间需要共享的状态,按照共享范围从小到大依次有三种场景:父子组件间共享状态,不同子树上组件间共享状态和不同组件树间共享状态。
对于上述三种场景,ArkUI提供了@State+@Prop、@State+@Link、@State+@Observed+@ObjectLink、@Provide+@Consume、AppStorage、LocalStorage六种装饰器组合以解决不同范围内的组件间状态共享。按照共享范围能力从小到大,各装饰器组合的共享范围能力和生命周期如下:
- @State+@Prop、@State+@Link、@State+@Observed+@ObjectLink:三者的共享范围为从@State所在的组件开始,到@Prop/@Link/ObjectLink所在组件的整条路径,路径上所有的中间组件通过@Prop/@Link/@ObjectLink都可以共享同一个状态。@State修饰的状态和其所属的自定义组件共享生命周期,在组件内定义时创建,组件销毁时被回收。@Link装饰的变量和其所属的自定义组件共享生命周期。@ObjectLink装饰的变量和其所属的自定义组件共享生命周期。
- @Provide+@Consume:状态共享范围是以@Provide所在组件为祖先节点的整棵子树,子树上的任意后代组件通过@Consume都可以共享同一个状态。@Provide修饰的变量与其所属的组件绑定,在组件内定义时被创建,在组件销毁时被回收。
- LocalStorage:共享范围为UIAbility内以页面为单位的不同组件树间的共享。存储在LocalStorage中的状态的生命周期与LocalStorage绑定。LocalStorage的声明周期由应用程序决定,当应用释放最后一个指向LocalStorage的引用时,LocalStorage被垃圾回收。
- AppStorage:共享范围是应用全局。AppStorage与应用的进程绑定,由UI框架在应用程序启动时创建,当应用进程终止,AppStorage被回收。存储在AppStorage中的状态的生命周期与LocalStorage绑定。
按照软件开发原则,应优先选择共享范围能力小的装饰器方案,减少不同模块间的数据耦合,便于状态及时回收。建议选择装饰器的优先级为:@State+@Prop、@State+@Link、@State+@Observed+@ObjectLink > @Provide+@Consume > LocalStorage > AppStorage。
3、减少不必要的参数层层传递
当按照上述优先级选择装饰器时,由于@State+@Prop、@State+@Link、@State+@Observed+@ObjectLink三种方案的实现方式是逐级向下传递状态,当共享状态的组件间层级相差较大时,会出现状态层层传递的现象。对于状态传递过程中途经的全部组件,都需要增加入参接收该状态再将状态传递给子组件。对于没有使用该状态的中间组件而言,这是“额外的消耗”,不利于代码的维护和拓展。尤其是当业务体系庞大时,需求变更容易出现“牵一发而动全身”的问题。
4、按照状态复杂度选择装饰器
@State+@Prop组合方案:
- @Prop装饰器支持接收Object、class、string、number、boolean、enum类型,以及这些类型的数组。
- @Prop装饰的变量是对父组件传入状态值的深拷贝,当@Prop装饰器装饰的变量为复杂Object、class或其类型数组时,会增加状态创建时间以及占用大量内存。
- @Prop装饰的变量和父组件是单向绑定的关系。当父组件数据源发生变化时,接收该数据源的@Prop所在组件的实例会重新渲染。 当该组件内被@Prop装饰的变量被修改时,父组件数据源不会变化,父组件实例也不会重新渲染。
@State+@Link组合方案:
- @Link装饰器支持接收Object、class、string、number、boolean、enum类型,以及这些类型的数组。
- @Link装饰器修饰的变量是对父组件传入状态的引用的拷贝,两者指向同一个地址。当状态是简单数据类型或简单Object类型时,@Link和@Prop在状态创建时间和内存的占用方面区别不大。当状态为复杂的Object、class或其类型数组时,@Link相较@Prop能明显减少状态创建时间和内存的占用。
- @Link装饰器的变量和父组件是双向绑定的关系。当父组件数据源发生变化时,接收该数据源的@Link所在组件的实例会重新渲染。 当该组件内被@Link装饰的变量被修改时,父组件数据源会同步修改,父组件实例也会重新渲染。
@State+@Observed+@ObjectLink组合方案:
- @ObjectLink只支持接收被@Observed装饰的class实例及继承Date或者Array的class实例。
- @ObjectLink装饰的变量是只读的,不支持对状态重新赋值。
- @ObjectLink必须配合@Observed使用,它的设计是为了解决对嵌套类对象属性变化的监听,如需要观察对象数组中单个数据项的属性值变化,或嵌套对象的对象类型属性的子属性变化。
结合三个方案的特性,在选择时有如下建议:
- 需要观察嵌套类对象的深层属性变化的场景,选择@State+@Observed+@ObjectLink。
- 状态是复杂对象、类或其类型数组的场景,选择@State+@Link。
- 状态是简单数据类型时,使用@State+@Link和@State+@Prop均可。在功能层面上,依据@Prop单向绑定的特性,@State+@Prop适合用于非实时修改的场景,如编辑电话薄联系人信息时,展示编辑界面的子组件信息的修改要求不实时同步回父组件,需要等到编辑完成后点击“确认”按钮时才会以事件驱动的方式修改父组件的状态。依据@Link双向绑定的特性,@State+@Link适合用于实时修改的场景,如组件嵌套时的滚动条同步。
三、精细化拆分复杂状态
对于AppStorage的使用,由于其作用范围最广,开发者为了方便开发容易将各种状态存入其中以达到共享的目的,这通常会造成大量的性能损失。
这是因为,在ArkUI中状态的修改刷新是粗颗粒的。使用装饰器修饰对象类型状态时,ArkUI能监听到对象本身值的变化以及对象的属性值的变化。当对象属性值发生变化后,ArkUI会以整个对象颗粒度通知所有使用了该状态的组件重新渲染,而不是按属性颗粒度大小通知使用了该变化属性的组件重新渲染。
五、集中化状态修改逻辑
在使用@Link装饰器时,开发者可以直接在@Link装饰器接收状态的组件内部修改状态。当多个子组件修改状态的逻辑基本相同时,建议将状态的修改集中到单个函数中,以提升逻辑的可复用性、代码的可维护性和可测试性。
六、使用监听和订阅精准控制组件刷新
多个组件依赖对象中的不同属性时,直接关联该对象会出现改变任一属性所有组件都刷新的现象,可以通过将类中的属性拆分组合成新类的方式精准控制组件刷新。
在多个组件依赖同一个数据源并根据数据源变化刷新组件的情况下,直接关联数据源会导致每次数据源改变都刷新所有组件。为精准控制组件刷新,可以采取以下策略。
1、使用 @Watch 装饰器监听数据源
在组件中使用@Watch装饰器监听数据源,当数据变化时执行业务逻辑,确保只有满足条件的组件进行刷新。
@Entry
@Component
struct UseWatchListener {
@State currentIndex: number = 0; // 当前选中的列表项下标
private listData: string[] = [];
aboutToAppear(): void {
for (let i = 0; i < 10; i++) {
this.listData.push(`组件 ${i}`);
}
}
build() {
Row() {
Column() {
List() {
ForEach(this.listData, (item: string, index: number) => {
ListItem() {
ListItemComponent({ item: item, index: index, currentIndex: this.currentIndex })
}
})
}
.height('100%')
.width('100%')
.alignListItem(ListItemAlign.Center)
}
.width('100%')
}
.height('100%')
}
}
@Component
struct ListItemComponent {
@Prop item: string;
@Prop index: number; // 列表项的下标
@Link @Watch('onCurrentIndexUpdate') currentIndex: number;
@State color: Color = Math.abs(this.index - this.currentIndex) <= 1 ? Color.Red : Color.Blue;
isRender(): number {
console.info(`ListItemComponent ${this.index} Text is rendered`);
return 50;
}
onCurrentIndexUpdate() {
// 根据当前列表项下标index与currentIndex的差值来动态修改color的值
this.color = Math.abs(this.index - this.currentIndex) <= 1 ? Color.Red : Color.Blue;
}
build() {
Column() {
Text(this.item)
.fontSize(this.isRender())
.fontColor(this.color)
.onClick(() => {
this.currentIndex = this.index;
})
}
}
}
被依赖的数据源仅在父子或兄弟关系的组件中传递时,可以参考上述示例,使用@State/@Link/@Watch装饰器进行状态管理,实现组件的精准刷新。
当组件关系层级较多但都归属于同一个确定的组件树时,推荐使用@Provide/@Consume传递数据,使用@Watch装饰器监听数据变化,在监听回调中执行业务逻辑。
2、使用自定义事件发布订阅
当组件关系复杂或跨越层级过多时,推荐使用EventHub或者Emitter自定义事件发布订阅的方式。当数据源改变时发布事件,依赖该数据源的组件通过订阅事件来获取数据源的改变,完成业务逻辑的处理,从而实现组件的精准刷新。
import { ButtonComponent } from '../components/ButtonComponent';
import { ListItemComponent } from '../components/ListItemComponent';
@Entry
@Component
struct UseEmitterPublish {
listData: string[] = ['A', 'B', 'C', 'D', 'E', 'F'];
build() {
Column() {
Row() {
Column() {
ButtonComponent()
}
}
Column() {
Column() {
List() {
ForEach(this.listData, (item: string, index: number) => {
ListItemComponent({ myItem: item, index: index })
})
}
.height('100%')
.width('100%')
.alignListItem(ListItemAlign.Center)
}
}
}
}
}
七、总结
状态管理是MVVM模式中十分复杂的问题,为解决其中状态和视图一致性、渲染性能体验、代码可复用性和可维护性四个问题,本文主要有以下建议点:
- 在选择装饰器时,应理解各个装饰器的特性和共享范围,结合实际开发场景的优先级,合理选择装饰器,以确保状态和视图的一致性。
- 在使用装饰器时,对装饰器修饰的复杂变量应进行合理拆分设计,以此减少非必要的组件渲染次数,获得更好的性能体验。
- 在代码开发过程中,对相似的逻辑处理,应考虑其复用性合理集中处理,以此有效提升代码的可维护性和可复用性。