从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加mutableStateOf:
@Composable 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,组件内状态用@LocalV2:
@ComponentV2 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的组合基本替代了@Link,@Param负责传入,@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收进Compose:
class CartViewModel : ViewModel() { private val _totalPrice = MutableStateFlow(0) val totalPrice: StateFlow<Int> = _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 Compose | ArkUI V1 | ArkUI V2(API 12+) |
|---|---|---|---|
| 组件内私有状态 | remember { mutableStateOf() } | @State | @LocalV2 |
| 父传子单向数据 | 函数参数 | @Prop | @Param+@Once |
| 父子双向联动 | 回调提升状态 | @Link | @Param+@EventV2 |
| 对象内属性变化刷新 | mutableStateOf保存对象 | @Observed+@ObjectLink | @ObservedV2+@Trace |
| 跨层级共享 | ViewModel / StateFlow | @Provide/@Consume | @ProviderV2/@ConsumerV2 |
| 页面级全局存储 | SavedStateHandle | AppStorage | AppStorage/LocalStorage |
这张表建议收藏。我后来帮同事review代码,发现大部分"UI不刷新"的问题,往这张表里一套就能定位:不是装饰器用错了层级,就是状态对象没加观察能力。
3. 实操:购物车场景在Compose和ArkUI里的完整实现
3.1 Compose实现:状态提升+derivedStateOf
拿一个最常见的购物车场景练手。需求很简单:商品列表,每项有数量加减按钮,底部显示实时总价。Compose里我推荐这么做:
@Composable fun CartScreen() { val cartItems = remember { mutableStateListOf<CartItem>() } // 初始数据 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,列表项接收@ObjectLink:
@Observed 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 + @Trace
API 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内,或用collectAsState |
| ArkUI修改对象属性界面不刷新 | V1下类没加@Observed,或用了@Prop | 改用@Observed+@ObjectLink,或升级V2用@Trace |
| ArkUI数组增删后列表不更新 | @State数组用了push等原地操作 | 重新构造数组整体赋值 |
| 跨层组件拿不到@Provide的值 | 中间层状态透传中断 | 改为显式参数传递或提高Provide层级 |
| 页面恢复后状态全丢 | Compose没配rememberSaveable,ArkUI没落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在画图过程中自己就现形了,这个习惯在两端开发里都极其好用。