前言
好久没更新鸿蒙系列了,因为开发过程中踩的坑实在太多了
- “我这个组件怎么没更新?”
- “我要怎么把这个值传过去?”
- “哎呀,怎么点了没反应?”
- “天呐,怎么闪退了!”
作为鸿蒙开发新上手的同学,经常会在开发过程中遇到组件间状态管理的各种问题,这些问题无非就是数据没传递 、 UI没刷新 ,看似简单的两个问题,如果对鸿蒙复杂又简陋的状态管理不够了解的话是不好解决的。
本文基于鸿蒙V1稳定版状态管理
状态管理简述
由于很多同学可能刚接触声明式UI,我还是简述一下状态管理是啥。
组件状态
组件状态其实很好理解,就是我组件里有一个变量,这个变量的变更会影响到组件的渲染,,那么这个组件就是有状态的,一个永远不会变的组件是没有状态的。
- 一个固定text的TextView是无状态的
- 一个text会变更的TextView是有状态的
在鸿蒙里对应组件状态的是一个装饰器@State,用@State装饰的变量的变化会导致组件响应变化,它私有,它必须显式赋值,它可以响应对象自身的赋值变化。
状态管理
有了组件状态,就一定需要管理。管理其实无非以下几个方面:修改 、存储 、读取 。但是!如果涉及到主体和客体,这个问题可能就有些棘手了,谁来修改谁的状态,谁来读取谁的状态,又要把状态存储到哪里?这三个问题将很大程度上影响状态管理的逻辑。尤其是根据编程的范围最小原则,总不能所有东西都是全局的。这里就不卖关子了,大家看一下华为的文档也都明白。我直接梳理了下面这张图。
比起其他声明式UI的状态上提以及副作用控制,鸿蒙反而使用五花八门的装饰器来做状态管理。
所以当我们考虑到状态管理的时候,要考虑这几个方面
- 状态更新是否嵌套
- 状态管理的范围多大
- 状态管理是双向还是单向
很简单的三个问题,但嵌套是一个大坑,只能用ObjectLink 咯,但是很遗憾,ObjectLink是必须父子传递的,
我就是要在当前组件嵌套观察状态!那么我就陷入大坑咯。
案例分析
需求介绍
现在我们需要实现这样一个页面。
- View A组件有id:number, isSelected:boolean 两个属性,其中自身的click方法可以让isSelected取反
- View B是View A的父组件,使用List和ForEach来展示ViewAList,ViewB具备全选按钮,需要让ViewAList所有的isSelected变true
- View C在View B的底部,用于展示Select的数量
这其实是一个很常见的集合布局的最简单抽象,适用于购物、CMS等多种业务场景。
代码分析
现在我们分析一下每一个View的代码结构
class ClassA {
public id: number;
isSelected: boolean = false;
constructor() {
this.id = NextID++;
}
}
@Component
struct ViewA {
a: ClassA;
build() {
Row() {
Button(`ViewA [${this.a.id}] isSelect = ${this.a.isSelected}`)
.width(320)
.margin(10)
.onClick(() => {
this.a.isSelected = !this.a.isSelected
})
.backgroundColor(Color.Green)
}
}
}
@Entry
@Component
struct ViewB {
arrA: ClassA[] = [new ClassA(), new ClassA()];
build() {
Column() {
List({ space: 0, initialIndex: 0 }) {
ForEach(this.arrA,
(item: ClassA) => {
ListItem() {
ViewA({ a: item })
}
},
(item: ClassA): string => item.id.toString()
)
}
Button(`View B 全选`)
.width(320)
.margin(10)
.onClick(() => {
this.arrA.forEach(item => {
item.isSelected = true
})
}).backgroundColor(Color.Pink)
ViewC({ count: this.arrA.filter(item => item.isSelected === true).length })
}
}
}
@Component
struct ViewC {
count: number = -1;
label: string = 'ViewA1';
build() {
Row() {
Text(`ViewC isSelected count = ${this.count}`)
.width(320)
.margin(10)
}
}
}
我现在把三块代码都粘出来了,现在这三块代码没有用上状态的装饰器。我们要做的就是把装饰器装上,可以自己先按照自己的理解装上看看能否满足功能。
装饰器选择和代码改造
首先,我们需要自底向上分析。
View A中的属性a是一定需要加上状态a的,因为其中的isSelectA是变化的,那么首先想到@State
@State a: ClassA = new ClassA()
现在可以发现,全选和单选功能都OK了,但是View C的count怎么没变呢?
然后仔细看一眼传递count的代码,用了arrA,是不是没给arrA加上状态呢?那就加个@State吧,然后就会发现还是不行。
因为@State无法观察嵌套和数组内变化arrA中item的isSelected的变化不会通知arrA变化!
为了佐证上面的论点,可以给arrA增加个Watch方法。同时再增加一个按钮
@State @Watch('onChange')arrA: ClassA[] = [new ClassA(), new ClassA()];
onChange() {
console.log('viewB', 'arr change');
}
...
Button(`View B Test`)
.width(320)
.margin(10)
.onClick(() => {
this.arrA.length = 1
}).backgroundColor(Color.Pink)
再运行以后测试就会发现全选按钮是不会触发onChange,而Test按钮却可以,因为Test是直接操作了数组的变化,这是可以观察到的。所以再次证明了arrA中item的isSelected的变化不会通知arrA变化!
那么问题来了,咋办呢,看一下上面的表格,嵌套那用ObjectLink吧,不好意思,ObjectLink只能用于父子传递,这里作为父组件,用不了ObjectLink。那能不能像flutter一样,强刷一下setState或者Android自定义View的invalidate ,不好意思,也没有这样的方法。
穷途末路之下,我的选择是自己抽出来需要观察的对象。在这里就是@State selectedCount: number = 0 ,然后还需要给View A加上@Link count: number 并在View B里传递过去,再给click方法都作出改变,以及View C增加@Prop装饰器
ViewA({ a: item, count: this.selectedCount })
this.count = this.count + (this.a.isSelected ? 1 : -1)
this.arrA.forEach(item => {
item.isSelected = true
this.selectedCount = this.arrA.length
})
ViewC({ count: this.selectedCount})
@Prop count: number
上面是改动的代码,为了增加一个count,就加了这么多代码,还增加了变量,真是难受啊,如果同学们有更好的方法可以指导一下,目前我实践下来这可能是问题最少的方案。
问题延伸
上面的场景只是复杂业务场景的最小程度的抽象。实际上还可能会有更多问题,例如
- arrA: ClassA[] 可能存在刷新、增删等操作,需要更改Foreach的token才能响应变化,可以考虑给item增加时间戳
- arrA在复杂业务中可能存在更多层级包裹,必须手写adapt方法尽可能抽到只剩一层
- forEach里也许是更复杂的component,再加上如果需要使用tab的预加载,那么必须将contentList内置到组件,而外组件和其他组件也需要用到这个contentList的话,状态问题就会变得非常复杂
其他框架实现嵌套
以我较为熟悉的Compose和Flutter举例,
class ClassAListNotifier with ChangeNotifier {
List<ClassA> _arrA = [
ClassA(id: 1),
ClassA(id: 2),
// ...更多初始数据
];
List<ClassA> get arrA => _arrA;
void toggleSelection(int id) {
final item = _arrA.firstWhere((element) => element.id == id);
if (item != null) {
item.isSelect = !item.isSelect;
notifyListeners();
}
}
}
var listState by remember { mutableStateOf(arrA) } // 将列表转换为可观察状态
// 更新单个元素的isSelect,并触发UI刷新
fun updateSelection(id: Int, isSelected: Boolean) {
val index = listState.indexOfFirst { it.id == id }
if (index != -1) {
val updatedItem = listState[index].copy(isSelect = isSelected)
listState = listState.toMutableList().apply { this[index] = updatedItem }
}
}
Flutter使用Provider库,继承ChangeNotifier后notifyListeners可以很好的手动提示更新
Compose的话,本身可以使用mutableList,而mutableState是可以嵌套包裹的,再加上强大的flow,compose开发状态管理还是轻松的。
这两者虽然也都有自己的问题,但可以看出来设计者明确的状态管理意图以及操作的简化,以前我总觉得Flutter的状态管理不是很好用,现在看来是我以前声音太大了。
总结
在鸿蒙上想用好状态管理由以下三个方面
- 数据结构能剥多开就剥多开,没有嵌套变化的情况下问题都好解决,官方说法叫精细化拆分复杂状态
- 想清楚变化源的流向,选择合适的装饰器
- 集中化状态修改逻辑 将逻辑尽可能放在父组件完成,比如子组件的修改方法可以直接由函数参数从父组件传递
对应存在的问题
- 复杂的数据结构的拆分再加上本身棘手的各种NPE和undefined,导致数据结构处理异常复杂
- 复杂的装饰器选择
- 代码逻辑复杂且松散,再怎么集中化也还是改变不了
目前鸿蒙的状态管理还在不断升级,但是V2版本仍然在对装饰器推陈出新,更容易到处修改逻辑以及增加代码的复杂度。