1. 一次代码审查引发的“内战”:为什么StateFlow值得重新审视
大概两年前,我参与的一个项目从 LiveData 全面切换到 StateFlow,起因不是技术选型文档,而是一场代码审查。当时有个同学在 ViewModel 里把 LiveData 换成了 StateFlow,理由是“网络请求失败时需要重试,用 LiveData 做状态机很别扭”。结果在群里炸了锅:反对的意见是“LiveData 用得好好的,换什么 Flow,生命周期谁管?”支持的意见是“每次去 Fragment 里加 observer 加 removeObserver 不烦吗?”
吵到最后,我们把 Google 官方文档、CommonCents 系列博客、以及社区里那些踩坑帖子全翻了一遍,才意识到一个很根本的问题:LiveData 和 StateFlow 确实都能“保存一份状态并通知观察者”,但底层的设计基点完全不一样。LiveData 是生命周期感知框架的产物,它的核心价值是“UI 跟着生命周期自动处理订阅”;StateFlow 是协程生态里的状态容器,它的核心价值是“在无 UI 依赖的纯逻辑层也能用”,并且能与 Flow 操作符无缝衔接。
这篇文章不想帮你站队,也没有必要“非黑即白”。我更多是想把这两者在实践中真正影响写代码方式的 5 个关键区别讲透,再把我切换过程中踩过的坑列出来。如果你正在做技术选型、面试被问到这个问题、或者已经在项目里遇到 LiveData 状态混乱的痛点,这篇应该能给你一些可靠参考。
1.1 LiveData 设计之初面对的问题
LiveData 诞生于 2017 年左右。那时候 Android 开发正处在 MVP 转 MVVM 的热潮里,最常见的状态同步问题是:Activity 旋转导致 View 重建,异步回调回来时拿着旧的 View 引用直接崩掉。LiveData 的解法很聪明——它把“观察者”同 LifecycleOwner 绑定,在 STARTED 之后才回调,在销毁时自动清理。这个设计让 UI 层代码变得很干净,不用在 onDestroy 里手动反注册。
但它同时把生命周期感知逻辑写进了数据容器本身,也就是说 LiveData 从出生就离不开 android.arch.lifecycle 这一类 Android SDK 依赖。哪怕你只是想在 Utils 层定义一个状态,也不得不引一个 lifecycle-livedata-core 包,在纯 Kotlin 或 JVM 模块里用起来总有一种“越界感”。
1.2 StateFlow 从 Kotlin 协程生态里带来的能力
StateFlow 是 kotlinx.coroutines 库的一部分,本质上是 SharedFlow 的一个特殊配置:replay 为 1,没有多余的缓冲,并且默认去重。它不感知生命周期,不依赖 Android SDK,所以它可以安全地出现在 Domain 层、Data 层,甚至在单元测试里只用 StandardTestDispatcher 就能跑起来。
它的设计目标是“在协程世界里表达一种可观察的状态”:任何协程都可以通过 value 读取当前值,也可以通过 collect 挂起式地订阅每次变化。因为没有生命周期绑定,它把“什么时候观察、什么时候停止观察”的决策权完全交给了调用方。
1.3 先给结论:它们本质上是“不同维度”的东西
很多人喜欢问“LiveData 会被 StateFlow 取代吗”,我的答案在实践里越来越清晰:不是取代,而是分层。LiveData 更适合留在 UI 层做简单的视图状态绑定——如果你不想引入 reactive streams 的复杂度,它依旧好用。StateFlow 则更适合在业务层传递状态,因为它解决的问题是“数据流建模”,而不是“生命周期绑定”。
下面的 5 个关键区别,正是在这种定位差异下延伸出来的。我尽量按影响程度从大到小排列。
2. 五大关键区别逐项拆解(含代码对比)
2.1 区别一:生命周期处理从“自动降级”变成“主动声明”
这是最容易被误解的区别,也是很多刚切换的人第一个栽跟头的地方。
LiveData 的观察者是自动感知生命周期的。Activity 处于 STARTED 状态时收到事件,进入 STOPPED 状态时收不到事件,但数据不会丢失,回到前台会收到最新值。这个自动合规确实很省心,但代价是,“生命周期感知”行为是隐式的,你无法在非 UI 层复用这种观察,也不容易精确控制“我要不要接收后台变化”。
StateFlow 本身不感知任何生命周期。它只负责持有状态和向下游发射。要想让 StateFlow 的收集过程跟随界面生命周期,官方推荐的写法是在 Lifecycle.repeatOnLifecycle 块里启动协程:
class MyFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> render(state) } } } } }这段代码的含义是:界面进入 STARTED 时启动 collect,进入 STOPPED 时自动取消,再次 STARTED 时重新启动 collect。它做到了和 LiveData 类似的自动合规,但把控制权交给了开发者——如果你不写 repeatOnLifecycle,collect 就会一直运行,你在后台切出去再回来时,可能会看到一堆不该处理的回调,甚至造成不必要的资源消耗。
我给团队的建议是:把 repeatOnLifecycle 当成一个强制规范,所有 UI 层的 StateFlow 订阅必须走这个入口。虽然比 LiveData 多写了一层包装,但换来的是行为的“显式化”,代码审查时一眼就能看出收集边界在哪。
2.2 区别二:粘性数据与重复值策略不一样
LiveData 在数据重复方面有一个很经典的坑:setValue 同一个值多次,observer 也会多次回调。什么意思呢?你用 setValue("loading"),再 setValue("loading"),UI 会收到两次 loading。如果你在回调里做的是耗时逻辑,比如展示一个弹窗或者触发一次动画,就会发生重复执行。
StateFlow 内置了去重逻辑:只有 value 发生变化时,才会向下游发射。同样连续赋两次 "loading",collect 只会收到一次。这个设计很有用,尤其是 UI 状态恢复场景——界面重建后,有几个 StateFlow 的当前值没变,collect 不会因为重新订阅再回调一遍,避免了无意义的 UI 刷新。
但这也带来了新问题:如果你想“把用户重新拉回某页”或者“重新播放一次提示音”,但状态值并没有变化,StateFlow 会静默跳过,导致事件丢失。这类场景的正确做法不是用 StateFlow,而是用 SharedFlow,比如:
sealed interface UiEvent { data class ShowToast(val message: String) : UiEvent } private val _events = MutableSharedFlow<UiEvent>() val events: SharedFlow<UiEvent> = _events.asSharedFlow() fun showMessage(message: String) { viewModelScope.launch { _events.emit(UiEvent.ShowToast(message)) } }SharedFlow 不保留最新值,也没有去重逻辑,每次发射每个订阅者都能收到,这才适合一次性事件。我见过很多团队把提示、跳转、弹窗这类事件塞进 StateFlow,结果偶尔丢事件,定位半天才怀疑到“值没变被去重了”的头上。这个区别一定要在团队文档里写明:状态用 StateFlow,事件用 SharedFlow,LiveData 则没有这两个概念的严格区分。
2.3 区别三:并发合并与背压行为的不同假设
LiveData 的 setValue/postValue 在并发场景下是有讲究的。postValue 设计用于子线程更新,但它有两个比较让人意外的行为:如果在主线程上多次 postValue 而主线程还没来得及处理,后面的 post 会覆盖前面的,也就是说中间状态会被丢掉;而且 postValue 之后立刻在主线程读 value,可能还是旧值。
StateFlow 的发射逻辑也有合并行为,不过它是基于 Flow 的缓冲策略:当 collect 侧处理不过来的时候,StateFlow 只保留最新值,不积压中间值。这本质上是一种 conflation 策略,适合 UI 状态这种“我只关心最新值”的场景。
举例来说,你在 ViewModel 里并发地发起多个网络请求,每个请求回来时都会更新同一个 StateFlow:
viewModelScope.launch { launch { val r = api.fetchA(); _uiState.value = UiState(a = r) } launch { val r = api.fetchB(); _uiState.value = UiState(b = r) } }如果请求 A 先回来,UI 更新一次;请求 B 后回来,再更新一次。就算 B 先回来、A 后回来,UI 也永远看到的是“最后一次赋值”。这对 UI 来说没问题,但如果你在 collect 里做了副作用操作,比如把每次值写入数据库,那么中间值的丢失就可能导致漏写。
所以我在选择方案时会看另一个维度:下游消费方是否依赖“每一次变化”。如果只是展示,StateFlow 足够;如果要做审计、写入、累加,那你需要的不是 StateFlow,而是 Channel 或者带 Buffer 的 SharedFlow。这个决策不是“LiveData 更好”或“StateFlow 更好”,而是“你的数据模型需要什么样的背压语义”。
2.4 区别四:操作符丰富度与数据流组合能力
这一点是 StateFlow 摧枯拉朽般的优势。LiveData 虽然也提供了 map、distinctUntilChanged 等扩展,但数量很有限,而且组合多个 LiveData 需要 MediatorLiveData,代码写起来比较笨重。经常出现一种情况:你有两个数据源,需要等两个都返回后合并展示,LiveData 要写这样的代码:
val fullInfo = MediatorLiveData<FullInfo>() private val userLiveData = MutableLiveData<User?>() private val configLiveData = MutableLiveData<Config?>() fullInfo.addSource(userLiveData) { user -> val config = configLiveData.value if (user != null && config != null) { fullInfo.value = FullInfo(user, config) } } fullInfo.addSource(configLiveData) { config -> val user = userLiveData.value if (user != null && config != null) { fullInfo.value = FullInfo(user, config) } }这段逻辑看起来还行,但一旦数据源超过两个,或者你需要先过滤再映射再合并,MediatorLiveData 的可读性会急剧下降。StateFlow 和 Flow 生态写同样的逻辑,可以用 combine:
val fullInfo: StateFlow<FullInfo?> = combine( userStateFlow, configStateFlow ) { user, config -> if (user != null && config != null) FullInfo(user, config) else null }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = null )combine、flatMapLatest、filter、map、debounce、sample 这些都是 Flow 自带的操作符,你能像一个管道一样自由组装。对于复杂的搜索防抖、多接口合并、状态机切换,StateFlow 的表达能力明显更胜一筹。这也是我在业务层逐渐放弃 LiveData 的最主要原因——不是 LiveData 有多差,而是组合能力跟不上业务复杂度。
2.5 区别五:架构边界与可测试性的真正分歧
LiveData 的类实现在 lifecycle-livedata 包中,它依赖 LifecycleOwner、LifecycleRegistry 这些 Android Framework 相关类型。如果你在 Domain 层的接口里定义fun observeUser(): LiveData<User>,这个 Domain 层就变相被 Android SDK 入侵了。单元测试时要么引入 Robolectric,要么做一堆 Mock 来掩盖生命周期依赖,非常影响测试效率和调试体验。
StateFlow 来自 kotlinx.coroutines,是纯 Kotlin 类型,所以你可以在干净的 JVM 环境里测试:
class MyViewModelTest { @Test fun `state should update when data loads`() = runTest { val vm = MyViewModel(fakeRepository) vm.loadData() assertEquals(UiState.Success("hello"), vm.uiState.value) } }在分层架构里,我通常会让 Repository 层暴露普通 Flow,ViewModel 层用 stateIn 转成 StateFlow,UI 层通过 repeatOnLifecycle 收集。这样每个层都只依赖抽象的流,没有任何一个层因为“观察者模式”而被绑死在 Android 类上。
从团队协作角度看,这一点的价值最容易被低估。一旦 Domain 层可以用纯 Kotlin 写,你就可以在本地快速执行单元测试,不需要启动模拟器,CI 构建也会明显更快。这比任何花哨的特性都更能提升开发效率。
3. 迁移和混用中的典型坑位
理论说多了容易飘,真正动手切换时才是事故高发阶段。下面这些坑,我全部在真实项目里踩过,有些还花了好几天排查。
3.1 漏掉 repeatOnLifecycle 之后我在后台任务上踩的坑
我第一次把 Fragment 里的 LiveData.observe 改成viewModel.uiState.collect时,犯了一个非常经典的错误:直接在 lifecycleScope 里 launch,没有包 repeatOnLifecycle。代码看起来差不多,但行为差异巨大。
// 错误写法:界面在后台时 collect 不会停止 viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { state -> saveItem(state) } }问题出在:当 Activity 退到后台后,collect 依然在运行。如果此时数据源还在不断发射,collect 里的 saveItem 就会被反复执行。有一次用户在列表页快速切换应用,数据库被同时写入多份重复记录,后来我加日志定位才发现是 UI 层的 collect 一直没有取消。
这个坑之所以隐蔽,是因为它不一定会崩溃,只会在特定时序下产生数据异常。后来我把所有 UI 层订阅统一改成 repeatOnLifecycle,并在 code review 时强制检查这个模式,类似问题出现的概率就几乎为零了。
3.2 value 初始化和并发覆盖导致的 UI 状态闪跳
StateFlow 必须有初始值。这个初始值如果设置不当,在 UI 首次订阅时会触发一次回调。假设你把初始值设成UiState.Loading,但业务上这个页面一进来就有缓存可以展示,那么界面会先闪一下 Loading,再变成功,观感很不好。
更麻烦的是并发覆盖。StateFlow 不保证多次赋值之间的顺序与调用顺序一致?其实在单线程里是一致的,但如果你在多个协程里并发赋值,就会出现后调用的协程先赋值、先调用的协程后赋值,最终状态被旧逻辑覆盖的情况。这不是 StateFlow 的 bug,而是并发时序问题。我在项目里遇到过:登录成功后刷新用户信息,同时设置一个全局的_currentUser,另一个协程恰好也在登录流程中更新了同一个流,结果用户头像偶尔显示成上一个账号的。
解决方案是引入状态合并函数,不要直接赋值:
data class UserUiState( val user: User? = null, val loading: Boolean = false, val error: String? = null ) fun updateUser(transform: (UserUiState) -> UserUiState) { _uiState.value = transform(_uiState.value) }每次更新都基于当前最新值做变换,而不是直接用旧闭包里的值覆盖。这个习惯无论用 LiveData 还是 StateFlow 都应该养成,只是 StateFlow 由于更常被放进业务层,暴露的并发问题更多。
3.3 MediatorLiveData 到 combine 转换时的冷流陷阱
把 MediatorLiveData 改造成 combine 时,有同学发现界面不刷新了,排查半天才意识到 Flow 默认是冷流。冷流意味着:没有订阅者时,数据流不会执行。LiveData 则更像热数据容器,你可以随时添加观察者,也可以随时读取 value。
如果你把 Repository 的某个返回对象由fun getUser(): LiveData<User>改成了fun getUser(): Flow<User>,但 ViewModel 层没有用 stateIn 转成 StateFlow,而是直接把它暴露给 UI,那么 UI 在 collect 时才算真正开始执行数据加载。好在一般项目里 ViewModel 会做 stateIn 转换,这个坑只会出现在“直接从 Repository 拿 Flow 给 UI”这种不规范的写法里。
提醒一下:如果你用SharingStarted.WhileSubscribed(5000)做 stateIn,它的语义是“有订阅时启动上游,最后一个订阅者离开 5 秒后关闭上游”。如果上游是网络请求这种一次性操作,5 秒后可能不会触发新的请求,而是直接把旧缓存发射回来。这里要结合数据源的缓存策略仔细设计。
3.4 在 DataBinding 里直接用 StateFlow?先想清楚这一点
Google 官方 DataBinding 适配了 LiveData,所以在 XML 里直接@{}绑定很顺畅。但 StateFlow 默认不能和 DataBinding 直接双向绑定,需要手动导入协程的 Flow 相关类,或者通过asLiveData()转换。
团队里有些同学为了省事,在 ViewModel 里定义了 StateFlow,绑定 DataBinding 时又转成了 LiveData。功能上没问题,但这样就失去了一部分 StateFlow 的特性:你在 XML 绑定层看到的仍是 LiveData。我建议要么 UI 层统一用 ViewBinding/Compose,用 repeatOnLifecycle 收集;要么保留 DataBinding 做简单的asLiveData()单向绑定,不要混用两套订阅体系,否则团队心智负担会很大。
3.5 非空的初始值地狱:写成可空类型是偷懒不是解法
很多初学者遇到状态不知道初始值的情况,直接声明成可空类型:
private val _uiState = MutableStateFlow<MyState?>(null)这样写确实能跳过初始值设计,但后果是所有消费方都得判空,如果忘了判空,UI 可能直接崩。因为 StateFlow 在未赋值前会立刻把 null 发出来,界面一订阅就先收到 null。
你在 LiveData 里可能也有类似问题,但 LiveData 的 observer 收到 null 时大多是懒处理,问题爆发率低一些。StateFlow 的强约束反而逼着你认真考虑“这个页面没数据之前应该显示什么”。我的建议是定义明确的子状态,比如Loading、Empty、Success、Error,而不是用 null 表达一切。
4. 我的选择思路与团队落地建议
4.1 关键判断:你观察的是“状态”还是“事件”
先回答一个问题:你要暴露的东西是一份可以被反复读取的“当前值”,还是一次发生完就消失的“事件”?
如果是状态,比如用户信息、列表加载状态、播放器进度,用 StateFlow 很合适,因为它的当前值可以随时读取,并且具有去重行为。如果是事件,比如提示消息、页面跳转、滚动到顶部,用 SharedFlow 或者 Channel 更合适,因为它保留的是发射序列而非最新值。LiveData 在这两者之间没有明确区分,这也是很多团队在需求变复杂后不得不引入 Flow 的原因。
4.2 项目技术栈与团队协程基础
如果项目还停留在 Java,或者团队对协程不熟,那看到 StateFlow 的第一反应大概率是“这是什么鬼”。这种情况下不必强求,LiveData 依然是可用的简单方案。但如果项目已经全面 Kotlin 化,ViewModel 已经用了 viewModelScope,Repository 已经在用挂起函数和 Flow,那么 StateFlow 几乎是必然的下一步——因为它能让 ViewModel 和 Repository 之间的数据通道保持同一套抽象。
另外要注意:如果你的项目里 Fragment 还在用viewLifecycleOwner.lifecycleScope.launch收集数据,建议先统一升级到 repeatOnLifecycle,否则切到 StateFlow 后会留下一堆隐患。
4.3 分阶段迁移:先加开关,再逐步替换
不建议一次性把项目里的 LiveData 全掀翻。比较稳妥的做法是:
- 从新需求开始,新写的 ViewModel 默认用 StateFlow。
- 旧页面保留 LiveData,只在接口改动或 bug 修复时顺手迁移。
- 在公共基类或 BaseViewModel 里统一提供 uiState 容器的约定,让后续新增代码都遵循一样模式。
- 等到 LiveData 只存在于少数老页面时,再集中清理。
这样可以控制风险,不用为了“技术先进”而承担一次性大改造成的回归。
4.4 最终推荐的一个团队约定
如果你问我,现在新开一个中小型项目我会怎么定规矩,我的答案是:
- 数据层和仓库层:暴露普通 Flow。
- ViewModel 层:用 stateIn 把业务状态转成 StateFlow,事件用 SharedFlow。
- UI 层:用 repeatOnLifecycle 收集 StateFlow;只有快速改版或老项目接 DataBinding 时,才允许
asLiveData()做桥接。 - 不在纯 Kotlin 模块使用 LiveData。
这套约定兼顾了架构边界、可测试性和开发效率。
最后再分享一个我在切换过程中的小技巧:如果你不确定某个 StateFlow 的订阅边界有没有写对,可以在 collect 块里加一行日志Log.d("Collect", "currentState=$it"),然后按 Home 键再切回来,观察日志是否在后台继续打印。如果后台也在打印,说明你的 repeatOnLifecycle 漏写了,这是排查生命周期问题最快的方式。从 LiveData 翻到 StateFlow 不是终点,真正值钱的不是 API 长什么样,而是你能不能在使用它的过程中建立起一套清晰、能长期维护的状态管理约定。