鸿蒙状态管理 V1 用了 8 个月我全部推倒重写:@ObservedV2 + @Trace 到底修好了什么

🔑 关键词:鸿蒙开发,ArkTS,状态管理,ObservedV2,Trace

📖 摘要:从 V1 里 @ObjectLink 嵌套属性不刷新的老坑讲起,拆解 @ObservedV2 与 @Trace 的观测机制差异,附 6 步迁移清单、V1/V2 混用的两个哑火陷阱,以及 2000 条列表的实测数据。

鸿蒙状态管理 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;



![图片](http://img1.baidu.com/it/u=3759266164,4256206991&fm=253&fmt=auto&app=138&f=JPEG?w=750&h=500)


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 上,我给你我跑通的顺序:

  1. 数据类先改。@Observed 换 @ObservedV2,把真正会被修改的属性加 @Trace。不用全加,全加有内存开销,但也不要漏——漏一个就是三小时的 debug。
  2. 组件换基座。@Component 换成 @ComponentV2。注意这俩不能继承互换,一个页面里混着写迟早出事。
  3. @State → @Local。语义基本一致,都是组件内部状态,都不能从父组件传进来初始化。
  4. @Prop → @Param。如果这个值只在初始化时用一次、父组件后续的变化你根本不想跟,那就写成 @Param @Once。@Param 是引用传递,不再深拷贝,列表里差别很明显。
  5. @Link 双向的拆成一对。子组件声明成 @Param value: string 加 @Event onChange: (v: string) => void,父组件把回调传进来。写起来啰嗦一点,但数据流向从此是清楚的。
  6. @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 的手感——那些深拷贝在这里是纯粹的浪费。

🏷️ 标签: