一个很让人困惑的页面故障是这样的:任务列表的“完成”按钮点下去了,控制台里的对象属性也变了,列表中的文字却仍然显示“未完成”。等你返回页面再进入,或者顺手让整个数组重新赋值,界面又好像恢复了。这不一定是 List 的问题,更不能靠每次点击都强制刷新整页来解决。

我们做一个独立的任务清单示例,把问题缩小到状态订阅链。目标是解释:在 ArkUI 的状态管理 V1 里,@State 能观察到什么,@Observed 与 @ObjectLink 又为什么需要配合使用。把这些边界弄明白,列表排序、局部刷新和组件复用都会容易不少。

ArkUI 状态观测边界与列表行刷新技术封面

一、先抓住故障最短路径

某段页面代码保留了任务数组:

interface RawTask {
  id: string;
  label: string;
  detail: {
    done: boolean;
  };
}

// 页面类内部的示意片段
@State tasks: RawTask[] = [
  { id: 'report', label: '检查报告', detail: { done: false } }
];

// 某次点击后执行
this.tasks[0].detail.done = true;

初学者容易认为:tasks 写了 @State,它内部的任意对象再深几层的属性都能自动通知界面。这里实际修改的是 tasks[0].detail.done,它藏在数组元素的嵌套对象中。状态管理 V1 对对象及数组的可观测深度有边界,给数组根变量加一个装饰器,不等于无限层级的属性都被订阅。因此“对象的值已经变了”和“UI 知道这个值变了”是两件事。华为开发者的嵌套对象状态相关资料专门解释了这类限制。

先不要急着写一堆 JSON.stringify,也不要把问题推给 ForEach 的键。排查顺序应该是:事件是否触发,属性实际写入了哪里,装饰器是否能观察对应层级,最后才检查子组件是否读到了正确实例。

状态变更已发生但组件订阅未命中的故障路径

这张图里的关键不是“刷新次数”,而是通知有没有沿着正确的依赖链传播。它区分了两种常被混淆的情况:业务数据没变与数据变了但 UI 没订阅到。前者查事件和赋值,后者查对象层级和装饰器;两种故障需要的修复完全不同。

二、把嵌套字段挪到可观察的行模型上

对于任务清单这种小规模、单行独立更新的页面,可以让每条任务成为一个 @Observed 类实例,把 done 定义成该实例的第一层属性,再让行组件用 @ObjectLink 观察它。代码如下,放在 entry/src/main/ets/pages/Index.ets,使用 Stage 模型默认的 ArkTS/ArkUI 页面即可;数据是本地数组,不需要三方包或敏感权限。

@Observed
class WorkItem {
  id: string;
  label: string;
  done: boolean;

  constructor(id: string, label: string, done: boolean) {
    this.id = id;
    this.label = label;
    this.done = done;
  }
}

@Component
struct WorkRow {
  @ObjectLink item: WorkItem;

  build() {
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text(this.item.label)
          .fontSize(17).fontWeight(FontWeight.Medium)
        Text(this.item.done ? '已完成' : '未完成')
          .fontSize(12)
          .fontColor(this.item.done ? '#178455' : '#B45309')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Button(this.item.done ? '撤销' : '完成')
        .onClick((): void => {
          this.item.done = !this.item.done;
        })
    }
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .width('100%')
  }
}

@Entry
@Component
struct Index {
  @State tasks: WorkItem[] = [
    new WorkItem('report', '检查报告', false),
    new WorkItem('invoice', '核对发票', true),
    new WorkItem('backup', '确认备份', false)
  ];

  build() {
    Column({ space: 12 }) {
      Text('待办任务').fontSize(25).fontWeight(FontWeight.Bold)
      Text('点击按钮观察当前任务行的状态变化')
        .fontSize(13).fontColor('#667085')
      List({ space: 8 }) {
        ForEach(this.tasks, (task: WorkItem) => {
          ListItem() {
            WorkRow({ item: task })
          }
        }, (task: WorkItem): string => task.id)
      }
      .width('100%')
      .layoutWeight(1)
    }
    .padding(20).width('100%').height('100%')
    .backgroundColor('#F3F7FB')
  }
}

这个改法有三个重要动作。首先,数据模型变成 @Observed 类,done 是类实例的直接属性。然后,WorkRow 使用 @ObjectLink item,观察来自父组件的同一对象实例,而不是自行复制一份数据。最后,ForEach 的键是 task.id,而不是数组位置,避免列表排序时旧行与新数据配错。每个动作负责不同问题,缺少哪一步,现象都可能不一样。

值得把“对象身份”说得再细一点:一条任务的 id 是业务身份;JavaScript/ArkTS 里的对象引用是当前内存实例;ForEach 的 key 是渲染节点识别标识。三者有关联,但不能互相取代。换一条任务的状态,不应该偷偷把它的 id 改掉,也不需要为了让文字变化给整条记录随机生成一个新 key。稳定 key 不是刷新的开关,而是帮助框架在列表变化时正确复用组件。

三、为什么“数组浅拷贝”不是通用修复

你可能见过下面这种写法:

this.tasks = [...this.tasks];

它确实会让数组根引用发生变化,因此可能带动父组件重新计算,看上去问题解决了。但如果每次改变一条任务都靠重建整个数组来通知,业务语义和渲染触发被绑定在一起。更麻烦的是,开发者以后修改深层的 task.detail.owner.name 时,还会继续碰到同样的可观察性问题。浅拷贝通常只能处理一层引用变化,它不自动赋予嵌套数据完整的通知能力。

当然,@Observed 和 @ObjectLink 也不是“加上后任何深层对象都会自动更新”。它们在 V1 模型下有层级限制。假如 WorkItem 继续包含 meta: MetaInfo,而界面直接修改 meta.owner.name,仍要分析 MetaInfo 和更深层数据是否经过恰当的可观察处理,必要时拆成子组件再使用对应的对象链接。这也是华为后来提供 @ObservedV2 与 @Trace 深度观测能力的重要原因之一,但V1 和 V2 不是随意混写的一组装饰器,迁移时应按官方模型规则设计。

这个示例选择 V1,并不是断言它是所有场景下唯一方案,而是有意为一个独立问题给出最小、容易理解的修复路径。如果项目已经全面采用状态管理 V2,应该在 V2 体系里使用 @ObservedV2/@Trace 等对应能力,不必为了复现旧问题反向引入 V1 组件。

修复前后状态订阅路径与关键装饰器对照

四、排查清单:从事件到组件逐层缩小范围

如果实际工程还在出问题,可以把排查拆成五步,不用一开始就大改架构。第一步,在按钮回调中确认事件确实执行,并记录目标任务 ID;不要把“按钮有按压效果”误认为业务操作完成。第二步,定位被修改的变量路径,明确是在改根变量、对象的一层属性,还是数组项的嵌套对象属性。第三步,把页面绑定表达式与修改路径一一对应:绑定读的是 item.done,回调是否写到了同一个实例的 done?第四步,检查 @Observed 所在的类以及 @ObjectLink 所在组件,确认没有在子组件里重新构造另一个对象。第五步,确认 ForEach 的 key 由稳定且唯一的业务字段构成。

还可以设置两个有针对性的对照用例。只改变第一行的 done,观察其他行内容不应被错误改写;然后交换数组中两个任务的位置,确认“检查报告”的完成状态仍属于 ID 为 report 的记录。这里只验证功能结果,任何渲染耗时和内存数据都必须来自实际分析工具,不能从代码形状直接推算出节省了多少毫秒。

有一种容易被忽略的边界是数据替换:如果业务从网络重新加载了整个任务数组,即便 id 不变,可能出现旧实例与新实例切换。此时行组件应该接收新数据源中对应的对象,不能靠保留旧对象引用来充当业务缓存。另外,按钮快速重复点击并不等于网络请求安全;接入服务器后还要处理提交中状态、失败回滚与重复提交,这些是领域逻辑,不能期待 UI 装饰器替你完成。

五、把“会变”和“能被观察”分清楚

这次故障处理的实用结论是:一个变量的值变化,并不保证当前 UI 订阅链就能感知;@State 的用途不是给任意深度对象自动加上全链路观察;@Observed 类实例与 @ObjectLink 子组件的组合适合这类 V1 行级更新。选择模型时,先画清楚业务数据路径,再决定组件如何订阅,不要用反复改数组引用去掩盖真正的观测边界。

用简单的任务清单做示例,恰好能把网络、数据库、复杂导航全排除掉。等这条最短路径通了,再逐步把错误现场放回实际项目,问题会比“整页总是不刷新”具体得多。对于长期维护的工程,状态管理 V1 与 V2 的迁移要看既有组件体系、嵌套深度和测试成本,而不是看到新装饰器就一股脑替换。

参考资料:

Logo

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

更多推荐