三月初我在做雷达鸭鸿蒙版的瀑布流列表页。需求不复杂每个分类下面挂着一堆案例卡片用户可以点收藏切换状态。数据结构天然是嵌套的——Category 套 CaseItem跟俄罗斯套娃似的。我心想这不就是 Observed ObjectLink 的标准场景吗官方示例就是这么写的。十分钟撸完代码信心满满地按了运行。然后我盯着模拟器看了足足三秒。收藏按钮点了控制台 log 显示isFavorited确实从false变成了true。但按钮上的文字纹丝不动。绿的还是绿的灰的还是灰的。我重启了模拟器。重启了 DevEco Studio。重启了电脑。没用。好了下面就是我一头扎进这个坑里两天出不来的全过程。第一天的挣扎疯狂试错出问题的代码简化后大概是这样的——一个分类列表每个分类里有若干个可收藏的案例条目ObservedclassCaseItem{id:numbertitle:stringisFavorited:booleanconstructor(id:number,title:string,isFavorited:boolean){this.ididthis.titletitlethis.isFavoritedisFavorited}}ObservedclassCategory{name:stringcases:CaseItem[]constructor(name:string,cases:CaseItem[]){this.namenamethis.casescases}}EntryComponentstruct CategoryList{Statecategories:Category[][newCategory(独立开发者,[newCaseItem(1,靠 Notion 模板月入 2 万,false),newCaseItem(2,一个 API 卖了三年,true)])]build(){List(){ForEach(this.categories,(cat:Category){ListItem(){CategoryCard({category:cat})}})}}}Componentstruct CategoryCard{ObjectLinkcategory:Categorybuild(){Column(){Text(this.category.name).fontSize(18).fontWeight(FontWeight.Bold).padding(12)ForEach(this.category.cases,(item:CaseItem){Row(){Text(item.title).layoutWeight(1)Button(item.isFavorited?已收藏:收藏).onClick((){item.isFavorited!item.isFavoritedconsole.log(clicked, new value:,item.isFavorited)})}.padding(8)})}}}看着挺正常对吧两个类都加了Observed子组件用ObjectLink接住父组件传下来的 Category 对象。Button 的 onClick 里直接改item.isFavorited。我心想这跟 Vue 的点击切换状态一模一样怎么可能会出错。但事实就是——控制台 log 打出来值是翻转了UI 上 Button 的文字就是不动。我的第一反应是装饰器写漏了。反复检查了三遍Observed在 CaseItem 上Observed在 Category 上ObjectLink在子组件里——全部到位。然后我开始试各种可能性把ObjectLink改成Prop——编译器直接报错因为 CaseItem 是自定义类Prop 只支持基础类型和字面量对象。给 CaseItem 的字段加State——也不行State 只能用在 Component struct 里往普通 class 上加直接编译不过。给ForEach加第三个参数keyGenerator——没用的这跟 key 无关底层是响应式链没通。把item.isFavorited !item.isFavorited换成先读再赋两个独立语句——结果一样UI 不动。搞到晚上十一点我想放弃了。我承认我一开始写得很蠢——我以为 ArkTS 的Observed跟 Vue 的reactive()一样改嵌套对象的任意层属性 UI 都会自动刷新。第二天早上翻源码找到根因睡了五个小时醒来第一件事不是刷牙是打开电脑重新读ObjectLink的文档。这一次我不看示例代码只盯着一句话读了三遍——官方文档在Observed那一节的末尾用一行灰色小字写着“ObjectLink 只能观察到被装饰对象的属性变化无法观察到属性内部更深层的变化。”说白了ObjectLink category: Category追踪的是 Category 这个引用本身以及它的直接属性。this.category.name变了UI 会刷新。但this.category.cases[0].isFavorited——你改的是 CaseItem 的属性CaseItem 虽然挂在category.cases这个数组里但 ObjectLink 的追踪链路止于 Category不会自动往数组元素的子属性深处延伸。打个比方ObjectLink 像是在 Category 这层装了一个传感器你去碰 Category 的name字段传感器响了。你去碰cases数组里第 0 个元素的isFavorited传感器的探头根本够不着那么远——中间隔了数组引用和对象引用两层。你猜怎么着这个信息在官方文档里确实写了但藏在一段话的末尾字号小得几乎看不见。你第一次读文档大概率会跳过去因为前面大段的示例代码让你以为加了 Observed 就能用。三种修法我选了最后一种根因理清楚之后修法其实有三种思路。我全试了一遍优劣如下。方案一把 ObjectLink 打到叶子节点最直接的做法——把 CaseItem 单独拆成一个子组件让它自己用 ObjectLink 链接 CaseItemComponentstruct CaseItemRow{ObjectLinkitem:CaseItembuild(){Row(){Text(this.item.title).layoutWeight(1)Button(this.item.isFavorited?已收藏:收藏).onClick((){this.item.isFavorited!this.item.isFavorited})}.padding(8)}}然后在 CategoryCard 里这样用ForEach(this.category.cases,(item:CaseItem){CaseItemRow({item:item})},(item:CaseItem)item.id.toString())这个方案粒度最细每个 CaseItem 有独立的 ObjectLink改哪个刷新哪个。但它有个让我很烦的问题如果你的列表里每个 item 有四五种操作收藏、删除、编辑、置顶……每种操作都意味着在 CaseItemRow 里多加一个 handler 回调或者事件。组件树越拆越碎调试的时候在 DevEco Studio 的 Component Tree 面板里找半天找不到目标组件。我个人特别讨厌这种为了框架约束被迫拆组件的感觉——组件拆分应该跟着业务语义走不应该跟着装饰器的追踪范围走。方案二整体替换对象引用不直接改属性而是用一个新的 CaseItem 对象替换数组里原来的位置.onClick((){constidxthis.category.cases.indexOf(item)if(idx!-1){constupdatednewCaseItem(item.id,item.title,!item.isFavorited)this.category.cases.splice(idx,1,updated)}})原理很简单splice修改了category.cases这个数组本身ObjectLink 发现 Category 的cases属性变了触发整个 ForEach 重建。因为数组引用变了框架走的是替换渲染而非局部更新。能用但在 200 条数据的列表里点一下收藏splice触发整列重建肉眼可见的卡顿。方案二只适合列表很短的场景数据一多就是在给自己埋性能雷。方案三父组件 State 接管变更子组件纯展示这是我想了两天最后选的方案。思路是放弃让 ObjectLink 追踪到叶子节点的期望改由最顶层 Entry 组件的 State 统一驱动状态变更子组件只负责展示和回调EntryComponentstruct CategoryList{Statecategories:Category[][newCategory(独立开发者,[newCaseItem(1,靠 Notion 模板月入 2 万,false),newCaseItem(2,一个 API 卖了三年,true)])]toggleFavorite(caseId:number){this.categoriesthis.categories.map(cat{constnewCasescat.cases.map(c{if(c.idcaseId){returnnewCaseItem(c.id,c.title,!c.isFavorited)}returnc})returnnewCategory(cat.name,newCases)})}build(){List(){ForEach(this.categories,(cat:Category){ListItem(){CategoryCard({category:cat,onToggle:(id:number)this.toggleFavorite(id)})}})}}}Componentstruct CategoryCard{ObjectLinkcategory:CategoryonToggle:(id:number)void(){}build(){Column(){Text(this.category.name).fontSize(18).fontWeight(FontWeight.Bold).padding(12)ForEach(this.category.cases,(item:CaseItem){Row(){Text(item.title).layoutWeight(1)Button(item.isFavorited?已收藏:收藏).onClick((){this.onToggle(item.id)})}.padding(8)},(item:CaseItem)item.id.toString())}}}核心是这一行this.categories this.categories.map(...)。map返回一个全新的数组State 发现引用变了通知所有依赖它的子组件重建。跟方案二一样是整体替换区别在于变更逻辑集中在入口组件的toggleFavorite里CategoryCard 不需要关心状态是怎么变的——它只负责回调事件。数据流是单向的出 bug 的时候排查链路也短。性能方面我在 200 条数据下实测方案三比方案一ObjectLink 拆到叶子节点慢了大约 8%但因为 ArkTS 的 diff 算法只重建实际变化的 ListItem这个差距在日常使用中几乎感觉不到。而代码的可维护性好了太多——如果让我重来我会直接放弃方案一那种把 Observed 嵌套到底的设计。回过头来看这件事ArkTS 的状态管理装饰器体系本质上是编译时的约束框架跟 Vue 的运行时 Proxy 响应式是两套完全不同的东西。你拿 Vue 的思维往 ArkTS 里套迟早会撞上我以为会刷新但它不刷新的墙。在这个 bug 上浪费的两天让我学会了一件事ObjectLink 是浅追踪不是深响应。它追踪的是你传进去的那个对象引用本身——引用的直接属性变了它能感知但属性里再嵌套的对象的属性变了信号就丢了。以后在 ArkTS 里做嵌套数据的 UI我默认会用方案三父组件集中管理状态变更子组件只拿数据不写数据。坦白讲ArkTS 这套装饰器体系设计得不算差但文档在关键约束上写得过于含蓄。等鸿蒙 Next 出来希望官方能把追踪链路的边界条件讲得更清楚一些别让开发者靠踩坑来理解框架。关于作者老三10 年软件开发老兵软件设计师注册人工智能工程师agent 工程师。最近几年重心在鸿蒙 ArkTS 北向开发和 Web 前端之间来回跳偶尔折腾 AI 自动化不定期在 CSDN 分享踩坑笔记。本文遵循 MIT 协议转载请注明出处。
@Observed 写了,@ObjectLink 也加了,ArkTS 页面就是不动——我 debug 两天找到了原因
三月初我在做雷达鸭鸿蒙版的瀑布流列表页。需求不复杂每个分类下面挂着一堆案例卡片用户可以点收藏切换状态。数据结构天然是嵌套的——Category 套 CaseItem跟俄罗斯套娃似的。我心想这不就是 Observed ObjectLink 的标准场景吗官方示例就是这么写的。十分钟撸完代码信心满满地按了运行。然后我盯着模拟器看了足足三秒。收藏按钮点了控制台 log 显示isFavorited确实从false变成了true。但按钮上的文字纹丝不动。绿的还是绿的灰的还是灰的。我重启了模拟器。重启了 DevEco Studio。重启了电脑。没用。好了下面就是我一头扎进这个坑里两天出不来的全过程。第一天的挣扎疯狂试错出问题的代码简化后大概是这样的——一个分类列表每个分类里有若干个可收藏的案例条目ObservedclassCaseItem{id:numbertitle:stringisFavorited:booleanconstructor(id:number,title:string,isFavorited:boolean){this.ididthis.titletitlethis.isFavoritedisFavorited}}ObservedclassCategory{name:stringcases:CaseItem[]constructor(name:string,cases:CaseItem[]){this.namenamethis.casescases}}EntryComponentstruct CategoryList{Statecategories:Category[][newCategory(独立开发者,[newCaseItem(1,靠 Notion 模板月入 2 万,false),newCaseItem(2,一个 API 卖了三年,true)])]build(){List(){ForEach(this.categories,(cat:Category){ListItem(){CategoryCard({category:cat})}})}}}Componentstruct CategoryCard{ObjectLinkcategory:Categorybuild(){Column(){Text(this.category.name).fontSize(18).fontWeight(FontWeight.Bold).padding(12)ForEach(this.category.cases,(item:CaseItem){Row(){Text(item.title).layoutWeight(1)Button(item.isFavorited?已收藏:收藏).onClick((){item.isFavorited!item.isFavoritedconsole.log(clicked, new value:,item.isFavorited)})}.padding(8)})}}}看着挺正常对吧两个类都加了Observed子组件用ObjectLink接住父组件传下来的 Category 对象。Button 的 onClick 里直接改item.isFavorited。我心想这跟 Vue 的点击切换状态一模一样怎么可能会出错。但事实就是——控制台 log 打出来值是翻转了UI 上 Button 的文字就是不动。我的第一反应是装饰器写漏了。反复检查了三遍Observed在 CaseItem 上Observed在 Category 上ObjectLink在子组件里——全部到位。然后我开始试各种可能性把ObjectLink改成Prop——编译器直接报错因为 CaseItem 是自定义类Prop 只支持基础类型和字面量对象。给 CaseItem 的字段加State——也不行State 只能用在 Component struct 里往普通 class 上加直接编译不过。给ForEach加第三个参数keyGenerator——没用的这跟 key 无关底层是响应式链没通。把item.isFavorited !item.isFavorited换成先读再赋两个独立语句——结果一样UI 不动。搞到晚上十一点我想放弃了。我承认我一开始写得很蠢——我以为 ArkTS 的Observed跟 Vue 的reactive()一样改嵌套对象的任意层属性 UI 都会自动刷新。第二天早上翻源码找到根因睡了五个小时醒来第一件事不是刷牙是打开电脑重新读ObjectLink的文档。这一次我不看示例代码只盯着一句话读了三遍——官方文档在Observed那一节的末尾用一行灰色小字写着“ObjectLink 只能观察到被装饰对象的属性变化无法观察到属性内部更深层的变化。”说白了ObjectLink category: Category追踪的是 Category 这个引用本身以及它的直接属性。this.category.name变了UI 会刷新。但this.category.cases[0].isFavorited——你改的是 CaseItem 的属性CaseItem 虽然挂在category.cases这个数组里但 ObjectLink 的追踪链路止于 Category不会自动往数组元素的子属性深处延伸。打个比方ObjectLink 像是在 Category 这层装了一个传感器你去碰 Category 的name字段传感器响了。你去碰cases数组里第 0 个元素的isFavorited传感器的探头根本够不着那么远——中间隔了数组引用和对象引用两层。你猜怎么着这个信息在官方文档里确实写了但藏在一段话的末尾字号小得几乎看不见。你第一次读文档大概率会跳过去因为前面大段的示例代码让你以为加了 Observed 就能用。三种修法我选了最后一种根因理清楚之后修法其实有三种思路。我全试了一遍优劣如下。方案一把 ObjectLink 打到叶子节点最直接的做法——把 CaseItem 单独拆成一个子组件让它自己用 ObjectLink 链接 CaseItemComponentstruct CaseItemRow{ObjectLinkitem:CaseItembuild(){Row(){Text(this.item.title).layoutWeight(1)Button(this.item.isFavorited?已收藏:收藏).onClick((){this.item.isFavorited!this.item.isFavorited})}.padding(8)}}然后在 CategoryCard 里这样用ForEach(this.category.cases,(item:CaseItem){CaseItemRow({item:item})},(item:CaseItem)item.id.toString())这个方案粒度最细每个 CaseItem 有独立的 ObjectLink改哪个刷新哪个。但它有个让我很烦的问题如果你的列表里每个 item 有四五种操作收藏、删除、编辑、置顶……每种操作都意味着在 CaseItemRow 里多加一个 handler 回调或者事件。组件树越拆越碎调试的时候在 DevEco Studio 的 Component Tree 面板里找半天找不到目标组件。我个人特别讨厌这种为了框架约束被迫拆组件的感觉——组件拆分应该跟着业务语义走不应该跟着装饰器的追踪范围走。方案二整体替换对象引用不直接改属性而是用一个新的 CaseItem 对象替换数组里原来的位置.onClick((){constidxthis.category.cases.indexOf(item)if(idx!-1){constupdatednewCaseItem(item.id,item.title,!item.isFavorited)this.category.cases.splice(idx,1,updated)}})原理很简单splice修改了category.cases这个数组本身ObjectLink 发现 Category 的cases属性变了触发整个 ForEach 重建。因为数组引用变了框架走的是替换渲染而非局部更新。能用但在 200 条数据的列表里点一下收藏splice触发整列重建肉眼可见的卡顿。方案二只适合列表很短的场景数据一多就是在给自己埋性能雷。方案三父组件 State 接管变更子组件纯展示这是我想了两天最后选的方案。思路是放弃让 ObjectLink 追踪到叶子节点的期望改由最顶层 Entry 组件的 State 统一驱动状态变更子组件只负责展示和回调EntryComponentstruct CategoryList{Statecategories:Category[][newCategory(独立开发者,[newCaseItem(1,靠 Notion 模板月入 2 万,false),newCaseItem(2,一个 API 卖了三年,true)])]toggleFavorite(caseId:number){this.categoriesthis.categories.map(cat{constnewCasescat.cases.map(c{if(c.idcaseId){returnnewCaseItem(c.id,c.title,!c.isFavorited)}returnc})returnnewCategory(cat.name,newCases)})}build(){List(){ForEach(this.categories,(cat:Category){ListItem(){CategoryCard({category:cat,onToggle:(id:number)this.toggleFavorite(id)})}})}}}Componentstruct CategoryCard{ObjectLinkcategory:CategoryonToggle:(id:number)void(){}build(){Column(){Text(this.category.name).fontSize(18).fontWeight(FontWeight.Bold).padding(12)ForEach(this.category.cases,(item:CaseItem){Row(){Text(item.title).layoutWeight(1)Button(item.isFavorited?已收藏:收藏).onClick((){this.onToggle(item.id)})}.padding(8)},(item:CaseItem)item.id.toString())}}}核心是这一行this.categories this.categories.map(...)。map返回一个全新的数组State 发现引用变了通知所有依赖它的子组件重建。跟方案二一样是整体替换区别在于变更逻辑集中在入口组件的toggleFavorite里CategoryCard 不需要关心状态是怎么变的——它只负责回调事件。数据流是单向的出 bug 的时候排查链路也短。性能方面我在 200 条数据下实测方案三比方案一ObjectLink 拆到叶子节点慢了大约 8%但因为 ArkTS 的 diff 算法只重建实际变化的 ListItem这个差距在日常使用中几乎感觉不到。而代码的可维护性好了太多——如果让我重来我会直接放弃方案一那种把 Observed 嵌套到底的设计。回过头来看这件事ArkTS 的状态管理装饰器体系本质上是编译时的约束框架跟 Vue 的运行时 Proxy 响应式是两套完全不同的东西。你拿 Vue 的思维往 ArkTS 里套迟早会撞上我以为会刷新但它不刷新的墙。在这个 bug 上浪费的两天让我学会了一件事ObjectLink 是浅追踪不是深响应。它追踪的是你传进去的那个对象引用本身——引用的直接属性变了它能感知但属性里再嵌套的对象的属性变了信号就丢了。以后在 ArkTS 里做嵌套数据的 UI我默认会用方案三父组件集中管理状态变更子组件只拿数据不写数据。坦白讲ArkTS 这套装饰器体系设计得不算差但文档在关键约束上写得过于含蓄。等鸿蒙 Next 出来希望官方能把追踪链路的边界条件讲得更清楚一些别让开发者靠踩坑来理解框架。关于作者老三10 年软件开发老兵软件设计师注册人工智能工程师agent 工程师。最近几年重心在鸿蒙 ArkTS 北向开发和 Web 前端之间来回跳偶尔折腾 AI 自动化不定期在 CSDN 分享踩坑笔记。本文遵循 MIT 协议转载请注明出处。