鸿蒙状态管理 V1 用了 8 个月我全部推倒重写:@ObservedV2 + @Trace 到底修好了什么
去年三月接的这个项目,客户要求原生 ArkTS 写,不许 Flutter 套壳也不许 RN 转译。我当时想,TypeScript 我写了六年,ArkTS 无非是加了点装饰器的 TS,能有多难。结果光状态层我前后重写了两次:第一次是排查一个嵌套数据不刷新的 bug 花了三天,第二次是知道 V2 之后把整个项目 47 个页面、12 个 @Observed 类全迁过去。
这篇不吹也不黑,就把我实际踩过的坑和最后怎么解的写下来。
先说那个让我查了三天的 bug
场景特别简单,订单列表每一项展开显示收货地址。我最初是这么写的:
@Observed
class Address {
city: string = '';
}
@Observed
class Order {
id: string = '';
address: Address = new Address();
}
@Component
struct OrderItem {
@ObjectLink order: Order;

build() {
Column() {
Text(this.order.id).fontSize(16)
Text(this.order.address.city).fontSize(14)
}
}
}
然后我在页面里执行 this.orders[0].address.city = '杭州',UI 纹丝不动。
但是改 this.orders[0].id 就刷得飞快,替换整个 this.orders[0].address = new Address() 也刷。就是改孙子的属性不刷。
翻了两遍官方文档才看明白:@Observed 是给类加了一层代理,@ObjectLink 订阅的是这个代理对象的第一层属性。order.address 这个引用本身没变——地址还是那个地址对象,只是里面的 city 变了——所以订阅收不到通知。
官方的解法是给 Address 也套一层组件,用 @ObjectLink 接过去:
@Component
struct AddressView {
@ObjectLink address: Address;
build() { Text(this.address.city) }
}
能用。但一个真实的订单模型有 4 层嵌套,你就得拆 4 个只有几行的组件出来,纯粹为了刷新。而且哪个字段挂在哪一层,改需求的时候得一层层对。我数过,光这个页面最后堆了 9 个 @ObjectLink。
这还不是最烦的。真正要命的是它不报错,只是不刷新。编译器不管你,控制台也不吭声。
V1 的根本问题:它观测的是变量,不是数据
我后来想明白了,V1 这套设计的核心是「组件视角」——@State、@Prop、@Link 装饰的都是组件里的一个变量,框架盯着这个变量有没有被重新赋值。
所以 this.count = 5 能触发,this.obj = newObj 能触发,但 this.obj.a.b = 5 触发不了,因为对 V1 来说 this.obj 这个变量压根没动过。
@Observed + @ObjectLink 算是打了个补丁,但它只补一层。深一层就得再套一层组件,本质上是让你用组件树的层级去模拟数据的层级。数据结构和组件结构一旦对不上,就开始哑火。
@Prop 还有个更隐蔽的成本:它是值拷贝。传一个复杂对象进去,父组件每更新一次就深拷一次。我们那个订单对象带 30 多个字段,列表滚动时能明显感觉到掉帧。
V2 换了个方向。@ObservedV2 装饰的类,配合 @Trace 标记属性,框架直接在属性层面做劫持——你改到第几层它就观测到第几层,不需要靠组件层级去搭桥。
@ObservedV2
class Address {
@Trace city: string = '';
}
@ObservedV2
class Order {
@Trace id: string = '';
@Trace amount: number = 0;
address: Address = new Address();
}
写成这样之后,this.order.address.city = '杭州' 直接就刷了。上面那 9 个 @ObjectLink 全删掉,AddressView 那个组件也不用要了。
我实际迁移的 6 个步骤
如果你现在也在 V1 上,我给你我跑通的顺序:
- 数据类先改。
@Observed换@ObservedV2,把真正会被修改的属性加@Trace。不用全加,全加有内存开销,但也不要漏——漏一个就是三小时的 debug。 - 组件换基座。
@Component换成@ComponentV2。注意这俩不能继承互换,一个页面里混着写迟早出事。 @State→@Local。语义基本一致,都是组件内部状态,都不能从父组件传进来初始化。@Prop→@Param。如果这个值只在初始化时用一次、父组件后续的变化你根本不想跟,那就写成@Param @Once。@Param是引用传递,不再深拷贝,列表里差别很明显。@Link双向的拆成一对。子组件声明成@Param value: string加@Event onChange: (v: string) => void,父组件把回调传进来。写起来啰嗦一点,但数据流向从此是清楚的。@Watch→@Monitor。@Monitor 支持路径写法,可以一次盯好几个:
@Monitor('user.address.city', 'user.name')
onUserChange(mon: IMonitor) {
console.info(`${mon.value()?.before} -> ${mon.value()?.now}`);
}
另外 V2 还多了个 @Computed,写计算属性用的,依赖不变的话不会重复算:
@Computed
get total() {
return this.items.reduce((sum, i) => sum + i.price, 0);
}
文档没写清楚、但我踩到了的几个点
第一,替换整个对象引用时,父级那个属性也要加 @Trace。 比如 this.order.address = new Address(),如果你只给 @Trace city 加了标记,address 本身没加,这次替换是不刷新的。很多人栽在这。
第二,@Trace 装饰数组时只观测数组结构。 push、pop、splice 这类能触发,数组元素的属性变化则要求元素类型本身也是 @ObservedV2 且属性有 @Trace。别指望数组一层装饰就全管了。
第三,V1 和 V2 混用是静默失效。 我在迁移过程中试过偷懒——父组件还是 @Component,子组件换成 @ComponentV2,结果数据不刷新,编译也不报错。反过来也一样:@ObservedV2 的类在 V1 的 @ObjectLink 里是接不住的。迁移得整条链路一起改。
第四,ArkTS 里没有 null 和 undefined 类型。 你写 let x: undefined 编译不过,得写成联合类型。可选参数一多,判空代码能把人写烦,@Param 传可选值时尤其明显。
我这边实测的数字
都是 Release 模式,Mate 60 Pro,DevEco Studio 5.0.3.900,同一个订单列表页:
- 2000 条数据用
ForEach全量渲染,首帧 1.1 秒左右。 - 换成
LazyForEach配 IDataSource,加.cachedCount(3),首帧 210ms。 - 迁移 V2 之后,滚动帧率从 47fps 提到 58fps 上下。改动主要是干掉了 9 个 @ObjectLink 订阅和 @Prop 的深拷贝。
cachedCount 这个参数我建议默认值别用,它保守得有点过分,给 3 到 5 更稳。
最后说点我自己的看法
V1 到 V2 不是功能增强,是范式换了。V1 是「以组件为中心」观测变量,V2 是「以数据为中心」观测属性。这个转变意味着你项目里状态层的组织方式得跟着动,光把装饰器名字替换一遍是没用的——嵌套那层该不刷还是不刷,然后你就开始骂鸿蒙不行。
还有个反直觉的点:@Trace 这套属性级脏检查,让 React 那套不可变数据流的必要性消失了。React 里你得手动保证每次更新都产生新引用,不然 shouldComponentUpdate 拦不住。ArkTS V2 你直接改对象属性就行,框架自己知道哪儿变了。所以从 React 转过来的人,反而要克制一下到处 clone 的手感——那些深拷贝在这里是纯粹的浪费。