HarmonyOS:状态管理最佳实践
一、概述
在声明式UI编程范式中,UI是应用程序状态的函数,应用程序状态的修改会更新相应的UI界面。ArkUI采用了MVVM模式,其中ViewModel将数据与视图绑定在一起,更新数据的时候直接更新视图。如下图所示:
ArkUI的MVVM模式图解
ArkUI提供了一系列装饰器实现ViewModel的能力,如@Prop、@Link、@Provide、LocalStorage等。当自定义组件内变量被装饰器装饰时变为状态变量,状态变量的改变会引起UI的渲染刷新。
在ArkUI的开发过程中,如果没有选择合适的装饰器或合理的控制状态更新范围,可能会导致以下问题:
- 状态和UI的不一致,如同一状态的界面元素展示的UI不同,或UI界面展示的不是最新的状态。
- 非必要的UI视图刷新,如只修改局部组件状态时导致组件所在页面的整体刷新。
当用户与界面产生交互行为时,状态的修改是通过事件驱动处理的。事件的处理可以在应用的任何地方,如果没有进行适当的逻辑处理管理也会导致代码冗余和不利于维护。
本文旨在从装饰器的选择、使用以及状态的逻辑处理管理方面解决以上问题,以实现更好的状态管理。
二、合理选择装饰器
2.1 避免不必要的状态变量的使用
删除冗余的状态变量标记
状态变量的管理有一定的开销,应在合理场景使用,普通的变量用状态变量标记可能会导致性能劣化。
反例1
@Observed
class Translate {
translateX: number = 20;
}
@Entry
@Component
struct MyComponent {
@State translateObj: Translate = new Translate(); // 变量translateObj没有关联任何UI组件,不应该定义为状态变量
@State buttonMsg: string = 'I am button'; // 变量buttonMsg没有关联任何UI组件,不应该定义为状态变量
build() {
}
}
以上示例中变量translateObj,buttonMsg没有关联任何UI组件,没有关联任何UI组件的状态变量不应该定义为状态变量,否则读写状态变量都会影响性能。
反例2
@Observed
class Translate {
translateX: number = 20;
}
@Entry
@Component
struct MyComponent {
@State translateObj: Translate = new Translate();
@State buttonMsg: string = 'I am button';
build() {
Column() {
Button(this.buttonMsg) // 这里只是读取变量buttonMsg的值,没有任何写的操作
}
}
}
以上示例中变量buttonMsg仅有读取操作,没有修改过,没有修改过的状态变量不应该定义为状态变量,否则读状态变量会影响性能。
正例
@Observed
class Translate7 {
translateX: number = 20;
}
@Entry
@Component
struct UnnecessaryState1 {
@State translateObj: Translate = new Translate7(); // 同时存在读写操作,并关联了Button组件,推荐使用状态变量
buttonMsg = 'I am button'; // 仅读取变量buttonMsg的值,没有任何写的操作,直接使用一般变量即可
build() {
Column() {
Button(this.buttonMsg)
.onClick(() => {
animateTo({
duration: 50
}, () => {
this.translateObj.translateX = (this.translateObj.translateX + 50) % 150; // 点击时给变量translateObj重新赋值
})
console.log(`执行了水平偏移位置动画, this.translateObj.translateX = ${this.translateObj.translateX}`)
})
}
.translate({
x: this.translateObj.translateX // 读取translateObj中的值
})
}
}
效果图
没有关联任何UI组件的状态变量和没有修改过的状态变量不应该定义为状态变量,直接使用一般变量即可,否则会影响性能。
建议使用临时变量替换状态变量
状态变量发生变化时,ArkUI会查询依赖该状态变量的组件并执行依赖该状态变量的组件的更新方法,完成组件渲染的行为。通过使用临时变量的计算代替直接操作状态变量,可以使ArkUI仅在最后一次状态变量变更时查询并渲染组件,减少不必要的行为,从而提高应用性能。状态变量行为可参考@State装饰器:组件内状态。
反例
@Entry
@Component
struct Index {
@State message: string = '';
appendMsg(newMsg: string) {
this.message += newMsg;
this.message += ';';
this.message += '<br/>';
}
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 })
}
}
正例
@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('操作临时变量');
console.log("this.message:", this.message)
})
.width('90%')
.backgroundColor(Color.Blue)
.fontColor(Color.White)
.margin({ top: 10 })
}
.justifyContent(FlexAlign.Start)
.alignItems(HorizontalAlign.Center)
.margin({ top: 15 })
}
}
使用临时变量替换状态变量效果图
三、最小化状态共享范围
在没有强烈的业务需求下,尽可能按照状态需要共享的最小范围选择合适的装饰器。应用开发过程中,按照组件颗粒度,状态一般分为组件内独享的状态和组件间需要共享的状态。
3.1 组件内独享的状态
组件内独享的状态的生命周期和组件同步,状态的定义和更新都在组件内,组件销毁,状态也随即消失。常见于界面UI元素数据,比如当前按钮是否可用、文字是否高亮等。组件内独享的状态使用@State装饰器,被@State装饰器修饰后状态的修改只会触发当前组件实例的重新渲染。如下图主题列表上单个主题组件内使用@State修饰主题是否被选中的变量,当在界面点击主题时在组件内直接修改状态值。此时,只有当前主题的组件实例会重新渲染,其他主题组件不会重新渲染。
HMOS世界App主题选择交互图
3.2 组件间需要共享的状态
组件间需要共享的状态,按照共享范围从小到大依次有三种场景:父子组件间共享状态,不同子树上组件间共享状态和不同组件树间共享状态。
父子组件间共享状态:如下图,”父组件”和其子组件”子组件A”、”子组件B”共享状态loading。
父子组件间共享状态场景
不同子树上组件间共享状态:如下图,祖先组件的左子树上”孙子组件AAA”和右子树上”孙子组件BAA”共享状态loading。
不同子树上组件间状态共享场景
不同组件树间共享状态:如下图,组件树A内”子组件AA”和组件树B内”孙子组件AAA”共享状态loading。
不同组件树间共享状态的场景
对于上述三种场景,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被回收。
按照软件开发原则,应优先选择共享范围能力小的装饰器方案,减少不同模块间的数据耦合,便于状态及时回收。建议选择装饰器的优先级为:@State+@Prop、@State+@Link、@State+@Observed+@ObjectLink > @Provide+@Consume > LocalStorage > AppStorage。
四、使用监听和订阅精准控制组件刷新
多个组件依赖对象中的不同属性时,直接关联该对象会出现改变任一属性所有组件都刷新的现象,可以通过将类中的属性拆分组合成新类的方式精准控制组件刷新。
在多个组件依赖同一个数据源并根据数据源变化刷新组件的情况下,直接关联数据源会导致每次数据源改变都刷新所有组件。为精准控制组件刷新,可以采取以下策略。
4.1 使用 @Watch 装饰器监听数据源
在组件中使用@Watch装饰器监听数据源,当数据变化时执行业务逻辑,确保只有满足条件的组件进行刷新。
反例
在下面的示例代码中,多个组件直接关联同一个数据源,但是未使用@Watch装饰器和Emitter事件驱动更新,导致了冗余的组件刷新。
@Entry
@Component
struct NoUseWatchListener {
@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 })
}
})
}
.alignListItem(ListItemAlign.Center)
}
.width('100%')
}
.height('100%')
}
}
@Component
struct ListItemComponent {
@Prop item: string;
@Prop index: number; // 列表项的下标
@Link currentIndex: number;
private sizeFont: number = 50;
isRender(): number {
console.info(`ListItemComponent ${this.index} Text is rendered`);
return this.sizeFont;
}
build() {
Column() {
Text(this.item)
.fontSize(this.isRender())// 根据当前列表项下标index与currentIndex的差值来动态设置文本的颜色
.fontColor(Math.abs(this.index - this.currentIndex) <= 1 ? Color.Red : Color.Blue)
.onClick(() => {
this.currentIndex = this.index;
})
}
}
}
上述示例中,每个ListItemComponent组件点击Text后会将当前点击的列表项下标index赋值给currentIndex,@Link装饰的状态变量currentIndex变化后,父组件Index和所有ListItemComponent组件中的Index值都会同步发生改变。然后,在所有ListItemComponent组件中,根据列表项下标index与currentIndex的差值的绝对值是否小于等于1来决定Text的颜色,如果满足条件,则文本显示为红色,否则显示为蓝色,下面是运行效果图。
可以看到每次点击后即使其中部分Text组件的颜色并没有发生改变,所有的Text组件也都会刷新。这是由于ListItemComponent组件中的Text组件直接关联了currentIndex,而不是根据currentIndex计算得到的颜色。
针对上述父子组件层级关系的场景,推荐使用状态装饰器@Watch监听数据源。当数据源改变时,在@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;
})
}
}
}
上述代码中,ListItemComponent组件中的状态变量currentIndex使用@Watch装饰,Text组件直接关联新的状态变量color。当currentIndex发生变化时,会触发onCurrentIndexUpdate方法,在其中将表达式的运算结果赋值给状态变量color。只有color的值发生变化时,Text组件才会重新渲染,运行效果图如下:
被依赖的数据源仅在父子或兄弟关系的组件中传递时,可以参考上述示例,使用@State/@Link/@Watch装饰器进行状态管理,实现组件的精准刷新。
当组件关系层级较多但都归属于同一个确定的组件树时,推荐使用@Provide/@Consume传递数据,使用@Watch装饰器监听数据变化,在监听回调中执行业务逻辑。
4.2 使用自定义事件发布订阅
当组件关系复杂或跨越层级过多时,推荐使用EventHub或者Emitter自定义事件发布订阅的方式。当数据源改变时发布事件,依赖该数据源的组件通过订阅事件来获取数据源的改变,完成业务逻辑的处理,从而实现组件的精准刷新。
下面通过部分示例代码介绍使用方式,ButtonComponent组件作为交互组件触发数据变更,ListItemComponent组件接收数据做相应的UI刷新。
UseEmitterPublish.ets代码
import { ButtonComponent22 } from '../components/ButtonComponent';
import { ListItemComponent22 } from '../components/ListItemComponent';
@Entry
@Component
struct UseEmitterPublish {
listData: string[] = ['A', 'B', 'C', 'D', 'E', 'F'];
build() {
Column() {
Row() {
Column() {
ButtonComponent22()
}
}
Column() {
Column() {
List() {
ForEach(this.listData, (item: string, index: number) => {
ListItemComponent22({ myItem: item, index: index })
})
}
.height('100%')
.width('100%')
.alignListItem(ListItemAlign.Center)
}
}
}
}
}
由于ButtonComponent组件和ListItemComponent组件的组件关系较为复杂,因此在ButtonComponent组件中的Button回调中,可以使用emitter.emit发送事件,在ListItemComponent组件中订阅事件。在事件触发的回调中接收数据value,通过业务逻辑决定是否修改状态变量color,从而实现精准控制ListItemComponent组件中Text的刷新。
ButtonComponent22.ets代码
@Component
export struct ButtonComponent22 {
value: number = 2;
build() {
Button(`下标是${this.value}的倍数的组件文字变为红色`)
.onClick(() => {
let event: emitter.InnerEvent = {
eventId: 1,
priority: emitter.EventPriority.LOW
};
let eventData: emitter.EventData = {
data: {
value: this.value
}
};
// 发送eventId为1的事件,事件内容为eventData
emitter.emit(event, eventData);
this.value++;
console.log(`执行了 下标是${this.value}的倍数的组件文字变为红色`)
})
}
}
ListItemComponent22.ets代码
@Component
export struct ListItemComponent22 {
@State color: Color = Color.Black;
@Prop index: number;
@Prop myItem: string;
aboutToAppear(): void {
let event: emitter.InnerEvent = {
eventId: 1
};
// 收到eventId为1的事件后执行该回调
let callback = (eventData: emitter.EventData): void => {
if (eventData.data?.value !== 0 && this.index % eventData.data?.value === 0) {
this.color = Color.Red;
console.log(`收到eventId为1的事件后执行该回调 ${this.index} 组件文字变为红色`)
}
};
// 订阅eventId为1的事件
emitter.on(event, callback);
}
build() {
Column() {
Text(this.myItem)
.fontSize(50)
.fontColor(this.color)
}
}
}
效果图
五、总结
状态管理是MVVM模式中十分复杂的问题,为解决其中状态和视图一致性、渲染性能体验、代码可复用性和可维护性四个问题,本文主要有以下建议点:
- 在选择装饰器时,应理解各个装饰器的特性和共享范围,结合实际开发场景的优先级,合理选择装饰器,以确保状态和视图的一致性。
- 在使用装饰器时,对装饰器修饰的复杂变量应进行合理拆分设计,以此减少非必要的组件渲染次数,获得更好的性能体验。
- 在代码开发过程中,对相似的逻辑处理,应考虑其复用性合理集中处理,以此有效提升代码的可维护性和可复用性。