【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()

如果项目已验证数组展开语法可用,也可以采用展开方式。

但团队应统一风格,不要在同一页面混用多种策略。


六、标签切换的完整状态变化

假设初始状态是:

[]

点击“运动”后:

旧状态 []
新状态 [运动]

点击“阅读”后:

旧状态 [运动]
新状态 [运动, 阅读]

再次点击“运动”后:

旧状态 [运动, 阅读]
新状态 [阅读]

每一步都生成新数组。

旧数组在当前更新过程中保持不变。

这会带来三个直接收益:

  1. UI 刷新边界明确;
  2. 调试时能比较更新前后状态;
  3. 异步操作不容易意外共享同一个可变数组。

七、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] 与旧数组中的第一个对象仍然是同一个引用。

这叫浅拷贝。

它可能带来两个问题:

  1. 旧状态中的对象也被同步修改;
  2. 调试或回滚时无法保留真正的旧值。

因此,修改对象字段时应同时创建新对象。

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;

这和 MoodPagenormalized 完全一致。

两处代码共同采用了一个稳定模式:

外部数据变化
    ↓
构造完整的新页面数组
    ↓
一次性替换 @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 导致整表重建。

优化顺序应该是:

  1. 避免循环中多次赋值;
  2. 只复制发生变化的路径;
  3. 使用稳定 key;
  4. 分页或限制数据规模;
  5. 高频数据采用专门状态方案;
  6. 最后再考虑减少数组复制。

二十六、不可变更新不等于 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

需要记住三个结论:

  1. push()splice()sort() 不是绝对不能用,它们不能直接作用于当前状态;
  2. 对象数组更新时,只复制外层数组可能不够,被修改对象也应创建新引用;
  3. 不可变更新解决的是状态提交边界,异步竞态、稳定 key 和响应式依赖仍需分别处理。

在 ArkUI 里,数组状态管理的关键不是“我确实改过它”,而是“我是否构造并提交了一个明确的下一状态”。

当每次变化都有清楚的新引用,页面刷新会更可预测,异步回滚更可靠,Cloud DB 数据与 UI 状态之间的边界也会更稳定。

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐