鸿蒙 ArkUI 开发踩坑:数据已经改了,列表为什么没刷新?

做一个任务列表页面时,我遇到了一个很容易让人怀疑点击事件的问题。

页面功能不复杂:展示任务,点击“完成”按钮后,把任务状态改为已完成,同时更新按钮文字。

添加和删除任务都正常,唯独点击“完成”时,界面没有变化。

一开始我以为事件没触发,后来在回调里加了日志才发现:事件执行了,布尔值也已经改变,只是页面没有跟着更新。

这次排查涉及的是 ArkUI 状态管理 V2。下面把问题拆开,说明为什么会出现这种情况,以及应该怎样修复。

一、问题出在什么地方?

为了方便说明,先把页面精简到最小。

任务对象最初是一个普通类:

class TaskItem {
  id: number;
  title: string;
  done: boolean = false;

  constructor(id: number, title: string) {
    this.id = id;
    this.title = title;
  }
}

页面中用 @Local 保存任务数组:

@Entry
@ComponentV2
struct Index {
  @Local tasks: TaskItem[] = [
    new TaskItem(1, '学习 ArkUI 状态管理'),
    new TaskItem(2, '完成任务列表页面')
  ];

  build() {
    Column({ space: 16 }) {
      ForEach(
        this.tasks,
        (item: TaskItem) => {
          Row({ space: 12 }) {
            Text(item.title)

            Button(item.done ? '已完成' : '完成')
              .onClick(() => {
                item.done = !item.done;
                console.info(
                  `taskId=${item.id}, done=${item.done}`
                );
              })
          }
        },
        (item: TaskItem): string => item.id.toString()
      )
    }
    .padding(20)
  }
}

这段代码看起来没有明显问题:

  • 数组用了 @Local。
  • 按钮文字依赖 item.done。
  • 点击事件修改了 item.done。
  • ForEach 也提供了唯一 ID。

当时我的理解是:既然 tasks 已经是状态变量,那么数组里面的数据发生变化,界面就应该更新。

问题恰好出在这个理解上。

二、先确认事件,再确认数据

碰到界面不更新,第一步不应该直接换装饰器,而是先确定问题发生在哪个环节。

我先在点击回调里记录任务 ID 和修改后的状态:

.onClick(() => {
  item.done = !item.done;

  console.info(
    `taskId=${item.id}, done=${item.done}`
  );
})

这里需要观察两件事。

第一,点击后是否有日志。如果没有,就应该先检查事件绑定、组件遮挡等问题。

第二,done 是否发生变化。如果回调执行了,但值没有按预期变化,就应该检查赋值逻辑,以及是否修改了错误的对象。

在本文这个最小示例中,赋值语句本身能够改变普通对象的属性。问题是,这次属性变化没有建立起驱动界面更新所需的观测关系。

于是,排查重点从“按钮有没有响应”,转到了“框架能不能观察到这次修改”。

三、数组发生变化,不等于对象属性发生变化

这次问题里,最需要区分的是下面两种操作。

第一种是修改组件持有的数组:

this.tasks = [
  ...this.tasks,
  new TaskItem(3, '补充交互验证')
];

这里给 tasks 赋了一个新的数组。

第二种是修改数组内某个对象的属性:

item.done = !item.done;

这里没有给 tasks 重新赋值,也没有增加或删除数组元素。改变的是已有对象内部的 done。

@Local 并不意味着普通对象任意层级的属性,都自动具备深层观测能力。

因此,添加任务能够更新列表,并不能证明任务对象内部的属性修改也一定能够更新界面。

这也解释了为什么同一个页面里,添加、删除正常,切换完成状态却出了问题:它们改变数据的层级不同。

四、修复:明确声明需要观察的对象属性

确认原因后,修复可以放在数据模型上。

使用 @ObservedV2 装饰任务类,再用 @Trace 标记需要响应变化的字段:

@ObservedV2
class TaskItem {
  id: number;
  title: string;

  @Trace done: boolean = false;

  constructor(id: number, title: string) {
    this.id = id;
    this.title = title;
  }
}

页面仍然通过 @Local 管理数组:

@Local tasks: TaskItem[] = [
  new TaskItem(1, '学习 ArkUI 状态管理'),
  new TaskItem(2, '完成任务列表页面')
];

点击事件也不用改变:

.onClick(() => {
  item.done = !item.done;
})

这时,界面读取的 item.done 是可观测属性,后续修改能够触发依赖它的 UI 更新。

华为官方文档也给出了这种模型:通过 @ObservedV2 和 @Trace 配合,实现对象属性变化的观测。参考:数据对象状态变量迁移。

这里没有给 id 和 title 添加 @Trace,因为这个版本里它们在创建后不会修改。

如果后续支持编辑任务标题,就要重新考虑 title 的观测需求。装饰器应该跟着数据的实际使用方式设计,不能只看字段类型。

五、为什么没有通过修改列表 key 来解决?

排查列表刷新问题时,很容易顺手修改 ForEach 的键值生成方式。

例如,把完成状态也拼进去:

(item: TaskItem): string =>
  `${item.id}_${item.done}`

我不建议把这种写法当成当前问题的修复方案。

任务从未完成变成已完成,改变的是任务状态,任务身份并没有变化。把状态放进 key,会让“同一任务状态变化”变成“列表项身份变化”。

而且,修改 key 的生成函数本身,也不能替普通对象补上缺失的属性观测关系。

本例保留下面的写法即可:

(item: TaskItem): string => item.id.toString()

这里有两个不同的问题:

  • ID 负责标识“这是哪一项任务”。
  • 状态观测负责通知“这项任务的内容变了”。

把这两个职责混在一起,可能暂时掩盖问题,也会增加后续维护难度。

六、修复后,不能只点一次按钮就结束

对于这种问题,只验证“按钮文字能变化”还不够。

我会按下面的顺序检查:

操作需要确认的结果
点击第一项“完成”第一项状态变化,其他任务保持原样
再次点击第一项能恢复未完成状态
连续切换不同任务每项状态独立
删除一项后再切换其他任务操作仍然对应正确的任务
新增任务后切换状态新对象同样具备观测能力
添加两个同名任务不会因为标题相同而相互影响

其中,“新增任务后再切换状态”很容易被遗漏。

初始化数据使用了 new TaskItem(),不代表后续所有数据来源都一定创建了相同的可观测对象。接入接口后,也需要确认数据转换流程,避免仅通过类型断言,就把普通 JSON 对象当成已经正确创建的模型实例。

类型检查通过,与运行时具备所需的状态观测行为,是两件需要分别确认的事情。

七、这次问题留下的一个排查习惯

以后再遇到“数据变了,页面没变”,我会按这个顺序排查:

事件是否执行
    ↓
目标数据是否改变
    ↓
修改的是哪个对象、哪一层属性
    ↓
该属性是否能够被观测
    ↓
界面是否读取并依赖这个属性

这样的排查顺序,比反复尝试重新赋值数组、修改 key 或者强制重建组件,更容易定位原因。

这次问题本身并不复杂,真正容易踩坑的是一个先入为主的判断:

数组用了状态装饰器,数组里的所有内容就都能自动刷新。

在 ArkUI 状态管理 V2 中,组件持有的状态与对象内部的可观测属性需要分别理解。把修改发生的位置找准,修复通常就会变得很直接。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐