一个很像“缓存”的问题

在开发 HarmonyOS 心情日记“心晴手记”时,我遇到过几类看起来互不相关的问题:

  • 编辑习惯的图标和颜色后,列表仍显示旧样式;
  • 点击心情或标签,数据已变化但选中态偶尔不更新;
  • 从 7 天切换到 30 天后,部分统计卡片还是原来的数字;
  • 切换一次 Tab 再回来,内容又突然正确了。

第一反应通常是:是不是 Preferences 保存慢了?是不是异步代码没有 await

但打印状态后发现,内存数组已经是新值。问题发生在“状态到视图”的最后一段。

最终定位到两个关键点:

  1. 更新数组时是否真正替换了状态引用;
  2. ForEach 的 Key 是否仍让框架认为这是原来的子节点。

一、先确认状态真的发生了可观察变化

假设我们直接修改数组中的对象字段:

this.habits[index].color = newColor;

从 JavaScript 语义上看数据已经变了,但在声明式 UI 中,“对象内部某处变化”不一定等于框架能准确感知到刷新范围。

项目中统一采用创建新对象、替换数组的方式:

this.habits = this.habits.map((habit: Habit) => {
  if (habit.id !== this.editingHabitId) {
    return habit;
  }

  return {
    id: habit.id,
    name: trimmed,
    icon: this.habitIconDraft,
    color: this.habitColorDraft,
    isActive: habit.isActive,
    createdAtMillis: habit.createdAtMillis,
    updatedAtMillis: Date.now(),
    archivedAtMillis: habit.archivedAtMillis
  };
});

这次赋值同时产生了:

  • 一个新的 Habit 对象;
  • 一个新的数组引用;
  • 新的 updatedAtMillis

它让状态变化更明确,也为后面的 Key 提供了修订依据。

二、Key 不只是“唯一 ID”

常见写法是:

ForEach(this.habits, (habit: Habit) => {
  HabitRow({ habit: habit })
}, (habit: Habit) => habit.id)

habit.id 的确唯一,而且在增删列表项时非常合理。但如果这一行内部包含选中态、图标派生状态或可复用子组件,固定 Key 可能让框架继续复用之前的节点。

从框架视角看:

Key 还是原来的 Key,那么这仍然是原来的那一行。

这通常是性能优化所需要的行为,但当行的显示结果依赖更多状态时,单独使用 ID 可能没有完整表达“何时需要重建”。

三、把真正影响显示的修订信息放进 Key

习惯被编辑时,项目会更新 updatedAtMillis,因此列表 Key 可以写成:

ForEach(this.activeHabits(), (habit: Habit) => {
  // 构建习惯行
}, (habit: Habit) =>
  `${habit.id}-${habit.updatedAtMillis}-${this.uiRevision}`
)

对于标签选中态,Key 直接包含选择结果:

ForEach(TAGS, (tag: TagDefinition) => {
  // 构建标签胶囊
}, (tag: TagDefinition) =>
  `${tag.key}-${this.selectedTags.includes(tag.key)}-${this.uiRevision}`
)

对于历史习惯打卡,Key 则包含日期和完成状态:

(habit: Habit) =>
  `${habit.id}-${this.selectedDayId}-` +
  `${this.completionFor(habit.id, this.selectedDayId) ? '1' : '0'}-` +
  `${this.uiRevision}`

这不是要求所有 Key 都无限拼接状态,而是要思考:这个子节点的“显示身份”到底由哪些信息决定?

四、为什么还增加了 uiRevision

项目中维护了一个显式修订号:

@State private uiRevision: number = 0;

private refreshUi(): void {
  this.uiRevision += 1;
}

在保存心情、切换标签、编辑习惯、打卡和切换统计周期后调用:

this.persist();
this.refreshUi();

uiRevision 的作用是给复杂页面一个可控的“显示版本”。当多个 Builder、派生函数和 ForEach 同时依赖一组数据时,修订号可以明确通知相关 Key:这一轮交互完成后,需要重新评估子节点。

但要注意,它是一种项目级兜底,不应该替代正确的状态建模。

如果一个简单文本都必须依靠全局修订号才能刷新,应先检查:

  • 变量是否使用了正确的状态装饰器;
  • 数组或对象是否被原地修改;
  • Builder 是否读取了实际状态;
  • Key 是否错误地固定;
  • 是否把应当声明为状态的数据放在普通成员变量里。

五、统计周期切换为什么更容易暴露问题

统计页的数字不是直接存储的,而是根据以下状态派生:

  • 当前选择 7 天还是 30 天;
  • 心情记录数组;
  • 习惯数组;
  • 打卡数组;
  • 习惯的创建和归档时间。

例如习惯完成率:

private habitCompletionRate(): number {
  const eligibleDays: number = this.totalEligibleHabitDays();
  if (eligibleDays === 0) {
    return 0;
  }

  const completedDays: number = this.habits.reduce(
    (total: number, habit: Habit) =>
      total + this.completedDaysForHabit(habit),
    0
  );

  return Math.min(
    100,
    Math.round(completedDays / eligibleDays * 100)
  );
}

如果统计卡片被稳定 Key 复用,它内部派生函数可能没有按预期重新构建。项目在统计区域的 Key 中同时包含 insightDaysuiRevision

ForEach([`${this.insightDays}-${this.uiRevision}`], () => {
  // 构建统计内容
})

切换周期时,显示身份随之变化,相关区域会重新生成。

六、不要把数组索引当作动态列表的长期 Key

如果列表允许插入、删除、排序,使用索引会产生另一个问题。

假设三项对应索引 0、1、2,删除第一项后,原来的第二项变成索引 0。框架可能把旧的第 0 行状态复用给新的第 0 行,造成输入框内容、开关状态或动画错位。

因此一般应优先使用业务稳定 ID:

(habit: Habit) => habit.id

只有在“业务 ID + 修订字段”仍不足以表达显示身份时,才增加相关状态。不要为了强制刷新随机生成 Key,否则每次构建都会摧毁所有复用收益。

七、一套实用的排查顺序

下次遇到“数据变了,页面没变”,可以按这个顺序排查:

第一步:打印交互后的内存状态

确认问题不是保存失败或业务分支没有执行。

第二步:检查是否使用不可变更新

优先使用 mapfilter、展开运算符创建新数组或新对象。

第三步:确认 UI 直接依赖的是状态变量

不要只更新一个普通字段,却期待组件自动刷新。

第四步:检查 ForEach Key

Key 是否唯一、稳定?是否遗漏真正改变显示身份的字段?

第五步:缩小复现范围

先用一个 Text 直接显示目标值,再逐层恢复 Builder 和子组件,定位是哪一级发生复用。

第六步:最后才考虑显式修订号

uiRevision 用在复杂兼容场景,而不是所有页面的默认方案。

总结

这次问题最终不是 Preferences,也不是异步保存,而是状态更新和节点复用共同造成的视觉滞后。

最值得记住的三点是:

  1. 状态更新尽量创建新数组、新对象;
  2. Key 表达的是子节点的显示身份,不只是数据库主键;
  3. 显式修订号可以兜底,但不能掩盖错误的状态建模。

把这三层关系理顺后,ArkUI 中很多“切换页面才刷新”的问题会变得容易定位。

本文案例来自“心晴手记” HarmonyOS 版的习惯、标签与统计页面。

体验心晴手记鸿蒙版

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐