粘性数据问题分析及解决方案
- 粘性数据出现底层原理
- 实际出现场景
-
- 1、导航事件(一次性的界面跳转)
- [2、 数据加载状态与一次性错误](#2、 数据加载状态与一次性错误)
- 3、多观察者场景下的意外消费
- 解决粘性数据
-
- [使用 SingleLiveEvent(Google 官方示例)](#使用 SingleLiveEvent(Google 官方示例))
- [使用 Kotlin Flow + SharedFlow](#使用 Kotlin Flow + SharedFlow)
-
- SharedFlow简介
- 具体代码
-
- 1、定义SharedFlow
- [在 Activity/Fragment 中收集事件](#在 Activity/Fragment 中收集事件)
- 总结
我们在上一篇文章源码分析的时候讲过,LiveData 谷歌官方专门设计了这个粘性数据(防止数据丢失)。例如在页面跳转或配置变更后重复接收旧数据。这里我们再从底层原理开始分析,解释为什么先执行 setValue后面再 observe仍能收到数据,最后再提供解决方案。
LiveData 的设计初衷是持有并分发状态(state),而不是事件(event)。状态是可重复消费的(例如界面上的列表数据),而事件应该只被处理一次。在实际开发中,开发者经常把事件也用 LiveData 来表达,于是粘性就导致了上述问题。
粘性数据出现底层原理

在上一篇文章中我们可以知道,每一个LiveData对象都有一个mVersion,默认值是-1。在setValue时,会调用mVersion++,此时mVersion是0。在observe方法里面,最终会调用上面图片中的判断。当页面配置信息变化或者页面跳转时,会相当于第一次执行observe方法。观察者的mLastVersion与LiveData的mVersion不匹配,判断无法进入return。就会调用到onChanged响应在observe之前设置的数据了。这个数据我们就称之为粘性数据。
具体也可以去看上一篇文章Android jetpack LiveData (二) 原理篇
实际出现场景
1、导航事件(一次性的界面跳转)
场景:在 ViewModel 中通过 LiveData 控制导航,例如用户点击按钮后跳转到另一个页面。
java
kotlin
// ViewModel
MutableLiveData<Boolean> navigateToDetail = new MutableLiveData<>();
public void onButtonClick() {
navigateToDetail.setValue(true);
}
// Activity
viewModel.navigateToDetail.observe(this, aBoolean -> {
startActivity(new Intent(this, DetailActivity.class));
});
问题:当配置变更(如旋转屏幕)导致 Activity 重建时,新的 Activity 会重新注册观察者。由于 LiveData 持有最新的 true 值,重建后的 Activity 会立即收到该值,从而再次触发导航,导致页面重复打开。用户可能只想在按钮点击时跳转一次,却因为屏幕旋转而意外再次跳转。
粘性带来的后果:本应是一次性的事件,变成了可重复消费的状态,破坏了预期的一次性行为。
2、 数据加载状态与一次性错误
场景:ViewModel 使用 LiveData 表示数据加载状态,例如 LOADING、SUCCESS、ERROR。
kotlin
enum Status { LOADING, SUCCESS, ERROR }
MutableLiveData<Status> status = new MutableLiveData<>();
public void loadData() {
status.setValue(LOADING);
// 网络请求...
status.setValue(SUCCESS);
}
// Activity
viewModel.status.observe(this, s -> {
switch (s) {
case LOADING: showProgress(); break;
case SUCCESS: showContent(); break;
case ERROR: showError(); break;
}
});
问题:当网络请求出错时,status 被设置为 ERROR,Activity 显示错误提示。如果此时用户旋转屏幕,新的 Activity 会立即收到 ERROR 状态,再次显示错误提示,尽管错误其实已经处理过或用户已经看到过。
粘性带来的后果:错误状态被重复消费,导致体验不佳。
3、多观察者场景下的意外消费
场景:一个 LiveData 被多个 Fragment 观察,用于页面间通信。例如一个 LiveData 用于传递选中的商品 ID。
java
kotlin
// 在 Activity 中共享的 LiveData
SharedViewModel sharedVM = new ViewModelProvider(requireActivity()).get(SharedViewModel.class);
sharedVM.selectedProduct.observe(getViewLifecycleOwner(), productId -> {
// 更新当前 Fragment 的 UI
});
问题:假设 FragmentA 设置了一个商品 ID,FragmentB 同时观察该 LiveData。当 FragmentB 首次创建时,它会立即收到之前 FragmentA 设置的值,这可能并不是 FragmentB 期望的行为------它可能只想在用户真正操作后接收新值,而不是初始状态。
粘性带来的后果:通信变得不可控,新打开的 Fragment 会被"旧数据"污染。
解决粘性数据
解决这些场景的核心思路是将事件与状态分离,采用适合事件的一次性通信机制:
使用 SingleLiveEvent:Google 官方示例,确保事件只会被一个观察者消费一次。
改用 Kotlin 协程的 SharedFlow(replay=0):在 Kotlin 项目中更灵活地处理事件。
采用通道(Channel)或回调接口:对于非常明确的一次性通信,直接使用接口回调或事件总线(谨慎使用)。
使用 SingleLiveEvent(Google 官方示例)
SingleLiveEvent 是一个自定义 LiveData,只允许一个观察者收到事件,且一旦消费后不会重复发送。
kotlin
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val pending = AtomicBoolean(true) // 标记事件是否待处理
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner) { t ->
if (pending.compareAndSet(true, false)) { // 仅首次接收生效
observer.onChanged(t)
}
}
}
override fun setValue(t: T?) {
pending.set(true) // 发布新事件时重置标记
super.setValue(t)
}
override fun postValue(t: T?) {
pending.set(true) // 发布新事件时重置标记
super.postValue(t)
}
}
原理:内部通过 AtomicBoolean 标记是否有未消费的事件。每次设置值时将标记设为 true,但在分发时,只有第一个观察者能将标记从 true 置为 false 并收到事件,后续观察者即使注册时得到该值,也会因标记已被置为 false 而收不到。
优点 :使用方式与普通 LiveData 相同,对观察者透明。
缺点:仅支持一个观察者收到事件(适合导航、Snackbar 等一次性事件)。如果多个观察者都需要消费同一个事件,则无法满足。
使用 Kotlin Flow + SharedFlow
Kotlin 协程的 SharedFlow 可以配置 replay 参数控制新订阅者是否收到之前发送的数据。
kotlin
// 创建一个没有 replay 的 SharedFlow
private val _event = MutableSharedFlow<EventType>(replay = 0)
val event: SharedFlow<EventType> = _event
// 发送事件
suspend fun sendEvent(data: EventType) {
_event.emit(data)
}
// 在 Activity/Fragment 中收集
lifecycleScope.launch {
viewModel.event.collect { event ->
// 处理事件
}
}
优点 :灵活,可精确控制 replay 数量、缓冲等。
缺点:需要引入协程,学习成本略高。
SharedFlow简介
SharedFlow 是 Kotlin 协程库提供的一种热流(hot stream),它可以有多个收集者(collector),并且支持配置重放(replay)、缓冲(buffer) 和 溢出策略(onBufferOverflow)。
关键属性:
replay:当一个新的收集者开始收集时,可以收到之前发出的多少个历史值。默认值为 0。
extraBufferCapacity:额外缓冲区大小,用于暂存来不及处理的值。
onBufferOverflow:缓冲区溢出时的处理策略(挂起、丢弃最新、丢弃最旧)。
对于解决黏性问题,我们只需将 replay 设为 0,新订阅者就不会收到之前已发送的事件,从而实现了非黏性事件流。
为什么 SharedFlow 能解决黏性问题?
LiveData 的黏性源于新观察者注册时会立即收到当前持有的最新值,因为内部版本号比较导致。而 SharedFlow 通过 replay=0 配置,新订阅者不会收到之前已发射的事件,只有订阅之后发射的事件才会被收到。这正符合我们对"一次性事件"的期望。
在实际架构中,应明确区分哪些 LiveData 用于状态(可以粘性),哪些用于事件(不能粘性),从而避免因粘性数据引发的问题。
官方建议:区分状态与事件
Google 建议将状态(State)和事件(Event)分开管理:
状态 :可重复消费,适合使用 LiveData/StateFlow(带 replay)。
事件 :仅应消费一次,适合使用 SingleLiveEvent、SharedFlow(replay=0) 或 Channel。
具体代码
1、定义SharedFlow
通常我们在 ViewModel 内部使用 MutableSharedFlow 来发送事件,对外暴露不可变的 SharedFlow。
kotlin
class MainViewModel : ViewModel() {
// 事件定义
sealed class UiEvent {
object NavigateToProfile : UiEvent()
data class ShowMessage(val text: String) : UiEvent()
}
// 创建MutableSharedFlow
private val _uiEvent = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// 对外暴露
val uiEvent = _uiEvent.asSharedFlow()
fun onProfileClick() {
viewModelScope.launch {
//emit 是一个挂起函数,需在协程中调用。
_uiEvent.emit(UiEvent.NavigateToProfile)
}
}
fun onSaveClick() {
viewModelScope.launch {
// 执行保存操作...
_uiEvent.emit(UiEvent.ShowMessage("保存成功"))
}
}
}
emit 是一个挂起函数,需在协程中调用。
如果不需要背压处理,可以省略缓冲区参数,但 emit 可能会挂起直到有消费者收集。通常建议设置一点缓冲区(如 extraBufferCapacity = 1)以避免发送方意外挂起。
溢出策略可根据需求选择:DROP_OLDEST(丢弃最旧的)、DROP_LATEST(丢弃最新的)、SUSPEND(挂起发送方)。
在 Activity/Fragment 中收集事件
正确收集 SharedFlow 需要考虑生命周期安全,避免在后台时浪费资源或引发崩溃。官方推荐使用 repeatOnLifecycle(lifecycle-runtime-ktx 2.4.0+)或 Flow.flowWithLifecycle。
使用 repeatOnLifecycle(最安全)
kotlin
class MainFragment : Fragment() {
private val viewModel: MainViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 收集事件
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiEvent.collect { event ->
when (event) {
is MainViewModel.UiEvent.NavigateToProfile -> {
findNavController().navigate(R.id.profileFragment)
}
is MainViewModel.UiEvent.ShowMessage -> {
Snackbar.make(requireView(), event.text,
Snackbar.LENGTH_SHORT).show()
}
}
}
}
}
// 按钮点击
binding.profileButton.setOnClickListener {
viewModel.onProfileClick()
}
binding.saveButton.setOnClickListener {
viewModel.onSaveClick()
}
}
}
总结
LiveData 粘性数据在实际场景中导致的问题,根源在于将一次性事件当作了可重复消费的状态来使用。常见场景包括导航控制、提示消息、错误状态显示以及跨页面通信等。开发者需要识别这些场景,并采用适当的事件处理机制来避免粘性带来的副作用。