【OpenHarmony/HarmonyOs 】24|ArkUI 数组不可变更新:为什么 `@State` 列表改了,界面却可能不刷新

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