ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Jetpack Compose与ArkUI状态管理对比:从重组到装饰器,迁移实战指南

Jetpack Compose与ArkUI状态管理对比:从重组到装饰器,迁移实战指南 从Jetpack Compose切到HarmonyOS的ArkUI干过这件事的朋友应该都有同感同样是状态驱动UI的声明式开发两边的状态管理从理念到写法完全是两个物种。Compose这边是快照系统加重组一个mutableStateOf改下去界面自动跟着变ArkUI那边则是装饰器家族大会State、Prop、Link、Provide、Consume再加上API 12之后的ObservedV2和Trace光是学这些注解就够喝一壶的。最近我在把公司的Android组件库往HarmonyOS NEXT上移植顺手把两边的状态管理做了完整的对照研究这篇文章把核心结论和实操中踩过的坑都整理出来给正准备双端开发或者正在迁移的朋友做个参考。1. 两种状态管理的地基差异重组引擎与装饰器体系1.1 Compose的总量快照系统怎么判定哪里该刷新Compose的状态管理底层是一套快照系统Snapshot System。它不是简单的观察者模式而是像给整个状态空间拍了一张又一张快照每次写入状态时快照系统会记录本次事务里的所有变更每次读取状态时系统又会悄悄记录下谁读了什么。当状态真正提交变更的那一刻运行时就能精确算出哪些读取过该状态的代码块需要重新执行。这个重新执行的过程就是重组Recomposition。Compose会把UI拆成一个个细粒度的重组作用域比如一个Column、一个Text都可能各自独立重组。你改了count只有读取了count的那个Text所在的lambda会被重新执行旁边的静态UI不会动。这种精确打击式的刷新是Compose性能设计的核心也是它跟传统View体系最大的分水岭。但快照系统不是免费的午餐。正因为它是事务式的状态的读写时机、读取位置都会直接影响重组范围。你在Composable函数里读状态和在普通函数里读状态效果完全不同——前者会建立重组依赖后者不会这就埋下了无数界面不刷新的坑。后面我会单独讲。1.2 ArkUI的漂亮装饰器如何建立观察关系HarmonyOS的ArkUI没有走快照这条路它选择的是装饰器依赖收集。你在自定义组件里声明一个State变量框架就会把这个变量和组件绑定建立细粒度的观察关系。变量一变绑定的组件或者组件里的某个UI元素就自动刷新。ArkUI的状态管理分V1和V2两代。V1就是大家熟悉的State、Prop、Link、ObjectLink、Provide/Consume这套它们基于对象级别的观察适合中小型页面。V2是从HarmonyOS NEXT SDK API 12开始推出的新一套包括ObservedV2、Trace、ComponentV2、Param、LocalV2、EventV2等它把观察粒度下沉到了类成员属性级别性能更好语法上也更接近主流声明式框架的直觉。V2的推出其实是ArkUI在被开发者吐槽装饰器规则太多之后的一次自我修正。V1时代哪些场景用State、哪些用ObjectLink、哪些必须配合Observed规则极其容易记混我身边不少同事就是从这一步开始放弃学习的。V2把对象内部属性变化也要刷新这个能力直接用Trace暴露出来心智负担小了很多。1.3 一句话总结两边的设计哲学差异Compose的思路是状态是数据流里的节点UI是状态的函数框架替你管理依赖关系你要做的是遵守重组规则ArkUI的思路是状态是组件上的装饰属性装饰器决定了它的传播范围你要做的是选对装饰器。本质上都是响应式但一个靠运行时推断一个靠声明式登记。这也是为什么跨端开发时最容易产生这代码我明明照抄了怎么就是不刷新的困惑。2. 核心API对照同一个需求在两边的标准写法2.1 组件内状态remember和State/LocalV2最简单的场景一个计数器。Compose里用的是remember加mutableStateOfComposable fun Counter() { var count by remember { mutableStateOf(0) } Column { Text(当前计数$count) Button(onClick { count }) { Text(1) } } }remember负责在重组时保留状态mutableStateOf负责创建可观察状态by委托则让你能直接读写count。这里有个关键认知如果没有remember重组时count会被重置如果不用mutableStateOf改count也不会触发UI刷新。两者缺一不可。ArkUI V1里同样的计数器是这样写的Component export struct Counter { State count: number 0 build() { Column() { Text(当前计数${this.count}) Button(1) .onClick(() { this.count }) } } }State就是ArkUI的remember mutableStateOf合体。但注意State只能观察赋值操作如果count是对象你改对象的某个属性UI大概率不会刷新这是V1最容易踩的坑。到了V2组件内状态用LocalV2ComponentV2 export struct CounterV2 { Local count: number 0 build() { Column() { Text(当前计数${this.count}) Button(1) .onClick(() { this.count }) } } }2.2 父子通信参数回调和Prop/Param的边界Compose里父传子就是普通函数参数子传父就是回调函数提升状态没有额外的绑定魔法。这是Compose最让人舒服的地方——状态提升State Hoisting写起来就是参数传递Composable fun ChildRow(count: Int, onCountChange: (Int) - Unit) { Text(子组件收到$count) Button(onClick { onCountChange(count 1) }) { Text(修改父组件状态) } } Composable fun Parent() { var count by remember { mutableStateOf(0) } ChildRow(count count, onCountChange { count it }) }ArkUI V1的父子通信就规矩多。Prop是单向传值父组件变子组件跟着变但子组件里改它不会同步回父组件Link是双向绑定两边互相影响。V2时代Param加EventV2的组合基本替代了LinkParam负责传入EventV2负责把子组件的操作抛回父组件ComponentV2 export struct ChildRowV2 { Param count: number 0 Event onCountChange: (value: number) void () {} build() { Text(子组件收到${this.count}) Button(修改父组件状态) .onClick(() { this.onCountChange(this.count 1) }) } }从工程视角看EventV2的显式回调写法比V1的Link更克制数据流向一目了然调试的时候不用满世界找到底是谁改了它。2.3 跨组件共享ViewModel/StateFlow与Provide/ProviderV2跨页面或者跨多层组件共享状态Compose社区的标准答案是ViewModel配合StateFlow再用collectAsState收进Composeclass CartViewModel : ViewModel() { private val _totalPrice MutableStateFlow(0) val totalPrice: StateFlowInt _totalPrice.asStateFlow() fun addPrice(delta: Int) { _totalPrice.value delta } } Composable fun CartScreen(viewModel: CartViewModel viewModel()) { val totalPrice by viewModel.totalPrice.collectAsState() Text(总价$totalPrice) }ViewModel解决了状态在配置变更和页面重建时的存活问题StateFlow解决的是跨协程、跨组件的可观察数据流这一套在Android生态里已经是共识。ArkUI V1的跨组件方案是Provide/Consume父组件用Provide提供任意层级的子孙组件用Consume消费Component export struct CartPage { Provide(totalPrice) totalPrice: number 0 build() { Column() { CartList() } } } Component export struct CartList { Consume(totalPrice) totalPrice: number build() { Text(总价${this.totalPrice}) } }V2对应的则是ProviderV2/ConsumerV2同时配合AppStorage和LocalStorage做全局存储。这里我的建议是页面内的共享优先用Provide/Consume真正跨页面、需要持久化的数据才上AppStorage滥用全局存储会导致状态流失控跟滥用Android里的静态变量一个后果。2.4 API映射速查表通用场景Jetpack ComposeArkUI V1ArkUI V2API 12组件内私有状态remember { mutableStateOf() }StateLocalV2父传子单向数据函数参数PropParamOnce父子双向联动回调提升状态LinkParamEventV2对象内属性变化刷新mutableStateOf保存对象ObservedObjectLinkObservedV2Trace跨层级共享ViewModel / StateFlowProvide/ConsumeProviderV2/ConsumerV2页面级全局存储SavedStateHandleAppStorageAppStorage/LocalStorage这张表建议收藏。我后来帮同事review代码发现大部分UI不刷新的问题往这张表里一套就能定位不是装饰器用错了层级就是状态对象没加观察能力。3. 实操购物车场景在Compose和ArkUI里的完整实现3.1 Compose实现状态提升derivedStateOf拿一个最常见的购物车场景练手。需求很简单商品列表每项有数量加减按钮底部显示实时总价。Compose里我推荐这么做Composable fun CartScreen() { val cartItems remember { mutableStateListOfCartItem() } // 初始数据 LaunchedEffect(Unit) { cartItems.addAll(repo.loadCart()) } val totalPrice by remember { derivedStateOf { cartItems.sumOf { it.price * it.quantity } } } LazyColumn { items(cartItems, key { it.id }) { item - CartItemRow( item item, onQuantityChange { newQuantity - val index cartItems.indexOfFirst { it.id item.id } if (index 0) { cartItems[index] cartItems[index].copy(quantity newQuantity) } } ) } } Text(总价$totalPrice) }这里有几个刻意设计mutableStateListOf让列表在增删改时能精确触发列表项重组totalPrice用derivedStateOf派生而不是单独维护一个总和变量这样总价永远是列表数据的投影不会出现列表改了总价没改的脏数据问题CartItemRow内部用局部remember保存输入框的临时编辑态把真正的数量状态留在父级这就是典型的状态提升。3.2 ArkUI V1实现Observed ObjectLink同样逻辑ArkUI V1的标准做法是把商品类标记为Observed列表项接收ObjectLinkObserved export class CartItem { id: string name: string price: number quantity: number constructor(id: string, name: string, price: number, quantity: number) { this.id id this.name name this.price price this.quantity quantity } } Entry Component export struct CartPage { State cartItems: CartItem[] [] aboutToAppear(): void { this.cartItems repo.loadCart() } build() { Column() { List({ space: 12 }) { ForEach(this.cartItems, (item: CartItem) { ListItem() { CartItemRow({ item: item }) } }, (item: CartItem) item.id) } CartFooter({ items: this.cartItems }) } } } Component export struct CartItemRow { ObjectLink item: CartItem build() { Row() { Text(this.item.name) Button(-) .onClick(() { if (this.item.quantity 0) { this.item.quantity-- } }) Text(${this.item.quantity}) Button() .onClick(() { this.item.quantity }) } } }这套写法里CartItem因为被Observed修饰它的属性变化对ObjectLink修饰的CartItemRow是可见的所以点加减按钮时这一行的Text会刷新。这里我必须强调一个V1的高频坑ObjectLink不能修饰普通对象必须配合Observed类才能工作。很多人直接在组件里ObjectLink一个interface类型的字段编译能过但运行起来界面纹丝不动。3.3 ArkUI V2实现ObservedV2 TraceAPI 12之后同样的购物车可以写得更直接ObservedV2 export class CartItemModel { Trace quantity: number 1 name: string price: number 0 constructor(name: string, price: number, quantity: number) { this.name name this.price price this.quantity quantity } } ComponentV2 export struct CartItemRowV2 { Param item: CartItemModel Event onQuantityChange: (delta: number) void () {} build() { Row() { Text(this.item.name) Button(-) .onClick(() { this.onQuantityChange(-1) }) Text(${this.item.quantity}) Button() .onClick(() { this.onQuantityChange(1) }) } } } Entry ComponentV2 export struct CartPageV2 { Local cartItems: CartItemModel[] [] build() { Column() { List({ space: 12 }) { ForEach(this.cartItems, (item: CartItemModel) { ListItem() { CartItemRowV2({ item: item, onQuantityChange: (delta: number) { item.quantity delta } }) } }, (item: CartItemModel) item.id) } } } }V2的核心变化是Trace。原来V1里只有整对象被观察现在Trace可以精确标记到属性价格变了但数量没变UI就只刷新价格相关的Text。这种细粒度看下来跟Compose的精确重组越来越像说明两边在响应式实现上其实殊途同归。3.4 三段代码背后的状态流转差异对比这三段实现你会发现Compose把所有状态逻辑都收敛到调用方通过参数和回调往下传状态流向是单向的出了问题跟着参数链路上溯就行。ArkUI则是把状态钉在组件上父组件通过Param/EventV2把数据和事件传下去虽然也是单向的但由于装饰器的存在你必须额外关心对象是否具备可观察性。简单说Compose的隐性规则在重组时机上ArkUI的隐性规则在装饰器的选择和使用边界上两者都需要经验积累才能避开暗坑。4. 实战中踩过的坑与排查思路4.1 Compose重组范围过大和无状态读取Compose最常见的坑是第一屏性能忽好忽坏。我遇到过一个小页面顶部有个Text在循环倒计时结果整个列表跟着一起疯狂重组。查了半天问题出在列表项的lambda里不小心读取了倒计时状态导致每次tick都触发LazyColumn的全部item重组。解决方式是把倒计时Text抽成独立的Composable让重组范围收敛。另一个经典问题是状态在非Composable作用域被读取。拿State在ViewModel或者普通工具类里当普通变量读编译不会报错但Compose根本感知不到这次读取UI自然不刷新。我之前写过一段代码在协程里直接读state.value去拼字符串返回给Compose用结果数据变了界面毫无反应。正确做法是在Composable里用by或.value读取再通过remember或collectAsState把数据转换成UI依赖代码见例Composable fun BadExample(viewModel: MyViewModel) { // 错误在lambda里读取没有建立依赖 val text remember { buildText(viewModel.state.value) } } Composable fun GoodExample(viewModel: MyViewModel) { // 正确把State读取放在Composable作用域内 val state by viewModel.state val text remember(state) { buildText(state) } }4.2 ArkUI装饰器选错、深层嵌套不刷新ArkUI这边的坑主要分三类。第一类就是装饰器层级用错State包对象改属性不刷新Prop传引用类型时子组件改了也不回传。第二类是数组操作问题V1里直接this.cartItems.push(item)经常刷新不出来必须重新赋值一个新数组才能触发State的观察这个问题在V2的Local上改善不少但老代码里依然到处都是。第三类坑比较隐蔽就是深层嵌套时依赖收集失效。比如Provide在Page层Consume在第三层子组件里中间隔着一层没做任何状态透传的普通组件V1某些版本下会出现消费不到值的诡异问题。排查手段是先确认中间是否有组件对状态做了拦截或者干脆把provide/consume关系改成显式传参既清晰又稳。4.3 高频问题速查表现象可能原因解决建议Compose页面性能差滚动卡顿重组范围过大无关状态被读取拆分Composable缩小重组作用域Compose数据变了UI不动状态在非Composable作用域读取把读取移到Composable内或用collectAsStateArkUI修改对象属性界面不刷新V1下类没加Observed或用了Prop改用ObservedObjectLink或升级V2用TraceArkUI数组增删后列表不更新State数组用了push等原地操作重新构造数组整体赋值跨层组件拿不到Provide的值中间层状态透传中断改为显式参数传递或提高Provide层级页面恢复后状态全丢Compose没配rememberSaveableArkUI没落AppStorage按持久化需求选对应存储方案这表我打印了一份贴在工位旁边后来带新人基本就是对着这张表讲状态管理的第一课。5. 选型建议与个人体会5.1 项目迁移时优先看什么如果你面临的是Android往HarmonyOS迁移重点优先梳理三个东西第一跨页面共享状态都放在哪里这决定了你要不要引入AppStorage或者重写状态容器第二列表型页面多不多ForEach的key策略和LazyColumn的key语义有差异迁移后性能可能变化明显第三有没有重度依赖ViewModel scope的异步逻辑ArkUI里对应的是aboutToAppear和自定义的TaskPool生命周期位置不一样写错就会泄漏。反过来如果是从HarmonyOS往Compose走重点则是理解状态提升和remember的粒度把装饰器思维切换成函数参数思维刚开始会很不习惯但一周左右基本就顺了。5.2 最后想说的一点个人经验我个人做完这次对比最大的感受是不必纠结谁抄谁、谁更强两边都是声明式UI在各自生态里的成熟答案。Compose胜在生态成熟、资料多、工具链顺手ArkUI V2起步虽晚但设计上吸收了前者的经验加上国产终端设备的系统级联动在HarmonyOS NEXT上做体验优化有天然优势。真正值得投入时间的不是站队而是把状态驱动UI这套底层心智模型吃透。一旦你想明白了状态在哪、怎么流转、谁读取了它换到任何框架都只是换API的事。最后再分享一个小技巧遇到状态相关问题先别改代码把状态从创建到读取的传播路径画一遍90%的bug在画图过程中自己就现形了这个习惯在两端开发里都极其好用。
返回列表