【OpenHarmony/HarmonyOs 】24|ArkUI 数组不可变更新:为什么 `@State` 列表改了,界面却可能不刷新
【OpenHarmony/HarmonyOs 】24|ArkUI 数组不可变更新:为什么 @State 列表改了,界面却可能不刷新?🧠
本文基于“灵犀互聊”项目
MoodPage.ets中标签选择、Cloud DB 动态列表、趋势图和聊天消息等真实场景展开,解释 ArkUI 数组状态为什么需要明确提交新引用,并给出新增、删除、替换、排序、异步回写和性能优化的完整解决方案。
一、项目里的真实场景:发布心情时选择标签
心情发布区支持选择多个活动标签。
页面使用 @State 保存选中结果:
@State selectedTags: string[] = [];
标签列表来自国际化资源:
List({ space: 8 }) {
ForEach(getStr(this.currentLang).activities,
(activity: string, index: number) => {
ListItem() {
Row() {
Text(activity)
.fontWeight(
this.selectedTags.indexOf(activity) >= 0
? FontWeight.Medium
: FontWeight.Normal
)
.fontColor(
this.selectedTags.indexOf(activity) >= 0
? this.primaryColor
: this.subTextColor
)
}
.backgroundColor(
this.selectedTags.indexOf(activity) >= 0
? this.lightPrimary
: '#F5F5F5'
)
.onClick(() => {
this.toggleTag(activity);
})
}
},
(activity: string): string => activity
)
}
每次点击都要在“选中”和“取消选中”之间切换。
一个稳定的更新方式是:
private toggleTag(tag: string): void {
const index = this.selectedTags.indexOf(tag);
if (index >= 0) {
const nextTags: string[] = this.selectedTags.slice();
nextTags.splice(index, 1);
this.selectedTags = nextTags;
return;
}
const nextTags: string[] = this.selectedTags.slice();
nextTags.push(tag);
this.selectedTags = nextTags;
}
它做了三件事:
复制旧数组
↓
修改副本
↓
把新数组重新赋给 @State
这就是数组不可变更新的核心。
二、什么叫“不可变更新”?
不可变更新并不是说数组永远不能变化。
它真正强调的是:
不直接修改当前状态对象,而是基于旧值构造一个新值,再把新值提交给状态变量。
下面是原地修改:
this.selectedTags.push(tag);
数组内容变了,但 this.selectedTags 仍指向原来的数组。
下面是不可变更新:
const nextTags: string[] = this.selectedTags.slice();
nextTags.push(tag);
this.selectedTags = nextTags;
这次 this.selectedTags 接收了一个新数组引用。
对声明式 UI 来说,第二种方式的状态提交边界更清楚。
三、为什么 push() 以后 UI 可能不符合预期?
普通 JavaScript 只关心数组内容是否正确。
声明式 UI 还需要知道:什么时候应该重新构建依赖这个状态的界面。
@State 建立的是状态与 UI 的响应式关系。
当执行:
this.selectedTags = nextTags;
框架可以明确观察到一次状态赋值。
而只执行:
this.selectedTags.push(tag);
属于在原引用内部修改内容。
不同装饰器、对象层级和观察能力下,深层变更的通知行为可能不如整体赋值直观稳定。
因此,工程实践不应依赖“这个版本刚好能观察到”。
更可靠的规则是:
数组内容变化
↓
创建新数组
↓
重新赋值状态
↓
ArkUI 获得明确更新信号
四、危险的不是 splice(),而是对谁执行 splice()
很多文章会直接说“不要使用 splice()”。
这个结论并不准确。
下面的写法有风险:
this.selectedTags.splice(index, 1);
因为它直接修改了状态数组。
但下面的写法完全符合不可变更新思路:
const nextTags: string[] = this.selectedTags.slice();
nextTags.splice(index, 1);
this.selectedTags = nextTags;
splice() 操作的是副本。
旧状态没有被原地修改。
最后又通过赋值提交了新引用。
所以判断一个操作是否安全,不要只看 API 名称。
要看它是否直接作用于当前状态。
五、ArkTS 中为什么可以优先使用 slice() 复制?
在普通 TypeScript 中,很多人喜欢写:
const next = [...this.selectedTags];
但 HarmonyOS NEXT 的 ArkTS 对语法和类型推断更严格。
项目规则也强调应谨慎使用展开操作符,尤其是复杂对象和不明确类型场景。
对于数组复制,以下写法更直观:
const nextTags: string[] = this.selectedTags.slice();
它有几个优点:
- 返回值明确是数组;
- 不依赖对象展开能力;
- 在严格类型环境中更容易阅读;
- 便于复制后再执行
push()、splice()或sort()。
如果项目已验证数组展开语法可用,也可以采用展开方式。
但团队应统一风格,不要在同一页面混用多种策略。
六、标签切换的完整状态变化
假设初始状态是:
[]
点击“运动”后:
旧状态 []
新状态 [运动]
点击“阅读”后:
旧状态 [运动]
新状态 [运动, 阅读]
再次点击“运动”后:
旧状态 [运动, 阅读]
新状态 [阅读]
每一步都生成新数组。
旧数组在当前更新过程中保持不变。
这会带来三个直接收益:
- UI 刷新边界明确;
- 调试时能比较更新前后状态;
- 异步操作不容易意外共享同一个可变数组。
七、UI 必须直接读取 @State,否则新数组也救不了刷新
不可变更新只是状态管理的一半。
另一半是 UI 必须建立对状态的直接依赖。
项目标签样式直接读取:
this.selectedTags.indexOf(activity) >= 0
因此重新赋值 selectedTags 后,相关样式能随状态重建。
一种容易出问题的写法是提前计算字符串,再通过普通参数传入不具备响应式关系的构建路径:
const summary = this.selectedTags.join('、');
this.buildSummary(summary);
更稳定的做法是让 UI 构建方法内部读取状态:
@Builder
buildSelectedTagSummary() {
Text(this.selectedTags.join('、'))
}
核心原则是:
新引用负责发出状态变化信号
UI 直接读取状态负责建立响应式依赖
两者缺一不可。
八、常见操作一:向数组末尾新增元素
错误或不推荐的状态原地修改:
this.selectedTags.push(tag);
推荐写法:
const nextTags: string[] = this.selectedTags.slice();
nextTags.push(tag);
this.selectedTags = nextTags;
如果需要防止重复:
private addTag(tag: string): void {
if (this.selectedTags.indexOf(tag) >= 0) {
return;
}
const nextTags: string[] = this.selectedTags.slice();
nextTags.push(tag);
this.selectedTags = nextTags;
}
这与真实项目的多选标签业务完全匹配。
九、常见操作二:向数组头部插入元素
动态发布成功后,如果采用本地乐观更新,通常希望新动态出现在最前面。
不推荐直接执行:
this.displayMoodList.unshift(newItem);
推荐:
const nextList: MoodItem[] = this.displayMoodList.slice();
nextList.unshift(newItem);
this.displayMoodList = nextList;
当前项目在发布成功后选择重新查询 Cloud DB。
这是以云端为事实来源的保守方案。
如果未来增加乐观更新,就应使用新数组提交本地结果。
十、常见操作三:按条件删除元素
标签删除可以复制后 splice()。
对于动态列表,按 ID 过滤通常更清晰:
private removeMoodFromState(id: string): void {
const nextList: MoodItem[] = this.displayMoodList.filter(
(item: MoodItem): boolean => item.id !== id
);
this.displayMoodList = nextList;
}
它不会修改旧数组。
而且业务语义一眼可见:保留所有 ID 不等于目标 ID 的项目。
如果 Cloud DB 删除失败,则不要提前删除,或者需要恢复旧数组。
十一、常见操作四:替换数组中的一个对象
点赞是最典型的单项更新。
假设页面希望先本地增加点赞数:
private increaseLocalLikes(id: string): void {
const nextList: MoodItem[] = [];
for (let i = 0; i < this.displayMoodList.length; i++) {
const current = this.displayMoodList[i];
if (current.id === id) {
const updated: MoodItem = {
id: current.id,
userName: current.userName,
moodRate: current.moodRate,
content: current.content,
imageUri: current.imageUri,
timestamp: current.timestamp,
likes: current.likes + 1,
tags: current.tags,
isPrivate: current.isPrivate
};
nextList.push(updated);
} else {
nextList.push(current);
}
}
this.displayMoodList = nextList;
}
这里同时替换了:
- 外层数组引用;
- 被点赞项的对象引用。
这比只修改:
item.likes += 1;
更容易被状态系统明确识别。
十二、只复制数组够不够?要看修改层级
假设状态是对象数组:
@State displayMoodList: MoodItem[] = [];
下面的代码只复制了数组:
const nextList: MoodItem[] = this.displayMoodList.slice();
nextList[0].likes += 1;
this.displayMoodList = nextList;
外层数组是新的。
但 nextList[0] 与旧数组中的第一个对象仍然是同一个引用。
这叫浅拷贝。
它可能带来两个问题:
- 旧状态中的对象也被同步修改;
- 调试或回滚时无法保留真正的旧值。
因此,修改对象字段时应同时创建新对象。
ArkTS 中可以显式构建明确接口对象,避免不受支持的对象展开:
const updated: MoodItem = {
id: current.id,
userName: current.userName,
moodRate: current.moodRate,
content: current.content,
imageUri: current.imageUri,
timestamp: current.timestamp,
likes: current.likes + 1,
tags: current.tags,
isPrivate: current.isPrivate
};
十三、嵌套数组也要按变化路径逐层复制
MoodItem 中还包含标签数组:
tags?: string[];
如果需要给某条动态增加标签,不能直接修改旧对象中的数组。
可以先构造新标签数组:
const oldTags: string[] = current.tags ?? [];
const nextTags: string[] = oldTags.slice();
nextTags.push(newTag);
再构造新的 MoodItem:
const updated: MoodItem = {
id: current.id,
userName: current.userName,
moodRate: current.moodRate,
content: current.content,
imageUri: current.imageUri,
timestamp: current.timestamp,
likes: current.likes,
tags: nextTags,
isPrivate: current.isPrivate
};
最后放入新列表并赋值。
不可变更新的复制范围不是“全部深拷贝”。
而是:
沿着发生变化的路径,逐层创建新引用;没有变化的部分可以复用。
十四、常见操作五:排序
sort() 会原地修改数组。
下面的写法会直接改变状态:
this.displayMoodList.sort(
(a: MoodItem, b: MoodItem): number => b.timestamp - a.timestamp
);
推荐先复制:
const nextList: MoodItem[] = this.displayMoodList.slice();
nextList.sort(
(a: MoodItem, b: MoodItem): number => b.timestamp - a.timestamp
);
this.displayMoodList = nextList;
项目从 Cloud DB 查询时已经使用:
orderByDesc('timestamp')
因此正常情况下不需要页面再次排序。
这也说明最好的优化不是“更聪明地复制”,而是让数据源直接返回需要的顺序。
十五、常见操作六:清空数组
清空选中标签最直接的方式是:
this.selectedTags = [];
而不是:
this.selectedTags.splice(0, this.selectedTags.length);
前者明确提交了一个新的空数组。
发布心情成功后,项目可以一次重置多个表单状态:
this.moodNote = '';
this.selectedImage = '';
this.selectedTags = [];
this.isPrivate = false;
this.moodRate = 3;
每个状态都有明确的新值。
十六、Cloud DB 查询结果为什么天然适合整体替换?
项目刷新动态时构造:
const normalized: MoodItem[] = [];
转换完成后一次性提交:
this.displayMoodList = normalized;
这是非常标准的不可变更新。
旧列表不会在查询过程中被一条条修改。
只有当新数据完整准备好后,页面状态才切换到新列表。
优点包括:
- 不会出现加载一半的中间状态;
- 私密过滤和 JSON 解析完成后再渲染;
- 趋势图与列表可以使用同一批数据;
- 查询失败时旧页面内容可以继续保留。
十七、不要在循环里反复给 @State 赋值
一种低效写法是:
this.displayMoodList = [];
for (let i = 0; i < moodPosts.length; i++) {
const nextList: MoodItem[] = this.displayMoodList.slice();
nextList.push(this.toMoodItem(moodPosts[i] as MoodPost));
this.displayMoodList = nextList;
}
每一条记录都会触发一次状态提交。
列表越大,重建次数越多。
项目使用局部数组累计,最后一次性赋值:
const normalized: MoodItem[] = [];
for (let i = 0; i < moodPosts.length; i++) {
normalized.push(this.toMoodItem(moodPosts[i] as MoodPost));
}
this.displayMoodList = normalized;
这里对 normalized.push() 没有问题。
因为它只是尚未提交的局部变量,不是当前 @State。
这再次说明:
push()本身不是敌人,直接修改已提交状态才是风险来源。
十八、异步更新要防止旧请求覆盖新请求
假设用户连续触发两次刷新:
请求 A 先发出,网络较慢
请求 B 后发出,网络较快
请求 B 先返回并更新页面
请求 A 后返回,反而覆盖新数据
即使每次都使用新数组,也仍然会发生竞态。
可以增加请求版本号:
private refreshVersion: number = 0;
private async refreshData(): Promise<void> {
this.refreshVersion += 1;
const currentVersion = this.refreshVersion;
const normalized = await this.queryMoodItems();
if (currentVersion !== this.refreshVersion) {
return;
}
this.displayMoodList = normalized;
}
不可变更新解决“如何提交状态”。
版本控制解决“哪次异步结果有资格提交状态”。
二者属于不同问题。
十九、点赞的乐观更新与失败回滚
当前真实项目的点赞流程是:
查询 Cloud DB 记录
↓
likes + 1
↓
executeUpsert
↓
refreshData
它可靠,但反馈速度取决于网络。
若要做乐观更新,可以先保存旧数组:
const previousList: MoodItem[] = this.displayMoodList;
this.increaseLocalLikes(id);
再执行云端写入:
try {
await this.increaseCloudLikes(id);
await this.refreshData();
} catch (e) {
this.displayMoodList = previousList;
AlertDialog.show({ message: '点赞失败,请重试' });
}
为什么不可变更新在这里尤其重要?
因为 previousList 必须保留真正的旧状态。
如果之前直接修改了对象字段,所谓“旧数组”内部也已经被改变,就无法回滚。
二十、删除的乐观更新同样需要旧状态
删除动态可以先在页面中移除:
const previousList: MoodItem[] = this.displayMoodList;
this.removeMoodFromState(id);
云端删除成功后保持当前状态。
失败时恢复:
this.displayMoodList = previousList;
这种方式让用户立即看到结果。
但只有在以下条件满足时才值得采用:
- 删除失败有明确提示;
- 旧状态没有被原地污染;
- 不存在并发刷新覆盖;
- 云端最终状态会重新校正。
否则,当前项目“删除成功后刷新”的保守方式更简单。
二十一、聊天消息列表也是同一个问题
ChatDetailPage 使用:
@State messages: Array<Message> = [];
Cloud DB 快照到达后,项目先创建新数组:
const newMessages: Message[] = [];
过滤当前会话并逐条加入:
newMessages.push({
id: msg.getId(),
content: msg.getContent(),
isSelf: msg.getSenderId() === this.myUid,
time: new Date(msg.getTimestamp()).toLocaleTimeString()
});
最后整体赋值:
this.messages = newMessages;
这和 MoodPage 的 normalized 完全一致。
两处代码共同采用了一个稳定模式:
外部数据变化
↓
构造完整的新页面数组
↓
一次性替换 @State
二十二、趋势图和热力图为什么也应该整体替换?
项目还有:
@State trendData: TrendPoint[] = [];
@State monthData: HeatmapDay[] = [];
它们都是从动态列表计算出的派生数据。
计算时应该使用局部数组:
const result: TrendPoint[] = [];
完成后再赋值:
this.trendData = result;
而不是每算出一个点就修改状态数组。
图表状态尤其适合批量提交,因为半成品数据可能造成:
- 折线图短暂闪烁;
- 坐标轴范围反复变化;
- 动画重复启动;
- 热力图出现中间帧。
二十三、ForEach 的 key 与不可变更新是什么关系?
数组更新正确,并不代表列表渲染一定高效。
ForEach 还需要稳定且唯一的 key。
动态列表应使用业务主键:
ForEach(this.displayMoodList,
(item: MoodItem) => {
ListItem() {
this.buildMoodCard(item)
}
},
(item: MoodItem): string => item.id
)
标签列表可以使用标签文本,但前提是文本唯一:
(activity: string): string => activity
不要轻易使用数组下标作为长期 key。
删除或排序后,下标会变化,框架可能错误复用节点。
不可变更新告诉框架“列表变了”。
稳定 key 告诉框架“具体哪一项还是原来的那一项”。
二十四、不要在 ForEach 数据源位置反复计算新数组
不推荐直接写:
ForEach(this.getVisibleMoods(), ...)
如果方法每次返回新数组,构建过程中可能产生重复计算,复杂情况下还会造成异常重建。
更稳定的做法是提前把结果保存为状态:
@State displayMoodList: MoodItem[] = [];
查询和过滤完成后一次赋值。
UI 直接使用:
ForEach(this.displayMoodList, ...)
项目当前正是这种方式。
数据准备放在业务方法中。
UI 构建只消费稳定数组。
二十五、性能问题:不可变更新会不会一直复制很慢?
对于标签数组、聊天消息和普通信息流,不可变更新的成本通常完全可接受。
尤其是标签选择场景:
- 数组很小;
- 用户点击频率有限;
- 每次复制只涉及少量引用;
- 清晰可靠比微小性能差异更重要。
真正需要关注的是:
- 数万条数据每次全量复制;
- 高频传感器数据每帧更新;
- 超大对象深拷贝;
- 循环中反复提交状态;
- 不稳定 key 导致整表重建。
优化顺序应该是:
- 避免循环中多次赋值;
- 只复制发生变化的路径;
- 使用稳定 key;
- 分页或限制数据规模;
- 高频数据采用专门状态方案;
- 最后再考虑减少数组复制。
二十六、不可变更新不等于 JSON.parse(JSON.stringify())
有些开发者为了“深拷贝”会写:
const next = JSON.parse(JSON.stringify(this.displayMoodList));
这通常不是好方案。
它会带来:
- 日期、类实例和特殊类型语义丢失;
- 不必要的序列化开销;
- 类型信息模糊;
- 错误被推迟到运行时;
- ArkTS 严格类型下需要额外断言。
不可变更新不要求每次深拷贝整个状态树。
只要沿变化路径创建新引用即可。
其他没有变化的对象可以安全复用。
二十七、不可变更新不等于每次都使用 map()
map() 很适合一对一转换。
但 ArkTS 项目中,显式循环有时更容易写出明确类型,也更方便同时处理跳过、容错与对象构建。
例如项目把 Cloud DB 对象转换成 MoodItem 时使用循环:
const normalized: MoodItem[] = [];
for (let i = 0; i < moodPosts.length; i++) {
const post = moodPosts[i] as MoodPost;
if (post.getIsPrivate() && post.getUid() !== this.myUid) {
continue;
}
normalized.push(this.toMoodItem(post));
}
这里的局部 push() 简单清楚。
最后整体赋值同样符合不可变状态原则。
选择循环、filter() 还是 map(),应根据可读性和 ArkTS 类型约束决定。
二十八、一个可复用的更新模式
对于任何 @State 数组,都可以套用下面的思考方式:
第一步:明确旧状态
const currentList = this.displayMoodList;
第二步:构造下一状态
const nextList: MoodItem[] = [];
第三步:只修改副本或新对象
nextList.push(updatedItem);
第四步:一次性提交
this.displayMoodList = nextList;
第五步:确保 UI 直接读取该状态
ForEach(this.displayMoodList, ...)
第六步:提供稳定 key
(item: MoodItem): string => item.id
这六步同时解决状态通知、数据一致性和列表复用问题。
二十九、真实项目中的更新策略对照
| 场景 | 当前项目策略 | 推荐理由 |
|---|---|---|
| 标签选择 | 复制后增删再赋值 | 小数组高频反馈 |
| Cloud DB 动态刷新 | 构造 normalized 后整体替换 |
避免半成品状态 |
| 聊天快照更新 | 构造 newMessages 后整体替换 |
云端快照是事实来源 |
| 趋势图计算 | 构造结果数组后整体替换 | 防止图表中间态 |
| 热力图计算 | 构造结果数组后整体替换 | 保持统计口径一致 |
| 点赞 | 云端成功后重新刷新 | 实现简单且最终一致 |
| 删除 | 云端成功后重新刷新 | 避免本地与云端分叉 |
可以看到,不可变更新并不只是一种标签技巧。
它已经贯穿项目多个数据流。
三十、排查“数组改了但 UI 没刷新”的清单
遇到问题时,可以按顺序检查:
- 数组是否使用了
@State、@Link或其他正确状态装饰器; - 是否直接对状态数组执行
push()、splice()、sort(); - 修改后是否把新数组重新赋给状态;
- 对象数组中被修改的对象是否也创建了新引用;
- 嵌套数组是否沿变化路径复制;
- UI 是否直接读取对应状态;
- 是否把计算后的固定值传入了非响应式路径;
-
ForEach是否提供唯一稳定 key; - 是否在
ForEach数据源中调用返回新数组的方法; - 是否在循环中反复提交状态;
- 是否存在旧异步请求覆盖新结果;
- 是否错误使用下标作为排序后列表的 key;
- 是否在乐观更新前污染了旧对象,导致无法回滚;
- 是否把 Cloud DB 半成品结果直接写进页面状态。
三十一、什么时候可以原地修改数组?
如果数组只是方法内部的局部变量,还没有提交给状态,可以放心修改:
const result: TrendPoint[] = [];
result.push(point);
result.push(nextPoint);
this.trendData = result;
如果数组是新复制出来的副本,也可以修改:
const nextTags: string[] = this.selectedTags.slice();
nextTags.splice(index, 1);
this.selectedTags = nextTags;
真正需要避免的是:
this.selectedTags.splice(index, 1);
判断标准非常简单:
当前操作的数组是不是 UI 正在持有的那份状态引用?
如果是,就先创建新数组。
三十二、总结
“灵犀互聊”的标签选择、动态刷新和聊天快照更新共同说明了一件事:
ArkUI 数组状态管理的重点,不只是数组内容有没有变化,而是变化是否被明确提交给响应式系统。
最稳定的模式是:
读取旧状态
↓
构造新数组
↓
必要时构造新对象
↓
一次性赋回 @State
↓
UI 直接读取状态
↓
ForEach 使用稳定 key
需要记住三个结论:
push()、splice()、sort()不是绝对不能用,它们不能直接作用于当前状态;- 对象数组更新时,只复制外层数组可能不够,被修改对象也应创建新引用;
- 不可变更新解决的是状态提交边界,异步竞态、稳定 key 和响应式依赖仍需分别处理。
在 ArkUI 里,数组状态管理的关键不是“我确实改过它”,而是“我是否构造并提交了一个明确的下一状态”。
当每次变化都有清楚的新引用,页面刷新会更可预测,异步回滚更可靠,Cloud DB 数据与 UI 状态之间的边界也会更稳定。

更多推荐


所有评论(0)