状态管理 V2 迁移实录:@ObservedV2 与 @Trace 如何治好「改了数据界面不刷新」
最近把一个跑了半年多的 HarmonyOS 订单模块从状态管理 V1 迁到 V2。起因是个很小的 bug:用户在列表里点「修改备注」,数据层明明把 order.remark 改了,界面上那行文字却纹丝不动,退出页面重进才刷新。查到最后,问题不在 ArkUI 的渲染,而在我们用 @State 装了一个 Order[],改的却是数组里某个对象的属性。
这类「改了不刷新」几乎是每个 ArkTS 开发者都要上的一课。V1 时代的标准解法是整体替换数组来骗过依赖收集,V2 才从模型层面把它解决掉。这篇按一次真实迁移的顺序,把 V1 的观测边界、V2 的精确观测、装饰器映射、五个容易翻车的点,以及一套可以照着抄的状态类封装整理出来。文中的代码基于 API 12 及之后的状态管理 V2 能力,具体装饰器名称请以所用 SDK 版本为准。
一、先弄清楚 V1 到底能观测到什么
V1 的依赖收集是按「变量」而不是按「属性路径」做的。组件里的 @State 变量被赋值时,框架知道它变了;但如果变量指向一个对象或数组,你只改它内部的字段,变量本身的引用没变,框架就收不到通知。
@Observed
class Order {
id: string = '';
remark: string = '';
amount: number = 0;
}
@Entry
@Component
struct OrderPage {
@State orders: Order[] = [];
build() {
List() {
ForEach(this.orders, (o: Order) => {
ListItem() {
Text(o.remark) // 改 o.remark 不会刷新这一行
}
}, (o: Order) => o.id)
}
}
updateRemark(id: string, text: string) {
const hit = this.orders.find(x => x.id === id);
if (hit) hit.remark = text; // 无效:引用没变
}
}
很多人以为加了 @Observed 就万事大吉,其实 @Observed 只负责给类加上被观测的能力,真正接收属性级通知的是子组件里的 @ObjectLink。也就是说,V1 里想让「数组元素属性变化」驱动刷新,只有两条路:
- 拆子组件:把 ListItem 抽成独立组件,用
@ObjectLink order: Order接收,父组件仍然靠@State管数组结构变化。 - 整体替换:改完字段后重新构造数组,让
@State看到一次新引用,也就是当年随处可见的this.orders = [...this.orders]。
第二条写起来快,代价不小:列表稍长就会带来整轮 diff,LazyForEach 的键值生成器也会跟着重跑;而且它把「业务上改了一个字段」硬编码成「结构上换了一整个数组」,任何忘记拷贝的地方都会埋下一个不刷新的 bug。我们那个订单模块里,一共有 7 处这样的浅拷贝,其中 2 处漏了。
二、V2 换了一个思路:从「变量」到「属性路径」
V2 不再要求你替换引用。@ObservedV2 装饰类,@Trace 装饰真正需要驱动 UI 的属性,框架在读写这些属性时做细粒度依赖收集:哪个组件读了 order.remark,就只把那个组件标脏。
@ObservedV2
class Order {
@Trace id: string = '';
@Trace remark: string = '';
@Trace amount: number = 0;
// 纯派生或缓存字段不打 @Trace,避免无意义刷新
cachedLabel: string = '';
}
@ComponentV2
struct OrderPage {
@Local orders: Order[] = [];
updateRemark(id: string, text: string) {
const hit = this.orders.find(x => x.id === id);
if (hit) hit.remark = text; // 现在会精确刷新读了 remark 的那个 ListItem
}
}
这里有两个值得停下来看一眼的设计。第一,只有打了 @Trace 的属性才参与观测,这既是性能开关,也是意图声明:一个 20 字段的模型里,通常只有三四个字段真的会出现在 UI 上。第二,V2 对 Array、Map、Set、Date 做了内置代理,所以往 @Local 数组里 push 一条记录,不需要再手动赋一次新数组。这两条合起来,前面那 7 处浅拷贝可以全部删掉。
三、装饰器映射表:迁之前先对好号
迁移不是把 @State 全局替换成 @Local 就完事,语义差别比名字差别大:
| V1 | V2 | 语义变化 |
|---|---|---|
| @State | @Local | 组件内部状态;@Local 不允许父组件初始化,必须就地给初值 |
| @Prop | @Param | 单向传入;V2 不再做深拷贝,复杂对象传的是同一份被代理实例 |
| @Link | @Param + @Event | 取消双向绑定,子组件改父状态要显式回调 |
| @Observed | @ObservedV2 | 配合 @Trace 才能生效,粒度从「对象」细化到「属性」 |
| @ObjectLink | @Param(接收被观测实例) | 不再需要专门的链接装饰器 |
| @Provide / @Consume | @Provider / @Consumer | 跨层传递,V2 需要显式 key 或类型匹配 |
| @Watch | @Monitor | @Monitor 可一次监听多个属性路径,回调拿到变更路径集合 |
| AppStorage / LocalStorage | AppStorageV2 / PersistenceV2 | 按「类型 + key」连接,不再是字符串路径读写 |
| 无 | @Computed / @Once | 新增计算属性缓存与「仅同步一次」 |
最需要注意的一行是 @Link:V2 里没有等价的双向装饰器。这是刻意的设计——双向绑定让「谁改了这份数据」变得难以追踪。迁移时不要试图用 @Param 直接改父状态,正确姿势是配一个 @Event 回调。
四、五个真正会卡住人的坑
坑 1:两套装饰器不能在同一个组件里混用
@ComponentV2 里不能出现 @State / @Prop / @Link,@Component 里也不能用 @Local / @Param。编译期就会报错,但它们互相传递数据时未必报错,只是静默地不刷新:一个 V1 组件把 @ObservedV2 实例传给 V2 子组件,或反过来,依赖收集链路直接断掉。
我们的做法是按页面整体推进,而不是按组件逐个改:先把一个完整页面的组件树全部换成 V2,边界处用普通参数(非状态装饰器)传值,验证通过再迁下一页。中间态越少,越不容易出现「看起来没报错但就是不刷新」的幽灵问题。
坑 2:嵌套对象的每一层都要打标
@Trace 只管一层。订单里有 customer: Customer,如果 Customer 没有 @ObservedV2、它的 name 没有 @Trace,那么改 order.customer.name 依然不刷新——哪怕 Order 本身写得完全正确。
@ObservedV2
class Customer {
@Trace name: string = '';
@Trace level: number = 0;
}
@ObservedV2
class Order {
@Trace id: string = '';
@Trace customer: Customer = new Customer(); // 内外两层都标
@Trace items: OrderItem[] = []; // 元素类型同样要被观测
}
我们专门写了个检查脚本,扫描模型目录里所有被 @ObservedV2 装饰的类,报告「属性类型是自定义 class 但该 class 未装饰」的情况,迁移当天就揪出 3 处漏标。这种纯静态的核对,比在真机上翻页找不刷新的字段快得多。
坑 3:@Param 是单向的,@Once 用错会「看起来像丢了更新」
子组件里 @Param order: Order 表示父组件传入,只允许父侧更新。如果业务需要子组件编辑后回写,配 @Event:
@ComponentV2
struct RemarkEditor {
@Param order: Order = new Order();
@Event onRemarkChange: (id: string, text: string) => void = () => {};
build() {
TextInput({ text: this.order.remark })
.onChange((v: string) => this.onRemarkChange(this.order.id, v))
}
}
@Once 则是「只同步一次,之后父组件再改也不跟」。它适合把初始配置传进子组件,但如果你拿它传一个会持续变化的列表项,就会得到「第一屏是对的,后面全不动」的现象——这个表现和 V1 的不刷新几乎一模一样,很容易往错误方向排查。遇到「只有首屏正确」,先查 @Once。
坑 4:AppStorageV2 / PersistenceV2 的连接语义与落盘时机
V2 的全局状态是按「类型 + key」连接的,同一个 key 拿到的是同一份实例,工厂函数只在首次创建时执行:
@ObservedV2
class SessionState {
@Trace token: string = '';
@Trace uid: string = '';
}
// 任一组件内
const session = AppStorageV2.connect(SessionState, 'session', () => new SessionState());
session.token = newToken; // 所有 connect 到 'session' 的组件同时刷新
两个细节值得注意。其一,connect 的 key 和类型要成对稳定,拼写不一致不会报错,只会拿到一份全新的空状态,表现为「登录态莫名其妙丢了」。我们把 key 集中成常量,禁止散落字符串。其二,PersistenceV2 的写盘有自己的节流与时机,不要假设「赋值即落盘」:关键数据在页面退出、应用切后台等节点显式调用保存接口,并在冷启动测试里验证真的读回来了。这一条和当年 Preferences 的坑本质相同,只是换了一层封装。
坑 5:@Monitor 里改被监听属性会形成循环
@Monitor 用来替代 @Watch,可以一次监听多个路径,回调里能拿到这次变脏的属性集合:
@ComponentV2
struct SummaryBar {
@Local order: Order = new Order();
@Computed get totalLabel(): string {
return '合计 ' + this.order.amount.toFixed(2);
}
@Monitor('order.amount')
onAmountChanged(monitor: IMonitor) {
// 只做副作用:打点、拉接口、写日志
reportAmountChange(monitor.dirty);
}
}
踩过的坑有两个:在 @Monitor 回调里反过来写 order.amount,会再次触发监听,轻则多跑一次,重则循环;在 @Computed 里做埋点或写状态,则因为计算属性带缓存,执行次数和渲染次数并不一致,统计出来的数据会对不上。约定很简单:@Computed 只做纯计算,副作用一律放进 @Monitor。另外 @Monitor 的触发是批处理后的,不要在回调里假设 UI 已经完成刷新。
五、一套可以直接抄的列表状态类
把「列表结构 + 单条更新 + 派生统计」收口到一个被观测的状态类里,组件只负责读和触发,迁移后的代码比原来那 7 处浅拷贝干净得多:
@ObservedV2
class OrderListState {
@Trace items: Order[] = [];
@Trace loading: boolean = false;
@Trace keyword: string = '';
@Computed get filtered(): Order[] {
if (!this.keyword) return this.items;
return this.items.filter(o => o.remark.includes(this.keyword));
}
@Computed get total(): number {
return this.items.reduce((sum, o) => sum + o.amount, 0);
}
async reload(fetcher: () => Promise<Order[]>) {
this.loading = true;
try {
const data = await fetcher();
this.items.splice(0, this.items.length, ...data); // 原地改,UI 照样刷新
} finally {
this.loading = false;
}
}
patchRemark(id: string, text: string) {
const hit = this.items.find(o => o.id === id);
if (hit) hit.remark = text; // 不再需要 [...items] 骗刷新
}
}
组件侧只需要 @Local state: OrderListState = new OrderListState(),然后读 state.filtered 和 state.total。注意 @Computed 依赖的是 @Trace 属性,缓存会自动失效;如果哪天发现「筛选结果不更新」,第一反应应该是检查 keyword 和 items 是否都打了 @Trace,而不是去手动清缓存。
六、怎么验证迁移没把旧功能改坏
状态迁移最怕「大面看着都对,某个角落不刷新」,所以我们没有靠肉眼翻页,而是做了三层回归:
- 静态核对:脚本扫描组件与装饰器组合,禁止 V1/V2 混用;同时检查嵌套模型漏标、AppStorageV2 的 key 是否来自常量表。
- 状态层单测:不挂 UI,直接对
OrderListState做「改单条字段、push 新元素、splice 重载、keyword 筛选」四类操作,断言filtered与total的结果。这层能挡住大多数纯逻辑错误。 - UI 打点:在关键组件 build 里计数,执行同一组操作,对比刷新次数。迁完之后,「改一条备注」的刷新次数从整列表 40 多次降到 1 次,顺带把长列表滑动的掉帧也治好了——这算是意外收获。
第三层特别值得做:它既能证明精确观测真的生效,也能在后续有人不小心加回 @Once 或整数组替换时,第一时间把问题暴露在数字上。
七、什么情况下不必急着迁
V2 不是「V1 的升级补丁」,而是另一套模型。如果页面规模很小、状态全是基本类型、也没有嵌套对象,V1 完全够用,硬迁只是增加学习成本。真正值得迁的信号是这三条:出现过多处 [...arr] 骗刷新、有嵌套对象/数组的局部编辑、以及长列表里的局部更新已经影响到帧率。我们那个订单模块三条全中,所以迁完之后收益很明显;同项目里另外几个设置页就暂时留在 V1,两套模型按页面边界共存,运行得也挺好。
回到开头那个备注不刷新的 bug:它从来不是渲染引擎的问题,而是我们和框架对「什么算变化」的理解没对齐。V1 要求你把变化表达成一次引用替换,V2 允许你直接说出变的是哪个属性。理解这一点,迁移就只是照着映射表改装饰器,剩下的时间都花在那五个坑上。
- 点赞
- 收藏
- 关注作者
评论(0)