鸿蒙 ArkUI 深水区:@Watch 和 @Observed,状态变了「自动跑」+ 嵌套对象「深层重绘」
鸿蒙 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 做了两件事:
@Watch:字号改一次,lastChangeLog自动更新一次(不用每个按钮 onClick 里手写)@Observed+@ObjectLink:购物车列表,每行CartRow接CartItem引用,改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 购物车嵌套重绘」:

重点看画面:第一区块显示「字号从上次的基线变到 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@Observedclass + 工厂方法set- 可直接用 DevEco Studio 打开运行
九、下一步该学什么?
跑通这个 demo 之后,你的 ArkUI 状态响应就齐了三件套:@State(基础)+ @Watch(监听)+ @Observed/@ObjectLink(嵌套)。建议按这个顺序往下:
@Provide/@Consume跨层传递:替代 props 层层透传,祖先直接给后代(我的下一篇就讲这个)@Computed计算属性:派生状态自动重算,比手写联动逻辑声明式@ObservedV2/@ComponentV2新装饰器体系:鸿蒙 6.1 新版状态管理,V2 比 V1 更精细- 状态管理选型实战:同一需求用四种姿势写,对比哪套最适合
写在最后
@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,随便用,别告我
更多推荐




所有评论(0)