ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS ArkUI V2状态管理:计算属性、监听器与属性级追踪

HarmonyOS ArkUI V2状态管理:计算属性、监听器与属性级追踪 上篇把Local、Param、Event这些V2状态管理的基础玩法过了一遍。说实话刚切到这套新写法的时候我的感觉只是“名字变了、少写了不少Prop/Link”。真正让我觉得V2状态管理“值得迁移”是在把计算属性、监听器和属性级追踪用进真实业务之后——这三个能力直接改变了我组织页面数据的方式。这篇下篇就把这些进阶用法和我在生产环境里踩过的坑一起整理出来给正在从V1往V2迁移、或者已经在用V2但总觉得哪里不太对劲的同学作个参考。1. V2装饰器全谱对齐别把新名字当旧瓶装新酒在聊进阶能力之前先把V2状态管理的装饰器地图摆出来。很多人从V1迁移过来容易陷入一个误区Local不就是State改名Param不就是Prop改名吗名字是像但底层设计思路完全是两回事。1.1 组件内状态Local、Param、Once的职责边界Local负责组件自己持有的本地状态对应V1里的State。但它和State有一个关键差异Local只做引用级观察。什么意思如果你直接给Local修饰的数组执行push()UI是不会有反应的必须整体替换引用比如this.list [...this.list, item]刷新才会发生。V1里State对数组方法有特殊处理所以很多老代码习惯了this.list.push(item)迁移到V2之后发现页面不动第一反应是框架坏了。其实不是V2把这件事做“严”了要响应式就明确替换引用或者干脆用后面要讲的ObservedV2类来做属性级追踪。Param接收父组件传进来的参数对应V1的Prop。它有两个重要的修饰搭档Once和Require。Once的意思是“初始化一次后续父组件再改也不影响子组件”很适合那些只在创建时需要传入、之后完全由子组件自己维护的数据。Require则强制父组件必须传这个参数编译期就能拦住漏传的问题。我在项目里有一个经验凡是页面级组件的配置项默认都加上Require宁可编译时报错也不要运行时拿空值去渲染。1.2 跨组件通道Provider、Consumer、Event怎么配合V2里跨多级组件传值首选Provider和Consumer替代V1的Provide/Consume。用法上思路一致祖先组件提供数据子孙组件声明消费中间组件完全不用透传。V2版本对依赖收集做了优化只有真正声明了Consumer的组件才会建立关联理论上比V1的关联更精准。Event是V2补齐的一个能力专门用来把“子组件要改父组件数据”这件事从“传回调函数”变成一种装饰器语法。父组件把修改动作封装成方法通过Event传给子组件子组件直接调用。数据流是单向的父传子走Param子反馈父走Event。这套组合用顺手之后你会发现自己写组件对外接口的思维会变清晰——先想清楚哪些是参数、哪些是事件而不是一把梭传个对象进去然后在里面七改八改。1.3 类层面的追踪ObservedV2和Trace的分工V2最核心的升级在类观察这块。V1时代用Observed装饰类、ObjectLink承接对象本质上还是“对象级”观察对象里任何一个属性变了所有引用了这个对象的组件都会重新渲染。V2的ObservedV2装饰类、Trace装饰类里面的属性把观察粒度精确到了“属性级”。这个点我会在第四章重点展开这里先记住一句话在V2里响应式的粒度不再归组件管而是归数据本身管。2. Computed计算属性依赖收集与缓存机制实测Computed是V2里我非常喜欢的一个装饰器它可以加在getter上把派生数据变成响应式缓存。2.1 为什么普通getter撑不住复杂页面在没有Computed的年代处理“根据状态算出来的展示值”通常有两种做法要么在build()里写一个普通函数算要么把结果存成一个Local变量然后手动同步。第一种的问题是每次状态变化触发渲染这个函数都会被重新执行哪怕它依赖的数据根本没变如果计算里还嵌套了遍历、字符串拼接、正则匹配页面上只要是引用这个函数的地方都会重复计算帧率就容易被拖垮。用Local手动同步的问题是你得自己在所有可能影响结果的地方调用同步逻辑漏一个就是线上Bug。我自己之前写购物车结算金额就干过这种事——商品数量改了没调用合计方法用户看到的金额还是旧的这种问题排查起来非常痛苦。2.2 Computed的依赖收集规则Computed的机制很有意思它在getter执行的时候会自动记录你访问了哪些V2状态变量只有这些被依赖的变量变化了它才重新计算并通知UI刷新。我写一个实际例子这是我在项目里用的购物车结算逻辑简化版ComponentV2 struct CartSummary { Local items: CartItem[] []; Local selectedIds: string[] []; Computed get selectedCount(): number { return this.selectedIds.length; } Computed get selectedTotalPrice(): number { let total 0; for (const item of this.items) { if (this.selectedIds.includes(item.id)) { total item.price * item.count; } } return total; } build() { Column() { Text(已选 ${this.selectedCount} 件) Text(合计¥${this.selectedTotalPrice}) } } }两个Computed各自维护自己的依赖表。selectedCount只依赖selectedIds购物车商品列表变化时它不会重新计算selectedTotalPrice依赖items和selectedIds两个变量任一个变化都会触发重算。这就是和普通getter最大的区别——普通getter无法知道自己依赖了谁只能无脑重算Computed把“算”这件事变得精准。另外注意一个细节Computed的结果本身也是一个响应式变量可以被Monitor监听。比如你可以写Monitor(selectedTotalPrice)在金额变化时打点或者弹优惠提示这种利用方式在V1里几乎没法做得这么干净。2.3 三个容易踩的坑第一个坑不要在Computedgetter里做副作用。有次我在里面顺手写了一个this.visitCount结果页面明明没有数据变化这个值却被加了多次。原因在于Computed的执行时机由框架调度某些场景下可能多次执行来收集依赖副作用就会被重复触发。后来我的规矩很死Computedgetter必须是纯函数只算值不改变任何状态。第二个坑它不支持异步。getter里不能写async/await也不要把异步结果挂上去。真有异步计算需求老老实实拆成Monitor触发回调在里面算完了再赋给Local。第三个坑注意依赖的“可见性”。getter里如果掉了一个方法这个方法内部访问的V2状态变量有时是能收集到的有时却会漏这取决于调用链是否在同一个执行上下文中。为了不赌这个行为我的做法是把计算逻辑所有读写都写在getter内部或者抽成私有纯函数并把状态作为参数传进去不要让getter通过函数闭包偷偷摸内部状态。3. Monitor监听器从“事后通知”到“主动接管”Monitor是V2里替代V1Watch的新一代监听装饰器。第一次用的时候我还在想这不就是换个名字吗实际用下来差别大得离谱。3.1 Monitor与Watch的核心差异Watch在V1里只能监听已被V1装饰器修饰的变量而且只能观察到“变量整体被替换”这种粗粒度变化。比如你有一个State user: UserWatch能捕捉到this.user newUser但捕捉不到this.user.name 新名字。这在对象嵌套比较深的业务里几乎等于没有监听能力你只能自己想办法在每次修改属性后额外调一个通知方法。Monitor可以直接监听嵌套路径。我列个对比表格能力V1 WatchV2 Monitor监听变量整体替换支持支持监听对象属性变化不支持支持如Monitor(user.name)监听数组长度变化不支持支持如Monitor(list.length)同时监听多个变量不支持支持如Monitor(a, b)配合Computed不支持支持这个差异直接改变了我的编码习惯。以前要“监听某个字段变化”得在修改字段的地方强行插入通知代码现在完全可以在组件里声明式地写清楚。3.2 监听路径的实战写法监听对象嵌套属性的写法是这样的ObservedV2 class Address { Trace city: string ; Trace street: string ; } ComponentV2 struct OrderPage { Local address: Address new Address(); Monitor(address.city) onCityChanged() { // 城市变了可能需要重新拉取区县列表 console.log(city changed to ${this.address.city}); } Monitor(address.street, address.city) onAddressChanged() { // 城市或街道变了更新配送预估 } }注意这里的Address类是用ObservedV2和Trace修饰的。也就是说Monitor的嵌套监听能力是建立在类属性被Trace追踪的基础上的。如果你的类没有加ObservedV2/Trace属性变化本身就不会产生响应式事件Monitor自然也就监听不到。这个依赖关系搞清楚之后很多“为什么我的Monitor不触发”的问题其实一眼就能看出来。数组场景也有一个很实用的写法监听长度。Local cartList: CartItem[] []; Monitor(cartList.length) onCartCountChanged() { // 购物车角标更新 }这个监听不关心数组里具体哪个元素变了只关心数量变化特别适合列表数据驱动的红点、角标、统计数字这类UI。要监听具体某个下标元素可以写成Monitor(cartList[0].count)不过下标监听在增删元素时会比较敏感我自己用的不多更推荐把元素交给子组件各自处理。3.3 实战表单联动与防抖一个我近期做过的例子登录页用户名校验和密码强度提示联动。密码输入框绑一个Local password然后Monitor(password) validatePassword() { const pwd this.password; if (pwd.length 0) { this.passwordTip ; } else if (pwd.length 6) { this.passwordTip 密码至少6位; } else if (!/[A-Z]/.test(pwd)) { this.passwordTip 建议包含大写字母; } else { this.passwordTip ✓; } }这个写法比V1里在onChange回调里手动调校验方法清晰很多因为校验逻辑不再依赖具体输入框组件的事件而是绑定在数据变化上。数据从哪来不重要网络请求回填也好、用户手动输入也好只要password变了一定校验。一个防抖的补充经验如果监听回调里有接口请求建议在回调里自己加防抖因为Monitor是同步触发的输入场景下每次按键都会回调。我通常会在回调里setTimeout并用一个Local记录定时器ID组件销毁时在aboutToDisappear里清掉。V2没有专门为Monitor提供防抖机制这一点只能自己处理。4. ObservedV2/Trace属性级追踪终于能精确刷UI了这一章是V2状态管理里我觉得最值回票价的部分。4.1 V1时代对象更新的尴尬V1时代用State user: User保存一个对象即使只改了user.name所有读取了user的组件都会因为“对象引用”或者“对象级观察”而刷新。页面小的时候无所谓页面大了之后一个属性变化引发一片组件重建掉帧就是这么来的。用ObjectLink能缓解一点但对象的嵌套一深还是容易整个组件重跑。V2的思路换了观察粒度直接下沉到属性。ObservedV2 class UserInfo { Trace nickName: string 匿名用户; Trace level: number 1; Trace vip: boolean false; }组件里这样消费ComponentV2 struct ProfileView { Local user: UserInfo new UserInfo(); upgrade() { this.user.level 1; } build() { Column() { Text(this.user.nickName) Text(等级${this.user.level}) Text(this.user.vip ? 会员用户 : 普通用户) } } }当upgrade()执行时只有读取了user.level的那个Text节点会发生更新另外两个Text完全不动。这就是属性级追踪每个UI节点维护的是对具体属性的依赖而不是对组件的依赖。这种能力在V1里需要很小心地拆分组件才能近似做到在V2里几乎是默认行为。4.2 属性级追踪的生效边界有几点边界必须说清楚。第一Trace只对属性变化生效整体替换对象引用仍然会让依赖该对象的所有节点刷新。比如this.user new UserInfo()那所有引用this.user的地方都要重建这和V1没有区别。第二UI上必须直接访问属性路径才能建立精确依赖。如果你在build里写this.getDisplayName()而这个方法里访问了this.user.nickName依赖能否被正确收集取决于这个方法的调用是否在UI计算上下文里。我自己测试下来大部分情况下能收集但为了保险起见复杂展示逻辑我尽量用Computed包一层让依赖收集变得明确。第三嵌套类也要各自用ObservedV2。比如UserInfo里有一个Address类型的属性Address类自己也要装饰ObservedV2并且里面的字段用Trace标记才能对user.address.city这种路径做属性级追踪。只在外层类加装饰器内层对象的变化是感知不到的。4.3 序列化、深拷贝与响应式丢失的教训这个坑我从线上项目里踩出来的比较值得说。有段时间我从服务端拉用户信息然后想都没想用JSON.parse(JSON.stringify(...))把数据复制了一份再赋给Local user。结果页面渲染正常但后续所有对user.level的修改都不触发UI刷新了。原因很简单JSON.stringify会把Trace属性变成普通数据字段还原出来的是一个普通对象压根没有响应式能力。正确的做法是保留类实例用工厂方法或构造函数重建字段ObservedV2 class UserInfo { Trace nickName: string ; Trace level: number 0; static fromJson(json: Recordstring, Object): UserInfo { const user new UserInfo(); user.nickName json.nickName as string; user.level json.level as number; return user; } }然后在网络回调里用this.user UserInfo.fromJson(json.data);这样既完成了数据填充又保住了Trace的响应式能力。另外还要注意一个细节不要直接把响应式类实例塞进某些持久化存储或全局Store里。之前我图省事把ObservedV2对象直接写进AppStorage后来取出时发现行为变得很奇怪。我现在的做法是持久化时只存普通JSON或字段值读取时用工厂方法重建类实例。这个习惯帮我省掉了大量“数据恢复后页面不刷新”的排查时间。5. 性能优化和几个生产级实践建议V2的响应式粒度已经比V1精细很多但“能用”和“用得好”之间还是有不少经验要沉淀。这里把我压箱底的几条建议列一下。5.1 列表场景的局部刷新拆解列表是移动端性能的试金石。V2里如果你把整个列表数据放在父组件每一项的内容直接用父组件的状态渲染那么任何一项数据变化整个ForEach可能都会重新计算。破解办法是拆把列表项抽成独立的ComponentV2子组件每一项数据通过Param传进去。ComponentV2 struct TodoItem { Param todo: Todo; Once index: number 0; build() { Row() { Text(${this.index 1}. ${this.todo.title}) } } } Builder function todoItemBuilder(todo: Todo, index: number) { TodoItem({ todo: todo, index: index }) }父组件里的ForEach只负责生成子组件不再直接渲染列表项内容。这样哪一个TodoItem里的Trace属性变了就只有那一个子组件内的节点刷新兄弟组件完全不受影响。这个拆法在V1里也能做但V2配合属性级追踪之后效果差距会非常明显。5.2 持久化与启动恢复的联动注意如果你的应用启动时需要从本地持久化恢复数据切记不要直接把恢复出来的普通对象赋给Local或Param开头的类类型状态。我遇到过一个典型情况用户设置里有一个主题色字段第一次启动是默认值用户改成深色后我把它存了本地第二次启动我读出来赋给Local themeColor页面确实显示了深色但用户再去设置页切换主题色时UI怎么都不刷新。最后定位到问题我从持久化读出来的是一个纯字符串赋给Local后它虽然能渲染却没有建立起后续的响应式依赖。解决办法是不要直接操作持久化原始值而是把它作为初始化参数经过类工厂方法重建一遍确保后续修改都发生在响应式的轨道上。5.3 我目前的生产级配置建议项目跑了一段时间我把V2状态管理的使用规范固定成了下面这套数据模型全部用ObservedV2类 Trace属性无论层次多深都按这个规矩写。组件对外参数用Param必传项加Require只初始化一次的加Once。子组件要通知父组件变更统一走Event不要在子组件里直接通过Consumer改全局数据否则数据流会变得不可追踪。派生展示数据一律用Computed不手动同步。跨层共享数据用Provider/Consumer但使用次数要克制。我给自己定的底线是超过三层且多个页面共同依赖的状态才考虑Provider/Consumer否则优先用Param和Event把数据流显式串起来。如果非要说一个最重要的心得体会那就是V2状态管理的设计目标是把响应式能力从组件层下沉到数据层所以你的代码思路也要跟着变先设计数据模型再设计组件结构。我在刚迁移那会儿还带着V1的惯性拿到需求先画组件树结果数据流怎么摆都别扭。后来改成先设计ObservedV2模型、确定哪些字段需要Trace、哪些派生值需要Computed再回过头来画组件整个项目的状态关系就清爽很多了。如果你正在把老项目往V2迁移我的建议是不要一上来就重写页面挑一个数据模型比较清晰、状态联动比较多的模块先试水把模型层全部改成ObservedV2类页面层逐步替换装饰器。跑通一个模块之后再做下一个你会发现迁移的收益在第一个模块就能体验到——因为最直观的变化就是改一个字段UI不再连带刷新一片了。
返回列表