我为什么让 Toast 多弹了一次

我之前在这篇故事里说到,实习生阿泽在经历项目锻炼后写了一篇文章-《我为什么让 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,代码大概是这样:

kotlin 复制代码
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 里这样观察:

kotlin 复制代码
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 包装

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

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

kotlin 复制代码
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 改成:

kotlin 复制代码
private val _toastEvent = MutableLiveData<Event<String>>()
val toastEvent: LiveData<Event<String>> = _toastEvent

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

Activity 里这样消费:

kotlin 复制代码
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 实现:

kotlin 复制代码
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 里这样收集:

kotlin 复制代码
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

kotlin 复制代码
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:丢弃最新的数据,保留旧数据

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

kotlin 复制代码
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 的特化版本,专门用于状态管理:

kotlin 复制代码
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

kotlin 复制代码
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

StateFlowSharedFlow 的主要区别:

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

StateFlow 有几个特点值得注意:

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

所以一个简单的分工是:

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

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

一点想法

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

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

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

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

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

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

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

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

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

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

参考资料

相关推荐
stevenzqzq1 小时前
app性能日志工具
android
DB哥讲数据库1 小时前
【最新】MySQL9.7安装教程:图文详解(附MySQL安装包)
android·mysql·adb
IT乐手1 小时前
Android 写本地日志工具类
android·java
非典型Android程序员2 小时前
Launcher学习总结
android
Coffeeee3 小时前
Android功耗优化,哪些代码会让你的机子发热又耗电
android·架构·kotlin
Android打工仔3 小时前
Kotlin 编译器是如何把 suspend 函数变成状态机的?
kotlin
Android打工仔3 小时前
一个 suspend 函数,为什么会变成一个普通函数?
kotlin
xianrenli384 小时前
Android14自动时区更新
android·学习·framework·aosp14·自动时区
心中有国也有家4 小时前
AtomGit Flutter 鸿蒙客户端:零外部资源的应用打包策略
android·javascript·flutter·华为·harmonyos
-SOLO-4 小时前
解决gralde下载太慢的问题
android