我为什么让 Toast 多弹了一次

0 阅读9分钟

我之前在这篇故事里说到,实习生阿泽在经历项目锻炼后写了一篇文章-《我为什么让 Toast 多弹了一次》,对,就是这篇!

现在,让阿泽来当主人公,主动讲述那 “Toast 多弹了一次” 背后的故事。

从 Toast 多弹一次说起

有一类 Bug 很容易让人怀疑人生:代码看起来没问题,业务逻辑也没问题,甚至第一次运行也完全正常。

直到你旋转了一下屏幕,或者从后台切回前台,Toast 又弹了一次。

那天下午,测试同学小安姐提了一个 bug:会员中心页面的 Toast 会重复弹出。

复现步骤很简单:

  1. 进入会员中心
  2. 断网
  3. 点击重试
  4. 出现"会员信息加载失败"的 Toast
  5. 旋转屏幕
  6. Toast 再次出现

我看着这条 bug,心想:这不可能啊,我明明用的是 LiveData,生命周期感知的,怎么会重复?这也是领导安排我这么做的,难道领导也有问题?

但实际上:LiveData 本身没有错,错在我把它当成了事件总线。

LiveData 更适合表达"状态",而 Toast、Snackbar、页面跳转、弹窗这类行为更像"一次性事件"。

状态可以被重复读取,事件通常只能被消费一次。

一个普通的 LiveData

事情要从一周前说起。当时我在做会员中心的 MVVM 改造,把 Activity 里的逻辑拆到了 ViewModel

状态管理用的是 LiveData,代码大概是这样:

data class MemberUiState(
    val userName: String = "",
    val levelName: String = "",
    val errorMessage: String? = null
)

class MemberCenterViewModel(
    private val repository: MemberRepository
) : ViewModel() {

    private val _uiState = MutableLiveData(MemberUiState())
    val uiState: LiveData<MemberUiState> = _uiState

    fun loadMemberInfo() {
        _uiState.value = _uiState.value?.copy(loading = true)
        viewModelScope.launch {
            runCatching { repository.getMemberInfo() }
                .onSuccess { info ->
                    _uiState.value = MemberUiState(
                        userName = info.userName,
                        levelName = info.levelName,
                    )
                }
                .onFailure {
                    _uiState.value = MemberUiState(errorMessage = "会员信息加载失败")
                }
        }
    }
}

Activity 里这样观察:

viewModel.uiState.observe(this) { state ->
    state.errorMessage?.let { message ->
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

看起来很完美,对吧?

ViewModel 持有状态,View 只负责渲染。旋转屏幕后,ViewModel 不会销毁,状态保留,用户体验流畅。

但问题也埋在这里:从业务需求上来讲,errorMessage 里保存的不是一个普通状态,而是一次性动作。它不是"当前页面显示错误信息",而是"请立刻弹一个 Toast"。

一个小陷阱

我仔细排查了一圈,发现问题不在代码错误,而在 LiveData 的一个特性:粘性事件。

什么是粘性事件?

简单说,新注册的观察者会立即收到 LiveData 中当前存储的最新值。

官方文档明确说明:当生命周期从非活跃回到活跃,或者 Activity/Fragment 因配置变更被重建时,观察者会收到最新可用的数据。

旋转屏幕后,Activity 重建,重新观察 LiveData,拿到了上次的 errorMessage,于是 Toast 又弹了一次。

这不是 LiveData 的 bug,是设计如此!!!

当年针对这个还有一个专有名词 —— 数据倒灌。

Google 设计 LiveData 的职责就是同步状态,而不是发送事件。

所以,此时的用法就会有一些冲突:

  • 状态的目标是"最新值可恢复"。比如屏幕旋转后,页面仍然应该显示上一次加载出来的用户信息。
  • 事件的目标却是"发生过就结束"。比如 Toast 已经弹过了,它不应该因为观察者重建就重新发生。

我把事件当状态用了,当然会出问题。不是 LiveData 弹错了,是我让它承担了不适合的职责。

加个 Event 包装

toast_2.png

当时没有思考那么多,迅速在网上寻找解决方案。幸好,很多人都碰到过这个坑。

找到原因后,解决办法就有了。不要直接保存 String,而是保存一个"可消费"的事件对象。事件被消费一次后,再次观察时就不会重复执行:

open class Event<out T>(private val content: T) {
    var hasBeenHandled = false
        private set

    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    fun peekContent(): T = content
}

ViewModel 改成:

private val _toastEvent = MutableLiveData<Event<String>>()
val toastEvent: LiveData<Event<String>> = _toastEvent

fun showError(message: String) {
    _toastEvent.value = Event(message)
}

Activity 里这样消费:

viewModel.toastEvent.observe(this) { event ->
    event.getContentIfNotHandled()?.let { message ->
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

问题解决了。Toast 只会弹一次,旋转屏幕后不会重复。

这个方案能解决问题,而且它也把意图表达出来了:这里不是状态,而是一个事件。

Google Android Developers 早期也讨论过 SingleLiveEventEventWrapper 这类方案,其中 EventWrapper 的优势是能显式表达"是否已处理"的语义。

但说实话,它还是不够优雅。

每次发事件都要包一层 Event,消费时还要调用 getContentIfNotHandled(),样板代码太多。而且如果有多处观察同一个事件,只有第一个观察者能收到,后续观察者会被忽略。业务代码里到处都是 getContentIfNotHandled(),读起来会有一点别扭。

更关键的是:我们为了把 LiveData 改造成事件工具,写了一层额外语义。

既然问题是"我需要一个事件流",那有没有一个东西本来就是为这个场景准备的?

遇见 SharedFlow

后来项目开始使用 Kotlin 协程,我接触到了 SharedFlow

第一次用它处理事件时,我就感觉:这玩意儿就是为事件而生的。

同样的场景,用 SharedFlow 实现:

sealed interface UiEvent {
    data class Toast(val message: String) : UiEvent
    data object NavigateBack : UiEvent
}

class MemberCenterViewModel(
    private val repository: MemberRepository
) : ViewModel() {

    private val _uiEvent = MutableSharedFlow<UiEvent>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val uiEvent: SharedFlow<UiEvent> = _uiEvent.asSharedFlow()

    fun loadMemberInfo() {
        viewModelScope.launch {
            runCatching { repository.getMemberInfo() }
                .onFailure {
                    _uiEvent.emit(UiEvent.Toast("会员信息加载失败"))
                }
        }
    }
}

Activity 里这样收集:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiEvent.collect { event ->
            when (event) {
                is UiEvent.Toast -> {
                    Toast.makeText(this@MemberCenterActivity, event.message, Toast.LENGTH_SHORT).show()
                }
                UiEvent.NavigateBack -> findNavController().navigateUp()
            }
        }
    }
}

没有 Event 包装,没有 getContentIfNotHandled(),代码干净了很多。

为什么 SharedFlow 不会重复?

因为它默认不缓存历史数据。replay = 0,新订阅者只能收到订阅后发出的事件,之前的事件不会重放。页面重建后重新收集事件,也不会因为上一条 Toast 还"躺在状态里"而再次弹出。

这并不是说 SharedFlow 天然比 LiveData 高级,而是它更贴合"一次性事件"这个问题。

工具本身没有贵贱,关键是语义是否匹配。

详细说说 SharedFlow

既然用到了 SharedFlow,就简单介绍一下它的参数。

SharedFlow 可以理解为一个可配置的热事件流。

热流的意思是:它的实例独立于 collector 存在,不像普通 cold Flow 那样每次 collect 才重新执行上游逻辑。

常见写法是 ViewModel 内部持有 MutableSharedFlow,外部只暴露只读的 SharedFlow

val flow = MutableSharedFlow<Event>(
    replay = 0,
    extraBufferCapacity = 0,
    onBufferOverflow = BufferOverflow.SUSPEND
)
  • replay:新订阅者能收到的历史数据数量。默认是 0,表示不缓存。如果设为 1,新订阅者会立即收到最近一次的值,类似 LiveData 的行为。对于 Toast、导航、弹窗等一次性事件,通常设为 0
  • extraBufferCapacity:额外缓冲容量。当生产者速度比消费者快时(解决背压问题,这里不做展开),可以临时缓存一些数据。默认是 0,不缓存。给一个缓冲可以给事件流一点缓冲余地,避免在 collector 尚未启动时发出的事件丢失。
  • onBufferOverflow:缓冲区满时的处理策略:
    • SUSPEND:挂起生产者,等待缓冲区有空间(默认)
    • DROP_OLDEST:丢弃最旧的数据,插入新数据
    • DROP_LATEST:丢弃最新的数据,保留旧数据

对于事件流,通常这样配置:

val eventFlow = MutableSharedFlow<UiEvent>(
    replay = 0,
    extraBufferCapacity = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)

replay = 0 确保新订阅者不收到历史事件,extraBufferCapacity 给一个缓冲,DROP_OLDEST 在极端情况下丢弃旧事件。

对于 Toast 这种弱事件,很多时候 DROP_OLDEST 更符合直觉:如果短时间内来了很多提示,保留更新的提示可能更有意义。

这里还有一个实用的区别需要注意:emittryEmitemit 是挂起函数,可以等待缓冲或 collector,这意味着这个函数只能在协程中运行;tryEmit 立即返回 Boolean,表示这次发送是否成功。

业务事件通常用 emit 更稳,除非你明确希望"发不出去就算了"。

一个实用经验是:UI 一次性事件可以先从 replay = 0 开始。是否需要 extraBufferCapacity,要看事件是否可能在 collector 尚未启动时发出,以及你能否接受丢失。

不要为了"保险"随手把 replay 设成 1,否则你可能又把事件变成了会重放的状态。

提一句 StateFlow

既然 SharedFlow 处理事件,那状态管理呢?

StateFlow 就是为此而生的。

StateFlowSharedFlow 的特化版本,专门用于状态管理:

data class MemberUiState(
    val loading: Boolean = false,
    val userName: String = "",
    val levelName: String = "",
    val errorMessage: String? = null
)

class MemberCenterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(MemberUiState())
    val uiState: StateFlow<MemberUiState> = _uiState.asStateFlow()

    fun loadMemberInfo() {
        _uiState.update { it.copy(loading = true) }
        viewModelScope.launch {
            runCatching { repository.getMemberInfo() }
                .onSuccess { info ->
                    _uiState.update {
                        MemberUiState(
                            userName = info.userName,
                            levelName = info.levelName,
                        )
                    }
                }
                .onFailure {
                    _uiState.update { MemberUiState(errorMessage = "加载失败") }
                }
        }
    }
}

View 层收集 StateFlow

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

StateFlowSharedFlow 的主要区别:

特性StateFlowSharedFlow
初始值必须有可选
缓存始终保留最新值可配置 replay
防抖自动防抖(相同值不触发)无防抖
适用场景状态管理事件分发

StateFlow 有几个特点值得注意:

  • 它必须有初始值,所以你需要先定义页面的初始状态
  • 它有 value 属性,可以直接读取当前状态
  • 它会基于 equals 做去重,新的值和旧的值相等时,不会重复通知 collector
  • 它会向新 collector 发送当前最新状态,这正是页面重建后恢复 UI 的关键

所以一个简单的分工是:

StateFlow 放"页面现在是什么样":加载中、内容、错误、表单输入、列表数据、按钮是否可用。

SharedFlow 放"页面现在要做什么":弹 Toast、跳转、打开弹窗、触发震动、发送一次埋点。

一点想法

回到最初的问题:为什么 Toast 会多弹一次?

表面上看,是我把事件当状态用了。往深了想,是没分清"状态"和"事件"的区别。

当然,这并不是一个多复杂的 Bug。它真正有价值的地方在于,它逼我重新区分了状态和事件。

LiveData 没有错。它的设计目标就是让 UI 在生命周期变化后仍然拿到最新数据。屏幕旋转后还能显示正确内容,是它的能力;屏幕旋转后 Toast 又弹一次,是我把一次性动作塞进了状态容器。

EventWrapper 也没有什么不好。它是一种务实的补救方案,尤其在项目还以 LiveData 为主时,能用较小成本解决重复消费问题。

只是当项目已经使用 Kotlin Coroutines 时,SharedFlow 会让"事件"这件事表达得更自然。

StateFlowSharedFlow 也不是要完全替代 LiveData。更准确的说法是:我们有了更细的工具,可以把"状态"和"事件"拆开建模。

  • 状态要可恢复,事件要可消费
  • 状态关心最新值,事件关心发生过
  • 状态可以重放,事件通常不该重放

工具是死的,人是活的。但选对工具,能少走很多弯路。

这篇文章的灵感来自一个真实的 bug。那个 Toast 多弹了一次的下午,让我重新思考了状态和事件。希望对你也有启发。

参考资料