一、界面不动的那个下午
去年 11 月我接手一个订单类的鸿蒙应用,compatibleSdkVersion 写着 5.0.0(12),SDK 是新的,装饰器全是旧的——@State、@Prop、@ObjectLink 那一套。前面几个扁平页面跑得挺好,直到我把「收货地址」从订单对象里拆出来,做成一个二级对象塞进 @State。改数据、打日志、console.log 打出来都对,界面纹丝不动。
我那一小时的排查顺序大概是:先怀疑是没刷新 → 加 @Observed → 没用 → 加 @ObjectLink → 部分好了 → 再嵌一层又坏 → 去翻文档 → 发现 API 12 早就把状态管理重做了一遍。这段经历后来我跟三个做鸿蒙的同行聊过,三个人都踩过同一个坑。这不是巧合,也不是谁写错了。
二、V1 的观测边界,比文档写得还窄
先说结论:V1 的观测粒度是一层,而且这一层是「属性级」而不是「对象级」。
@State 能观测到的是两种情况:整个变量被重新赋值(this.obj = newObj),以及它第一层属性的赋值(this.obj.name = 'x')。再往下就不行了——this.obj.address.city = 'x',框架根本不知道你动了东西。
@Observed + @ObjectLink 这套组合是用来补这个洞的,但补得很别扭:你必须在用到那个嵌套对象的那一级组件上加 @ObjectLink。注意这句话的主语——是视图组件,不是数据类。数据是不是可观测,由写 UI 的人决定,而写数据类的人压根不知道这件事。我见过的代码里,@ObjectLink 被加在离数据定义隔了三层的地方,换了个人接手、用另一个页面引用同一个类,一样不刷新。这不是谁写错了,这是职责放错了地方。
三、@Track 是补丁,不是解法
API 11 给了个 @Track。用法是在 @Observed 修饰的类里给部分属性加 @Track 标记,框架只观测被标记的属性,其他属性变化不触发刷新。对大对象来说这是好事,能省掉一堆无意义的刷新。
但它没解决嵌套。被 @Track 标记的属性如果本身是个对象,对象内部的字段变化照样观测不到。@Track 优化的是「刷新范围」,不是「观测深度」。这两个问题经常被混为一谈,我翻文档那会儿也没一下看明白,直到在一个 200 项的列表里做实验才确认。
四、V2 不是升级,是把问题重新定义了一遍
@ObservedV2 + @Trace 这一套(API 12 随 5.0.0(12) 一起来)做的最大改变,是把观测点从视图层挪到了数据层。你现在长这样:
@ObservedV2
class Address {
@Trace city: string = '';
@Trace detail: string = '';
}

@ObservedV2
class Order {
@Trace id: string = '';
@Trace address: Address = new Address();
}
只要在类定义的地方标了 @Trace,嵌套多深都能观测到,视图那边不需要再写任何多余的东西。这不是 API 的变化,是职责边界的变化——数据是否可观测,由定义数据的人决定。
我个人的判断是:ArkTS 把 TS 的动态性砍掉之后(不允许 any、结构化类型不生效、运行时代理能力受限),它就没法做出一套靠运行时深度拦截的响应式系统。V1 是在这个约束下硬凑出来的,V2 是承认约束之后的重设计——用编译期的装饰器去补运行时缺失的拦截能力。这是我从行为上倒推的理解,没去翻方舟运行时源码,但只有这个解释说得通。
五、一份装饰器对照表
| V1 | V2 | 关键差异 |
|---|---|---|
| @State | @Local | 只能在组件内部初始化,不再接受父组件直接传值 |
| @Prop | @Param | 单向只读,子组件改不了;要本地改配 @Once |
| @Link | @Param + @Event | 双向绑定被拆成「单向数据 + 事件回调」 |
| @Provide / @Consume | @Provider / @Consumer | 功能类似,@Consumer 支持设默认值 |
| @Watch | @Monitor | 可一次监听多个属性,回调直接给 oldValue / newValue |
| @Observed / @ObjectLink | @ObservedV2 / @Trace | 观测点从视图层移到数据层 |
| @Track | @Trace | @Trace 支持嵌套类,@Track 只解决刷新范围 |
六、迁移实操:我按这个顺序做的
- 先动数据类,不动 UI。 把所有
@Observed的类改成@ObservedV2,给需要的字段加@Trace。这一步可以整体编译通过,V1 组件照样能读@ObservedV2的数据,因为@Trace是数据层能力,V1 的状态变量能观测到它的变化。 - 按组件逐个迁。 一个
.ets文件里不能同时出现@Component和@ComponentV2,所以必须整组件地换,不能一行一行改。我的做法是从叶子组件往上换,先换没有状态依赖的纯展示组件,风险最小。 @Link全部改成@Param+@Event。 这一步最费时间。双向绑定拆成单向之后,原本「子组件改、父组件自动同步」的逻辑要显式写回调。我大概花了两天才把感觉找回来。@Watch换@Monitor。 注意@Monitor回调里不要改被监听的属性,会循环触发。我第一版就写出了死循环,日志刷屏刷到应用卡住,排查了四十分钟。@Provide/@Consume换@Provider/@Consumer。 这一对改动最小,但要注意@Consumer如果找不到上层@Provider,会走默认值而不是报错,容易埋雷。
整个过程在 12 个页面、约 2.3 万行的工程上花了六天,其中三天全耗在第 3 步。
七、两个不值得迁的信号
一是你的数据模型就是扁平的,没有三层以上嵌套,V1 完全够用,换 V2 只是给自己找活干。二是工程里大量引用了第三方 HAR,那些包内部还是 V1,你迁完之后混着跑,调试起来会很烦。
反过来,如果你的数据模型本身就是树形的(订单-商品-规格、组织-部门-人员),V2 的收益会立刻显出来。我在迁移之后做过一次对比:一个 200 项、每项带两层嵌套的列表,同一份数据下 V1 加 @Track 后单次编辑触发约 40 次组件刷新,V2 是 3 次。这个差距不是靠调优能补上的,它来自观测点位置的不同。