状态管理 V2 迁移实录:@ObservedV2 与 @Trace 如何治好「改了数据界面不刷新」

举报
yd_274993192 发表于 2026/11/10 14:34:55 2026/11/10
【摘要】 把订单模块从状态管理 V1 迁到 V2,起点只是一个「改了对象字段、UI 纹丝不动」的老 bug。本文先讲清 V1 的依赖收集边界与整体替换数组的权宜写法,再对照 @ObservedV2 + @Trace 的精确观测模型,给出一份 V1 → V2 装饰器映射表;随后整理迁移中真正会卡住人的五个点:两套装饰器不能混用、嵌套对象每一层都要打标、@Param 单向与 @Event 回写、AppStora

最近把一个跑了半年多的 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,而不是去手动清缓存。

六、怎么验证迁移没把旧功能改坏

状态迁移最怕「大面看着都对,某个角落不刷新」,所以我们没有靠肉眼翻页,而是做了三层回归:

  1. 静态核对:脚本扫描组件与装饰器组合,禁止 V1/V2 混用;同时检查嵌套模型漏标、AppStorageV2 的 key 是否来自常量表。
  2. 状态层单测:不挂 UI,直接对 OrderListState 做「改单条字段、push 新元素、splice 重载、keyword 筛选」四类操作,断言 filtered 与 total 的结果。这层能挡住大多数纯逻辑错误。
  3. UI 打点:在关键组件 build 里计数,执行同一组操作,对比刷新次数。迁完之后,「改一条备注」的刷新次数从整列表 40 多次降到 1 次,顺带把长列表滑动的掉帧也治好了——这算是意外收获。

第三层特别值得做:它既能证明精确观测真的生效,也能在后续有人不小心加回 @Once 或整数组替换时,第一时间把问题暴露在数字上。

七、什么情况下不必急着迁

V2 不是「V1 的升级补丁」,而是另一套模型。如果页面规模很小、状态全是基本类型、也没有嵌套对象,V1 完全够用,硬迁只是增加学习成本。真正值得迁的信号是这三条:出现过多处 [...arr] 骗刷新、有嵌套对象/数组的局部编辑、以及长列表里的局部更新已经影响到帧率。我们那个订单模块三条全中,所以迁完之后收益很明显;同项目里另外几个设置页就暂时留在 V1,两套模型按页面边界共存,运行得也挺好。

回到开头那个备注不刷新的 bug:它从来不是渲染引擎的问题,而是我们和框架对「什么算变化」的理解没对齐。V1 要求你把变化表达成一次引用替换,V2 允许你直接说出变的是哪个属性。理解这一点,迁移就只是照着映射表改装饰器,剩下的时间都花在那五个坑上。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。