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的包装类会将自己注册给@Observedclass,即向实例提供自身引用,让实例把它加入依赖列表。
属性更新:当@Observedclass 属性改变时……遍历依赖它的@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 | 拿不到观测能力 |
| 2 | JSON.parse 产物必须 new 成实例 | 普通对象无观测能力 |
| 3 | 更新必须就地改属性(it.x = v),不能 map 出新对象替换 | 换引用 = 同步链断裂 |
| 4 | ForEach 的 key 必须稳定 | 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/