尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LiveData、StateFlow与Compose State:状态管理的分层之道

LiveData、StateFlow与Compose State:状态管理的分层之道 一个很常见的困惑LiveData、StateFlow、Compose State这三样东西看起来都在做“存状态、通知界面更新”于是网上出现了大量对比文章标题大多是“谁替代谁”。我刚带项目从 XML 迁到 Compose 的那段时间团队里也天天有人问既然 Compose 连状态管理都自带了ViewModel 里还写 LiveData 是不是多余我当时的回答是这个问题本身就把三者放错了比较维度。LiveData 是生命周期感知的数据容器StateFlow 是 Kotlin 协程生态里的状态持有者Compose State 是 UI 组合阶段的最小状态单元。它们不是同一条赛道上的竞品而是不同层级的分工。只有先搞清楚“某一个值在项目里到底是谁需要感知、谁需要保鲜、谁拿着它去画界面”你才能真正理解它们的区别。这篇文章我不打算复述官方文档而是从源码机制、实际选型、以及我迁移过程中踩过的坑来聊聊如果你也在纠结这三个 API 怎么用应该能省不少时间。1. 为什么读了很多对比文章还是糊涂三者是分工而不是竞品1.1 一个容易懂的分层模型我先用个生活化比喻把这套东西串起来。LiveData 像大堂门禁。它只在你“人到了公司”App 处于前台且界面可见时才把快递送到你手上你要是下班走了它就先把快件存起来等你第二天上班再给你。中间如果到了很多快件它只保证你最终拿到最新的一件不保证件件都当面签收。StateFlow 像群里置顶的公告栏。它只保存最后一条公告谁想看都能随时看到当前值它不关心你是开会还是休假也不会因为某个人暂时不看就暂停更新。想用公告栏的人需要自己负责“什么时候去瞄一眼”。Compose State 像前台白板上用铅笔写下的数字。UI 在绘制的时候会读到这个数字并且把“自己正在使用这个数字”这件事记下来一旦数字改变凡是看了它的人都要把各自负责的那一块白板重新描一遍。它服务的是“谁在依赖我进行局部重绘”这件事。这三个比喻里的对象完全不同但你都叫它们“状态”。所以混在一起纠结很正常。1.2 出现顺序决定了它们的天职Android 体系里这三个 API 并不是同一时期同时塞给开发者的。早期 View XML 时代数据从后台到页面需要一个“在界面可见时才通知、界面销毁时不泄漏”的组件Google 于是在 Architecture Components 里推出 LiveData。它不需要开发者手动管理注册和反注册Observer 跟着 LifecycleOwner 走所以非常适合 Activity/Fragment 里的 MVVM。后来 Kotlin 协程普及Flow 在异步数据流上更顺滑。但普通 Flow 是冷流每次订阅都重新跑不符合“状态需要被多个人同时读同一份”的场景于是 StateFlow 出现了它复用协程的挂起、线程调度、组合变换能力又保留了“当前值”语义。再后来 Compose 抛弃了传统 View 树采用重组机制。重组需要一个能精确感知“这个 Composable 读到了哪个值”的状态对象LiveData 和 Flow 都做不到这种细粒度订阅于是 Compose 自己实现了 Compose State底层是快照系统。看到这你应该明白这三者不是在同一个功能上比谁优而是 Android 从“View 生命周期体系”演进到“协程数据流体系”再到“声明式 UI 体系”时每一层都产生了最适合自己的状态载体。2. 先聊聊 LiveData版本号、STARTED 和多出来的那层“保护”2.1 为什么 LiveData 天生自带生命周期保护LiveData 最核心的设计是绑定了 Lifecycle。一个 LiveData 观察者注册后并不是立刻就能收到数据只有当它关联的 LifecycleOwner 至少处于 STARTED 状态时才处于活跃状态非活跃期间 LiveData 不会把事件分发给它。从源码角度看observe(LifecycleOwner, Observer)创建的是LifecycleBoundObserver它实现了LifecycleEventObserver监听ON_START和ON_STOP。当宿主从 STARTED 变成 STOPPED观察者从活跃集合中“暂时移出”等界面重新回到前台时它会重新同步当前最新值。所以你会看到一种经典用法一个网络请求在后台线程跑完了回调里调用了liveData.postValue(result)页面处于后台时这个方法几乎没有任何负担只是在内存里把最新值存起来。等用户切回页面onStart一触发观察者自动恢复活跃立刻收到那个最新的值。这套机制在今天看来很朴素但在 View 时代非常省心不用手动在onResume注册、onPause注销不用背“异步回调导致界面泄漏”的锅。2.2 版本号机制和“不丢最新值”的设计LiveData 的内部维护着一个自增的版本号。每次setValue或postValue版本号加一。每个 Observer 内部也保存一个mLastVersion初始为START_VERSION -1。通知发生时LiveData 会比较两者只有 Observer 的版本号小于当前 LiveData 版本号时才会真正回调。这个设计的直接收益是非活跃期间错过的数据一旦恢复活跃considerNotify会把它补上。它不会把值丢给一个“已经收过”的观察者因为版本号已经对齐。这也是很多人最开始觉得 LiveData “粘性事件”烦人的原因。一个 Observer 注册上去常常立刻收到一个上次缓存的值。哪怕它根本不想要旧数据。解决方案要么用SingleLiveEvent这种自定义封装要么换用 Flow 的事件流模型。这是 LiveData 老架构里常被吐槽的点。2.3 LiveData 没有做去重同值也会通知这点是很多人忽略的也直接引出后面 StateFlow 的差异。LiveData 在setValue(Object)时并不做equals判断。你连续setValue(3)两次只要观察者活跃就会回调两次你在子线程连续postValue(3)和postValue(5)由于postValue把数据暂存在mPendingData并通过主线程任务派发如果第一个值还没来得及派发第二个值会直接把第一个覆盖。也就是说LiveData 有“同值不去重”和“postValue 会合流丢中间值”两个特性。有一次我在做直播间的连麦状态展示业务方在一秒内连续上报同一状态不同细节期望界面逐步展示过程动画。用 LiveData 承载这种高频状态时动画频繁跳阶段最后呈现出来的并不是完整过程。本质上LiveData 被设计成“UI 展示最终状态”它是标签式的不是事件队列式的。高频且必须逐条消费的数据不适合塞进 LiveData。提示LiveData 的setValue只能在主线程调用子线程更新值必须走postValue。如果子线程高频postValue中间值可能被合并最终只能收到最后一个。需要严格序列化每次变更时LiveData 并不是最合适的容器。2.4 现在什么业务还在用 LiveData 合适技术选型没有“必须淘汰”得看上下文。XML DataBinding 项目DataBinding 天生对 LiveData 有支持布局文件里可以直接绑定不需要写订阅代码。替换成 StateFlow 需要额外 bridge收益并不大。不依赖协程的简单 View 项目既然团队没有大规模使用 Flow硬引入 StateFlow 会增加理解成本LiveData 的自动生命周期管理很香。Compose 新项目不建议把状态暴露成 LiveData原因后面讲。除非是在和老模块桥接。所以说LiveData 并不是不能存在而是在 Compose 体系下它的“生命周期保护”红利已经有了更灵活的替代方案接着往下看。3. StateFlow 真正在解决的问题把“最新状态”搬进协程生态3.1 StateFlow 的底层三件套热流、conflation、去重StateFlow 本质上是一个 SharedFlow 的配置实例把replay设为 1所以任何订阅者一进来就能拿到当前值。这意味着它天然是热流数据生产者只在一个地方更新无论有多少个订阅者收到的都是同一份内存状态。它和普通 Flow 最大的区别就是普通 Flow 是冷流。“每个收集者都会触发一遍上游执行”和“多人共享同一个状态”是完全不同的两种语义。举个例子如果用flow { emit(fetchFromNetwork()) }做状态源两个界面各自调用collect网络请求会发生两次。但把结果放进 StateFlow所有收集者共享一个结果不会重复请求。StateFlow 的更新还有一个隐性的去重判断只有当新值和旧值equals不相等时才会更新并通知收集者。这个特性很友好它避免了很多无谓的重组和通知。但坑也在这里如果你的数据对象没有正确实现equals或者你想用相同值去强刷 UIStateFlow 会直接忽略。3.2 和 LiveData 对照着看更容易理解对比维度LiveDataStateFlow生命周期感知内置观察者跟随 LifecycleOwner无内置但可用 repeatOnLifecycle 实现初值可不提供必须有初值线程约束setValue 必须在主线程postValue 异步任何线程均可安全更新同值重复赋值会通知观察者不会通知有 equals 去重配合协程需要 liveData {} 或 asLiveData 桥接原生支持挂起、flow 操作符去重丢失中间值postValue 可能合并中间值StateFlow 也有合并语义只保最新API 风格observe LifecycleOwnercollect 协程作用域这里面最直观的两个结果就是StateFlow 不限制线程且不依赖 Android 主线程消息循环所以它的更新效率和协程调度天然匹配。比方说在viewModelScope里做复杂计算最后给_state.value result赋值这个动作不需要再 post 到主线程收集方可以通过collectAsStateWithLifecycle在适合的线程里消费。另外StateFlow 必须要有初值这看起来很啰嗦其实是优点强制你给状态设计好“未加载”或“初始”的状态。如果整个项目在 UI 层对状态是穷举完整的话很难出现“某个分支忘赋值导致空指针”的脏状态。3.3 别把 StateFlow 当成“事件通道”刚切换到 StateFlow 时最常见的错误是把一次性事件也放进 StateFlow。比如弹 Toast、跳转导航、显示 Snackbar。这类事件的问题是“它发生后只需要被消费一次”。如果放进 StateFlow 这种只保存最新值的状态容器里可能出现的问题就是页面旋转后重新订阅又收到上一次事件弹出了一个不该弹的 Toast或者在事件还没来得及消费前被下一次状态更新覆盖。时间敏感的事件应该用SharedFlow或Channel并让每个订阅者自己决定如何处理缓冲值。StateFlow 的定位就是“可查询的当前状态”不是“会拉起的信号”。我这里给一个 ViewModel 的状态模板也是我在项目里最常用的一套写法class UserViewModel( private val userRepository: UserRepository ) : ViewModel() { private val _uiState MutableStateFlowUserUiState(UserUiState.Loading) val uiState: StateFlowUserUiState _uiState.asStateFlow() private val _events ChannelUserEvent(Channel.BUFFERED) val events _events.receiveAsFlow() fun refresh() { viewModelScope.launch { _uiState.value UserUiState.Loading val result userRepository.getUser() _uiState.value UserUiState.Success(result) _events.send(UserEvent.ShowToast(刷新完成)) } } }状态归状态事件归事件两者分开之后UI 侧就会非常清晰collectAsStateWithLifecycle拿状态渲染页面collect事件流做一次性反馈。3.4 用 stateIn 时需要谨慎选择 started 策略很多 Repository 层会直接提供冷 Flow比如数据库查询网络轮询。把冷 Flow 转换为 StateFlow 时最常用的方式是stateIn但它需要传入一个SharingStarted策略。SharingStarted.Eagerly在 ViewModel 创建时立刻启动收集适合必须长期保持活跃的流但也会造成根本不耗时资源的浪费。SharingStarted.Lazily第一个订阅者出现时启动但之后上游会一直续命订阅者全部取消后也不停止。SharingStarted.WhileSubscribed(5000)有订阅者时启动所有订阅者消失 5 秒后停止。这是我最常用的避免页面在后台时数据流还在空转。val currentUser: StateFlowUser? userRepository.userStream .stateIn( scope viewModelScope, started SharingStarted.WhileSubscribed(5000), initialValue null )这个 5 秒的“冷却时间”是刻意设计的如果用户只是短暂离开页面又马上回来可以避免冷流频繁重启导致的多次请求。4. Compose State 的最底层视角快照状态与重组4.1 mutableStateOf 不是又一个“可观察对象”Compose State 的底层并不是普通的观察者模式而是一套基于快照的系统。我们在 Composable 里调用val count remember { mutableStateOf(0) }当读取count.value时Compose 会把当前正在执行的组合作用域注册为这个 state 的订阅者当你往count.value 1写值时快照系统会把这一个作用域标记为“需要重组”然后在下一帧重组它。这套机制和 LiveData/StateFlow 的差别在于LiveData/StateFlow 只会通知“观察者”执行回调至于回调里你去更新哪块 UI、怎么更新它不负责。Compose State 则是直接标记“谁读过我”它在编译器插桩后可以做到极细粒度的局部重组只绘制真正受状态影响的 Composable。从这层意义上Compose State 更像 UI 渲染层自己的同步器。它并不服务于跨层数据共享只服务于当前组合树中的界面状态。4.2 直接用 Compose State 代替 ViewModel 里的状态管理有些人图省事把所有状态都放在 Composable 的remember里用mutableStateOf一把梭。这在小型局部 UI 上没问题比如展开/折叠面板、选中 tab。但你一旦把网络数据也塞进去就会遇到两个问题第一重组丢失。Composable 销毁后remember块里面保存的状态全部随之销毁。如果这个数据需要被多个界面共享或者需要跨配置变更存活就必须把状态提升到 ViewModel。第二进程重建恢复困难。rememberSaveable可以基于 Bundle 保存基本类型和可序列化对象但复杂对象比如带协程、缓存池、复杂状态机的对象不适合塞进 Bundle。真正稳定可靠的做法是 ViewModel 持有状态Compose UI 收集并展示。Compose State 的本质决定了它适合做 UI 局部状态不适合做应用级状态管理。4.3 derivedStateOf、remember 与“派生状态”Compose 有一个高频状态容易踩坑就是当一个状态可以从另一个状态计算出来却有人把它独立存了一份。比如一个列表页你只关心“列表里有没有选中的项”于是一边维护selectedIds, 一边维护hasSelection。每次操作都要同步两个状态漏一处就 bug。Compose 提供了derivedStateOf专门处理派生状态val selectedIds remember { mutableStateOf(setOfString()) } val hasSelection by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }这段代码的意思是hasSelection不是你想办法去更新的它是随着selectedIds的变化自动计算出来的。在 StateFlow 的世界里这类需求通常用map distinctUntilChanged或combine也没问题。但在 Compose 内局部 UI 状态用derivedStateOf更简洁而且它只重算真正被读取时需要的值。这里也顺带回答一个常见问题Compose 中直接用val state remember { mutableStateOf(data) }和从 ViewModel 拿 StateFlow 后collectAsState()本质区别是什么前者原地持有销毁即丢后者由 ViewModel 持有UI 只是“投影”了一份到 State 里。所以状态到底该放哪儿不是看代码好不好写而是看它该活多久。注意collectAsState()和collectAsStateWithLifecycle()在 Compose 里都有。前者只是把 Flow 的值包装成 Compose State并不感知 Lifecycle后者会结合 Lifecycle 在界面不可见时暂停收集。如果可以选择推荐使用后者。5. 新老项目选型与实操中的坑从直接改代码说起5.1 三种架构下的默认方案纯 Compose 新项目我的默认链路是Repository 返回普通 Flow 或直接返回一次性挂起函数ViewModel 内部用MutableStateFlow汇聚成 UI 状态Compose UI 用collectAsStateWithLifecycle()订阅。任何跨模块的状态都不是 LiveData也不是 Compose State而是 StateFlow。XML DataBinding 老项目同时已经引入协程的可以慢慢往ViewModel StateFlow迁移但 UI 收集侧必须自己处理好生命周期如果还没引入协程直接用 LiveData 也完全合理别为了新而新。正在把 XML 页面逐步 Compose 化的项目我建议不要一边从 LiveData 迁到 StateFlow一边又改 UI两件事同时做很容易出问题。先保持 ViewModel 层统一暴露同一类型UI 迁移到 Compose 后再动手切换订阅方式每一步都留一个可以回滚的提交。5.2 最容易“看起来没事但状态没更新”的坑我在实际项目里遇到过一个很典型的 StateFlow 去重问题。列表详情页下拉刷新后接口返回的数据对象里面内容都相同只是刷新时间变了。由于 data class 的equals把刷新时间也包含进去了照理说是能触发更新的。但如果某个版本的 data class 没有把刷新时间纳入equals即使你_state.value newDataStateFlow 判定新旧相等直接丢弃更新。界面看起来就是你明明调了刷新接口也成功了可是 UI 纹丝不动。排查了一整天才发现是去重问题。此类问题最好的解法是在 UI 状态里显式加一个refreshKey字段每次刷新都refreshKey强制产生新状态或者在 Repository 层返回的数据本身不可变但保留请求时间戳差值。还有一次团队里有人把 Compose 的局部状态理解成可以全局访问的状态结果在某个点击事件里直接改了一个顶层mutableStateOf却发现渲染层确实更新了但状态生命周期完全不受管理页面一切换就没了。这类问题的根源就是混淆了“Compose 的 remember 作用域”和“ViewModel 的应用级状态”。5.3 桥接 API 梳理实际项目中经常需要三者在边界处互相转换。先把常用桥接 API 记清楚// Flow 转 LiveData flow.asLiveData() // LiveData 转 Flow liveData.asFlow() // StateFlow 转为普通可空 Flow 的值用于只订阅一次最新值 stateFlow.take(1).toList() // Compose 中订阅 StateFlow且感知 Lifecycle lifecycle-runtime-compose 库里的 collectAsStateWithLifecycle() // Compose 中订阅 LiveData旧代码遗留 liveData.observeAsState()asLiveData()可以把 StateFlow 桥接给老页面用反过来老模块里的 LiveData 也可以通过asFlow()转成 Flow 后进 StateFlow。关键原则是桥接的代码尽量放在模块边缘不要在整个项目里到处穿插。提示LiveData 的observeAsState()在 Compose 中并不是完全免罪的。它没有把“收集”和 Lifecycle 自动挂钩后台时依然在维持订阅。所以在有状态流订阅需求时优先考虑把上游改成 StateFlow 再collectAsStateWithLifecycle()这是 Compose 官方在实践层面也推荐的方向。5.4 如果让我重做一次技术选型给一个简化决策表你正在做的场景我推荐使用的状态载体界面的一次性事件Toast、导航Channel / SharedFlow跨越模块的共享业务状态StateFlowViewModel 对 UI 暴露的当前状态StateFlow Compose 层 lifecycle 收集XML 页面 DataBindingLiveData单页面内的开关、展开收起Compose State依赖其他 state 自动计算的 UI 状态derivedStateOf / StateFlow.map distinctUntilChanged这套组合并不死板核心是生命周期感知不一定非要靠 LiveData 内置Flow 配合repeatOnLifecycle同样能做Compose State 也不是银弹它只管理当前组合树。如果你现在正处在一个老项目迁 Compose 的过程中我给你最实际的建议是先把 ViewModel 层全部统一为 StateFlow让 UI 侧可以在一两行代码内切换collectAsStateWithLifecycle和observeAsState。然后再逐步替换 UI 层。避免“数据流方案”和“UI 层方案”同时变动排查问题会容易得多。最后说一句经验之谈状态容器没有绝对的好坏只有放在哪个层级合不合适。搞清楚 LiveData、StateFlow、Compose State 的职责边界之后你再看那些“谁替代谁”的文章基本就不会再被带偏了。
返回列表