ArkUI 状态刷新踩坑实录:@ObjectLink 静默失效的两个真正原因

一次购物车勾选不刷新的排查,前后绕了四个大弯。本文记录完整过程、两个真正的根因,以及一套可复用的排查方法论。

一、问题:数据明明变了,UI 不动

场景很普通:HarmonyOS 购物车列表,点击商品行的勾选框,调用后端接口切换选中状态。

现象却是:

  • 接口返回 200,数据确实变了;
  • 底部的「全选」图标会变,「合计金额」也会变;
  • 但商品行内部的勾选框图标不变,数量数字也不变。

诡异之处在于:同一个页面里,有的地方刷新,有的地方不刷新。

先交代一下技术栈:

  • HarmonyOS NEXT / ArkTS / ArkUI 声明式范式
  • 状态管理:V1@State / @Prop / @Observed / @ObjectLink
  • 数据源:AppStorage 里存购物车 JSON 字符串,static class 做 Store 层

二、先纠正一个认知:@State 只观测"第一层"

这是整件事的起点。ArkUI 的观测能力分层:

装饰器能观测到什么
@State变量本身的引用变化。this.arr = newArr 能感知;this.arr[0].foo = x 感知不到
@Prop从父组件单向同步的
@Observed + @ObjectLink对象内部的属性变化

所以「对象数组里某一项的字段变了」这种场景,@State 天然无能为力。这正是 @Observed / @ObjectLink 存在的意义。

三、第一轮尝试:加 key,能刷新了,但开始闪

第一反应是查到了官方 FAQ 的一条:ForEach 非首次渲染时,如果新 key 和旧 key 相同,框架会直接复用整棵组件子树,且不重新执行 itemGenerator

于是我把会变化的字段拼进 key:

ForEach(g.items, (item: CartItem) => {
  CartItemCard({ ... })
}, (item: CartItem) => `${item.cartItemId}_${item.selected ? 1 : 0}_${item.quantity}`)

能刷新了。 但引入了新问题:每次点击 key 都变,框架就销毁重建整个组件,商品图片被重新创建、重新走 CDN 加载——肉眼可见闪一下。

这是 key 方案的固有代价,在 key 方案里无解。要消除闪烁,必须换机制:让 key 保持稳定,改由属性级观测驱动刷新。

四、第二轮尝试:@Observed + @ObjectLink,完全不动

于是我改成了标准姿势:

@Observed
export class CartItem {
  cartItemId: number = 0
  // ...
}

@Component
export struct CartItemCard {
  @ObjectLink item: CartItem
  // ...
}
ForEach(this.rows, (item: CartItem) => {
  CartItemCard({ item: item })
}, (item: CartItem) => `${item.cartItemId}`)   // key 稳定

结果:一点反应都没有。

五、关键一步:用探针把链路切开

停止猜测。我在三个环节各埋一个探针,把"点击 → 数据 → 通知"这条链切成三段:

// ① 事件是否到达
onToggle: (id: number) => {
  console.log(`[CartPage] onToggle 触发 id=${id}`)
  CartStore.toggleSelect(id).then(() => {
    console.log(`[CartPage] toggleSelect 完成 id=${id}`)
  })
}

// ② 数据是否被修改
function mutateItem(items: CartItem[], id: number, ...): void {
  // ...就地改属性
  console.log(`[CartStore] mutateItem 就地修改 id=${id} → selected=${it.selected}`)
}

// ③ 通知是否送达(@Watch 与 @ObjectLink 可以叠用,这是官方示例过的合法写法)
@Component
export struct CartItemCard {
  @ObjectLink @Watch('onItemChanged') item: CartItem

  onItemChanged(): void {
    console.log(`[CartItemCard] @Watch item 变化!`)
  }
}

日志结果:

[CartPage] onToggle 触发 id=46                                    ✅
[CartStore] mutateItem 就地修改 id=46 → selected=false           ✅
[CartItemCard] @Watch item 变化!                                  ❌ 零输出

结论:数据改对了,通知没送达。 而且 aboutToAppear 只有首次渲染的两条——组件既没被重建,也没收到属性更新。

六、两个真正的根因

根因一:同步源必须是状态数据

官方文档「变量的传递/访问规则」里有一条硬性约束:

同步源的 class 或者数组必须是 @State@Link@Provide@Consume 或者 @ObjectLink 装饰的数据。

我为了把「分组 → 商品」两层结构拍平成一维(因为 @ObjectLink 只能绑定数组元素,不能跨嵌套层取 g.items[x]),写了一个中间方法:

private flatRows(): CartRow[] {     // ← 普通方法,每次返回全新的临时数组
  const rows: CartRow[] = []
  // ...
  return rows
}

ForEach(this.flatRows(), (row: CartRow) => { ... })

这个临时数组不被任何状态装饰器持有。 @ObjectLink 的注册依赖同步源是状态数据——注册链在初始渲染时就建立不起来。

修法:新增 @State rows: CartItem[],在 refreshDerived() 里重建并赋值,ForEach 直接遍历状态数组。

根因二:注册的实例 ≠ 被修改的实例(更隐蔽)

这才是最深的一层。官方对注册机制的描述是:

初始渲染@Observed 实例会被代理对象包装……@ObjectLink 的包装类会将自己注册给 @Observed class,即向实例提供自身引用,让实例把它加入依赖列表
属性更新:当 @Observed class 属性改变时……遍历依赖它的 @ObjectLink 包装类,通知数据更新。

关键点:通知是「跟着具体的对象实例走」的。

而我的数据源是 AppStorage 里的 JSON 字符串,每次读都要解析:

function readItems(): CartItem[] {
  const j = AppStorage.get(AK.cartItems) as string
  return JSON.parse(j).map(i => toCartItem(i))   // ← 每次都 new 出新实例
}

于是形成了这样一条断裂的链:

渲染时  → 解析出实例 A → @ObjectLink 把自己注册进 A 的依赖列表
点击时  → 解析出实例 B → mutateItem 改的是 B

B 的依赖列表里没有那个 @ObjectLink,而 A 的属性从头到尾没被改过。

所以日志完全对得上:mutateItem 确实执行了,但没有任何一个被观测的实例发生过变化,通知自然无从发出。

修法:在 Store 层加实例缓存,保证同一个 cartItemId 全局只有一份实例。

const itemCache = new Map<number, CartItem>()

function readItems(): CartItem[] {
  const raw: CartItem[] = JSON.parse(j) as CartItem[]
  const result: CartItem[] = []
  const seen = new Set<number>()

  raw.forEach((r: CartItem) => {
    let inst = itemCache.get(r.cartItemId)
    if (inst === undefined) {
      inst = toCartItem(r)
      itemCache.set(r.cartItemId, inst)
    } else {
      // 命中缓存:只同步字段值,保持同一实例(关键:不 new)
      inst.selected = r.selected
      inst.quantity = r.quantity
      // ...
    }
    seen.add(r.cartItemId)
    result.push(inst)
  })

  // 回收已删除项的实例,避免缓存无限增长
  itemCache.forEach((_v, k) => { if (!seen.has(k)) itemCache.delete(k) })

  return result
}

改完之后,三段探针全部打齐,UI 正常刷新,且因为 key 稳定、组件不重建,图片不再闪烁。

七、@ObjectLink 生效的完整检查清单

把这次的坑沉淀成一张清单。每一条都是「不满足就静默失效」,而且不会有任何报错提示,这是最坑的地方。

#条件违反后果
1类型必须是 @Observed 修饰的 class,不能用 interface拿不到观测能力
2JSON.parse 产物必须 new 成实例普通对象无观测能力
3更新必须就地改属性it.x = v),不能 map 出新对象替换换引用 = 同步链断裂
4ForEachkey 必须稳定key 一变就销毁重建,前三条白做
5同步源必须是状态数据@State/@Link/@Provide/@Consume/@ObjectLink注册链建立不起来
6实例必须全局唯一并复用(缓存),否则"注册的实例"≠"被改的实例"通知永远送不到
7不能本地初始化、不能整体赋值、不建议用于 @Entry 组件编译报错或同步链断裂

几个容易踩的细节:

  • @ObjectLink 只能绑定数组元素,不能跨嵌套层取 g.items[x]。两层结构需要先拍平成一维,但元素必须还是同一批实例,不能拷贝
  • @ObjectLink 装饰的变量不能本地初始化@ObjectLink item: X = new X() 编译报错),也不能整体赋值(会打断同步链并报运行时错误)。
  • @ObjectLink 不建议用在 @Entry 组件中,编译会告警——它应该放在子组件里,由父组件传入。
  • @Watch@ObjectLink 可以叠用@ObjectLink @Watch('onChanged') item: X,这是官方示例过的合法写法。

八、方法论:这三条比结论更重要

1. 优先加日志,别猜

这次最关键的转折点不是某个技术点,而是停止推测、改埋探针。三段日志一跑,"数据层正确、通知未送达"立刻定位到问题层面。

build()ForEach 的 itemGenerator 里不能写 console.log(会报 Only UI component syntax can be written here.)。所以探针要放在:普通方法体内、事件回调的箭头函数内、或 @Watch 回调里。

2. 证伪一个方案前,先核对它的先决条件是否都已满足

这是这次最大的教训。我在两轮里都把"配置没配到位"误判成了"框架不支持":

  • 第一次:用了 interface、更新时换了对象引用、key 还拼了状态摘要——三条都不满足
  • 第二次:同步源是普通方法临时返回的数组、实例每次重新 new——又是两条不满足

两次都不是 @ObjectLink 本身的问题。如果不能确认先决条件已全部满足,那次"验证失败"就是无效实验,不能作为结论。

3. 区分「控制渲染刷新」和「状态管理刷新」

这是两套独立机制,很容易混:

  • key 机制决定组件是否被复用/重建——它管"组件要不要重来";
  • 状态管理决定组件内部要不要重绘——它管"值变了要不要更新"。

我第一轮只靠 key 拿到的"能刷新",是拿重建换来的,所以必然闪。正确做法是:key 稳定 + 状态管理负责刷新

九、完整的数据流设计

最终落地的结构(Store 层用 static class,无第三方状态库):

用户点击
  │
  ├─ 子组件 @ObjectLink item 直接读 this.item.xxx
  │   回调把 id 抛给父组件
  │
  ├─ Store.toggleSelect(id)
  │   ├─ 调后端接口
  │   └─ mutateItem() 就地改实例属性   ← 通知从被观测实例发出
  │
  ├─ writeItems() 序列化回 AppStorage
  │
  └─ 父组件 refreshDerived()
      ├─ 重建 @State rows(数组容器新引用)
      └─ 元素仍是缓存里的同一批实例    ← 关键

配合的父子组件职责划分:

职责关键点
子组件 CartItemCard只负责渲染 + 抛事件@ObjectLink item,不持有数据源
父组件 CartPage持有 @State rows 作为同步源key 用 cartItemId,保持稳定
Store数据真相源 + 实例缓存就地改属性,不换引用

十、小结

问题原因解法
UI 完全不刷新同步源不是状态数据ForEach 遍历 @State 数组
UI 完全不刷新注册的实例 ≠ 被改的实例Store 层实例缓存,同一 id 复用实例
能刷新但闪烁key 变化导致组件销毁重建key 稳定,改由状态管理驱动刷新

一句话总结:@ObjectLink 不是"声明了就生效",它依赖一条完整的注册链——同步源是状态数据、实例全局唯一、更新就地改属性、key 保持稳定。四个环节缺任何一个,都会静默失效。

而这四个环节里最反直觉的是第四个:实例必须全局唯一。因为大多数人的数据源都是 JSON.parse 出来的,天生就是"每次都是新对象"。

原创文章,作者:ECHO陈文,如若转载,请注明出处:https://www.luweipai.cn/harmony/1789092898/

  • 1