Android jetpack LiveData (三) 粘性数据(数据倒灌)问题分析及解决方案

粘性数据问题分析及解决方案

我们在上一篇文章源码分析的时候讲过,LiveData 谷歌官方专门设计了这个粘性数据(防止数据丢失)。例如在页面跳转或配置变更后重复接收旧数据。这里我们再从底层原理开始分析,解释为什么先执行 setValue后面再 observe仍能收到数据,最后再提供解决方案。

LiveData 的设计初衷是持有并分发状态(state),而不是事件(event)。状态是可重复消费的(例如界面上的列表数据),而事件应该只被处理一次。在实际开发中,开发者经常把事件也用 LiveData 来表达,于是粘性就导致了上述问题。

粘性数据出现底层原理

在上一篇文章中我们可以知道,每一个LiveData对象都有一个mVersion,默认值是-1。在setValue时,会调用mVersion++,此时mVersion0。在observe方法里面,最终会调用上面图片中的判断。当页面配置信息变化或者页面跳转时,会相当于第一次执行observe方法。观察者的mLastVersionLiveDatamVersion不匹配,判断无法进入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 粘性数据在实际场景中导致的问题,根源在于将一次性事件当作了可重复消费的状态来使用。常见场景包括导航控制、提示消息、错误状态显示以及跨页面通信等。开发者需要识别这些场景,并采用适当的事件处理机制来避免粘性带来的副作用。

相关推荐
用户2018792831672 小时前
TabLayout被ViewPager2遮盖部分导致Tab难选中
android
法欧特斯卡雷特2 小时前
Kotlin 2.3.20 现已发布,来看看!
android·前端·后端
闻哥2 小时前
深入理解 MySQL InnoDB Buffer Pool 的 LRU 冷热数据机制
android·java·jvm·spring boot·mysql·adb·面试
ii_best2 小时前
安卓/ios开发辅助软件按键精灵小精灵实现简单的UI多配置管理
android·ui·ios·自动化
码农xo3 小时前
android 设备实时传输相机采集的视频到电脑pc端 通过内网wifi 方案
android·数码相机·音视频
常利兵3 小时前
解锁Android的隐藏超链接:Deep Link与App Link探秘
android·gitee
for_syq3 小时前
trace抓取工具
android·python
黄林晴3 小时前
Compose Multiplatform 1.10 发布:统一 Preview、Navigation 3、Hot Reload 三箭齐发
android·flutter
Kapaseker3 小时前
一杯 Kotlin 美式品味 object 声明
android·kotlin