鸿蒙 ArkUI 深水区:@Watch 和 @Observed,状态变了「自动跑」+ 嵌套对象「深层重绘」

写在前面

如果你写 ArkUI 写过购物车之类的页面,大概率遇到过这两个场景:

场景一:字号有「A-/A+/还原」三个按钮,每次点击除了改字号,还要写一行日志记录「字号从 X 变到 Y」。你在三个按钮的 onClick 里各手写一遍副作用逻辑——按钮多了就漏改,设计当场逮住。

场景二:购物车列表每行是一个商品对象,对象里有 count 数量字段。你把对象数组塞给子组件渲染,用户点「+」改了 count,界面不动——因为你改的是嵌套对象的属性,ArkUI 默认不观察嵌套对象属性变化,只观察引用本身。

这是「状态变化如何响应」和「嵌套对象如何深层观察」的分水岭。鸿蒙 ArkUI 给的答案是 @Watch + @Observed/@ObjectLink——前者监听状态变自动跑逻辑,后者把嵌套对象装成「活对象」,属性变也重绘。

本文就用一个真机可跑的购物车 demo,把这两个深水区从「听名字一脸懵」讲到「下个项目直接抄」。代码托管在 AtomGit,文末有链接,真机实拍截图作证。

适合人群:写过 ArkUI、被「嵌套对象改了不重绘」折磨过的同学。
不适合人群:还在学 @State 的同学——出门左转看我的入门篇。


一、先讲清楚:@Watch 和 @Observed 到底是啥

一句话:@Watch 监听状态变自动跑一段逻辑,@Observed+@ObjectLink 让嵌套对象属性变也触发重绘。

两个都解决「状态变化的响应」,但能力不同:

装饰器 解决什么 用在哪
@Watch('cb') 状态变了自动调 cb 函数 装在 @State/@Link/@StorageLink 等状态字段上
@Observed class 装成「活对象」,属性变能触发观察 装在 class 声明上
@ObjectLink 子组件接 @Observed 对象引用,属性变重绘 装在子组件字段上

新手最容易混淆的决策:「状态变要跑逻辑」用 @Watch,「嵌套对象属性变要重绘」用 @Observed+@ObjectLink。就这么简单。


二、@Watch:状态变自动跑逻辑

最小例子:

@State @Watch('onFontScaleChange') fontScale: number = 1.0
@State lastChangeLog: string = '尚未改字号'

// fontScale 一变自动跑这里,不用在每个 onClick 里手写副作用
onFontScaleChange(): void {
  this.lastChangeLog = `字号从上次的基线变到 x${this.fontScale.toFixed(1)}`
}

用的时候只改 this.fontScale:

Button('A+').onClick(() => {
  if (this.fontScale < 2.0) {
    this.fontScale = Math.round((this.fontScale + 0.2) * 10) / 10
    // 不用在这里写 lastChangeLog 更新,@Watch 自动调 onFontScaleChange
  }
})

@Watch 的价值在于把副作用逻辑集中一处,而不是在每个改状态的地方各贴一遍。状态字段一处声明,逻辑一处写,改动只改一处。

@Watch 能监听哪些状态字段

@Watch 不挑状态装饰器,任何状态字段都能挂:

状态字段 @Watch 能挂吗
@State
@Link/@Prop/@Provide/@Consume
@StorageLink/@StorageProp
@LocalStorageLink/@LocalStorageProp

一句话:凡是状态装饰器装的字段,@Watch 都能监听


三、@Observed + @ObjectLink:嵌套对象深层重绘

@Watch 解决了「状态变跑逻辑」,但有个场景它搞不定:嵌套对象的属性变

比如购物车的商品对象:

class CartItem {
  name: string = ''
  price: number = 0
  count: number = 1    // ← 用户点 +/- 改这个
}

你把 CartItem 数组塞给子组件渲染,用户点「+」改了 item.count——界面不动。因为默认 ArkUI 只观察「items 数组引用变没变」,不观察「item.count 属性变没变」。

解法分两步:

@Observed 把 class 装成「活对象」

@Observed
class CartItem {
  name: string = ''
  price: number = 0
  count: number = 1
  CartItem() {}
  set(name: string, price: number): CartItem {
    this.name = name
    this.price = price
    return this
  }
}

@Observed 装在 class 上,这个 class 实例的属性变化就能被观察。不装就是「死对象」,属性改了不触发任何事。

@ObjectLink 让子组件接活对象引用

@Component
struct CartRow {
  // @ObjectLink 接 @Observed 装的对象引用,双向同步
  // 不装 @ObjectLink = 接的是死值,改 item.count 不重绘
  @ObjectLink item: CartItem

  build() {
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text(this.item.name).fontSize(16).fontWeight(FontWeight.Bold)
        Text(`¥ ${this.item.price} × ${this.item.count}`).fontSize(13).fontColor('#FF4D4F')
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)

      Row({ space: 8 }) {
        Button('-').onClick(() => { if (this.item.count > 0) this.item.count-- })
        Text(`${this.item.count}`).width(30).textAlign(TextAlign.Center)
        Button('+').onClick(() => { this.item.count++ })
      }
    }
    .width('100%').padding(10).backgroundColor('#FAFAFA').borderRadius(8)
  }
}

@ObjectLink 接的是 @Observed 对象的引用——属性变 = 本组件重绘 + 父级也重绘(因 @Observed 通知所有观察者)。

一个新手必踩的坑

@ObjectLink 接的字段必须声明类型是 @Observed 装的 class,不能是裸 Object:

// ❌ 错:裸 Object,@ObjectLink 不知道观察啥
@ObjectLink item: Object

// ✅ 对:接 @Observed class 实例
@Observed
class CartItem { ... }
@ObjectLink item: CartItem

@ObjectLink 要知道「这个对象有哪些属性该观察」,裸 Object 给不了这个信息,必须显式 class。


四、动手:一个购物车 demo 同台演示两个深水区

demo 做了两件事:

  1. @Watch:字号改一次,lastChangeLog 自动更新一次(不用每个按钮 onClick 里手写)
  2. @Observed+@ObjectLink:购物车列表,每行 CartRowCartItem 引用,改 count 重绘 + 父级总价也重算

4.1 @Observed class

@Observed
class CartItem {
  name: string = ''
  price: number = 0
  count: number = 1
  CartItem() {}
  set(name: string, price: number): CartItem {
    this.name = name
    this.price = price
    return this
  }
}

4.2 主页面:@Watch + 嵌套对象列表

@Entry
@Component
struct Index {
  // ① @Watch:fontScale 一变自动跑 onFontScaleChange
  @State @Watch('onFontScaleChange') fontScale: number = 1.0
  @State lastChangeLog: string = '尚未改字号'

  // ② 埌套对象数组:每个 item 是 @Observed 装的
  @State items: CartItem[] = []

  aboutToAppear(): void {
    this.items = [
      new CartItem().set('鸿蒙开发实战', 89),
      new CartItem().set('ArkUI 进阶指南', 129),
      new CartItem().set('DevEco Studio 全解', 79),
    ]
  }

  // ① @Watch 回调:fontScale 变了自动跑
  onFontScaleChange(): void {
    this.lastChangeLog = `字号从上次的基线变到 x${this.fontScale.toFixed(1)}${new Date().toLocaleTimeString()}`
  }

  build() {
    Column({ space: 14 }) {
      // ① @Watch:字号改一次 lastChangeLog 自动更新一次
      Column({ space: 10 }) {
        Text(this.lastChangeLog).fontSize(14 * this.fontScale).fontColor('#222')
          .fontWeight(FontWeight.Bold).padding(8).backgroundColor('#F0F0F0').borderRadius(6)

        Row({ space: 16 }) {
          Button('A-').width(50).onClick(() => {
            if (this.fontScale > 0.6) this.fontScale = Math.round((this.fontScale - 0.2) * 10) / 10
          })
          Button('A+').width(50).onClick(() => {
            if (this.fontScale < 2.0) this.fontScale = Math.round((this.fontScale + 0.2) * 10) / 10
          })
          Button('还原').width(70).onClick(() => { this.fontScale = 1.0 })
        }
      }

      // ② @Observed + @ObjectLink:购物车列表
      Column({ space: 10 }) {
        ForEach(this.items, (item: CartItem, idx: number) => {
          CartRow({ item: item })
        }, (item: CartItem, idx: number) => `cart-${idx}`)

        // 总价:所有 item 的 price * count 之和(item.count 改 = 父级也重算)
        Text(`总价 ¥ ${this.items.reduce((sum: number, it: CartItem) => sum + it.price * it.count, 0)}`)
          .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF4D4F').margin({ top: 8 })
      }
    }
    .padding(16).backgroundColor('#F5F6F8').height('100%').width('100%')
  }
}

4.3 子组件:@ObjectLink 接活对象

@Component
struct CartRow {
  @ObjectLink item: CartItem

  build() {
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text(this.item.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#222')
        Text(`¥ ${this.item.price} × ${this.item.count}`).fontSize(13).fontColor('#FF4D4F')
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)

      Row({ space: 8 }) {
        Button('-').width(36).onClick(() => { if (this.item.count > 0) this.item.count-- })
        Text(`${this.item.count}`).fontSize(14).width(30).textAlign(TextAlign.Center)
        Button('+').width(36).onClick(() => { this.item.count++ })
      }
    }
    .width('100%').padding(10).backgroundColor('#FAFAFA').borderRadius(8).margin({ bottom: 6 })
  }
}

五、真机实拍:两个深水区跑起来长这样

我把这个 demo 装到真机上跑(鸿蒙 6.1.1.125, API 24),下面这张是真机实拍,没有任何 P 图。

整体效果:两区块依次演示「① @Watch 字号改自动跑逻辑」「② @Observed+@ObjectLink 购物车嵌套重绘」:

@Watch/@Observed 深层状态响应 demo 真机整体效果

重点看画面:第一区块显示「字号从上次的基线变到 x1.0」(由 @Watch 自动写就,不是按钮 onClick 手写)+ A-/A+/还原三按钮;第二区块是 3 件购物车商品(每件带 +/- 计数)+ 总价 ¥ 297(每件 price × count 之和,改 count 父级总价自动重算)——这就是 @Observed+@ObjectLink 嵌套对象深层重绘的威力。


六、@Watch vs @Observed:啥时候用哪个

新手最容易纠结的问题:既然都管「状态响应」,二者咋取舍?

装饰器 解决什么 触发啥 何时用
@Watch 状态变跑一段逻辑 装饰的字段引用变 状态变要跑副作用(日志/请求/联动)
@Observed+@ObjectLink 嵌套对象属性变重绘 对象的属性变 子组件接嵌套对象,属性变要重绘

一句话决策:状态变要跑逻辑用 @Watch,嵌套对象属性变要重绘用 @Observed+@ObjectLink。两者不互斥,本 demo 就同时用。


七、常见坑(都是血泪)

症状 解法
嵌套对象改属性不重绘 界面不动 class 装 @Observed,子组件接引用装 @ObjectLink
@ObjectLink 接裸 Object 编译报错或观察不到 接的字段必须显式声明 @Observed class 类型
@Watch 回调里改同字段 死循环栈溢出 回调里别改监听字段本身,改其他状态可以
@Observed 忘装 class 子组件 @ObjectLink 重绘失败 class 声明前必须 @Observed,实例化前就装好
每个按钮 onClick 里手写副作用 漏改一处设计当场逮 副作用集中到 @Watch 回调,一处写一处改
@Watch 监听非状态字段 不触发 @Watch 只监听状态装饰器装的字段

八、完整代码仓库

本文所有代码都已托管到 AtomGit,欢迎 clone、提 issue、点 star:

🔗 仓库地址:https://atomgit.com/JaneConan/arkui-watch-observed

仓库包含:

  • 完整的「购物车字号+嵌套重绘」demo 工程
  • Index.ets 主页面(@Watch 字号监听 + @Observed 购物车列表)
  • CartRow 子组件(@ObjectLink 接活对象引用)
  • CartItem @Observed class + 工厂方法 set
  • 可直接用 DevEco Studio 打开运行

九、下一步该学什么?

跑通这个 demo 之后,你的 ArkUI 状态响应就齐了三件套:@State(基础)+ @Watch(监听)+ @Observed/@ObjectLink(嵌套)。建议按这个顺序往下:

  1. @Provide/@Consume 跨层传递:替代 props 层层透传,祖先直接给后代(我的下一篇就讲这个)
  2. @Computed 计算属性:派生状态自动重算,比手写联动逻辑声明式
  3. @ObservedV2/@ComponentV2 新装饰器体系:鸿蒙 6.1 新版状态管理,V2 比 V1 更精细
  4. 状态管理选型实战:同一需求用四种姿势写,对比哪套最适合

写在最后

@Watch@Observed 的关系,本质是**「状态变要跑啥」和「嵌套对象属性变要重绘啥」的分离**。这思想在前端圈叫 watch effect / deep reactive,在鸿蒙圈叫 @Watch/@Observed,名字不同灵魂相通。

一旦你开始用响应式思维写状态,你会发现大部分「改状态联动副作用」「嵌套对象重绘」的需求,都是装饰器声明的自然结果。代码量少一半,bug 少九成,设计逮不住你漏改副作用。

代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手改一个 @Watch 回调试试。

跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀


作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-watch-observed
协议:Apache-2.0,随便用,别告我

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐