乐于分享
好东西不私藏

协程开太多你的App已经emo了

协程开太多你的App已经emo了

你启动了太多协程(而你的应用正在为此付出代价

献给 Kotlin 协程的一封情书——以及一次温柔的干预

我们得好好谈谈。

我在代码审查里见过它。我在开源项目里见过它。我在凌晨两点自己写的“先让它跑起来”的提交里也见过它。我们 Android 开发者与 viewModelScope.launch {} 有着一种极其不健康的关系。

协程确实了不起。它们让异步 Android 开发重新变得人性化。但在某个时刻,我们开始把一切都包进协程里——就像刚发现 sealed class 的开发者,突然觉得每个数据模型都需要十七个子类。

我们来谈谈这实际造成的问题,为什么它们有害,以及如何修复。

首先,破除一个迷思

你可能听说过不必要的协程会“堆积”、“泄漏”或者“变成僵尸”。这大多是民间传说。在 viewModelScope 中启动的协程,一旦完成它的工作就会立即返回——它的 Job 会从父级的孩子列表中移除,GC 会回收一切。那些短生命周期且能正常完成的协程并不是内存泄漏

那么为什么这篇文章还存在?因为过度使用 launch {} 的真正问题不在于内存。它们关乎正确性、顺序性和 UI 响应速度——而且这些问题比内存泄漏要狡猾得多。内存泄漏会在分析器里现形。而我们即将讨论的 bug,往往通过一条周五下午四点的 Slack 消息浮出水面:“用户说这个 App 用起来怪怪的”。

一个不必要的 launch 的真实代价

当你写下 viewModelScope.launch { ... } 时,发生了四件如果你直接调用代码就不会发生的事情:

  1. 你失去了同步顺序性。 这个代码块不会立即执行。它被分派了。默认情况下,viewModelScope 使用 Dispatchers.Main,它会把这个块投递到主线程的消息队列。即使是在主线程上,这个块也会在当前调用返回之后才运行——而不是在调用期间。
  2. 你失去了返回值。launch 返回一个 Job,而不是你的代码产生的结果。如果下游的任何东西需要那个值,你就默默地破坏了契约。
  3. 你削弱了异常处理。launch 内部抛出的异常不会传播给调用者。它们会去到 CoroutineExceptionHandler(或者导致协程作用域崩溃)。原本能干净抛出异常的函数,现在要么静默失败,要么在一个令人困惑的地方失败。
  4. 你创造了交错执行的机会。 连续启动的两个 launch 块可能以任意顺序执行。如果它们操作相同状态,你就引入了一个之前不存在的竞态条件。

最后一点是杀手锏,也是我们稍后会讲到的 TextField bug 的根本原因。

罪魁祸首 #1:无用的 Launch

下面是我经常看到的一个真实模式:

private fun reduceProfile() {
    viewModelScope.launch {
        val user = getContentState().songViewData?.owner
        if (user != null) {
            Nexty.put(user.id, user)
            setAction(Action.OnProfile(user.id))
        }
    }
}

看起来不错,对吧?它在 ViewModel 里,用了 viewModelScope,看起来很专业

现在让我们问三个问题:

  • getContentState() 是挂起函数吗?不是——它只是读取一个 MutableStateFlow.value
  • Nexty.put() 是挂起函数吗?不是——它是一个同步缓存写入。
  • setAction() 是挂起函数吗?不是——它向另一个 MutableStateFlow 发送一个值。

这里完全没有异步操作。 你创建了一个协程来做三件本来可以在微秒内直接完成的事情——反而把它们全部推迟到了下一个消息循环周期。如果调用者依赖于这个函数返回后 setAction 就能立即生效的话,恭喜你:你创造了一个不会在你机器上复现的 bug。

修复方案:

private fun reduceProfile() {
    val user = state.songViewData?.owner
    if (user != null) {
        Nexty.put(user.id, user)
        setAction(Action.OnProfile(user.id))
    }
}

相同意图。同步。可预测。

经验法则: 如果你的 launch {} 内部没有任何调用是 suspend 函数,而且你也没有切换调度器——那你就不需要协程。到此为止。

罪魁祸首 #2:TextField 协程(一种特殊罪行)

这个问题值得单独开一节,因为它不仅推迟工作——它还以一种极其令人痛苦的方式直接破坏 UI。

private fun reduceOnAlbumName(value: TextFieldValue) {
    viewModelScope.launch {
        updateState { state.copy(albumName = value) }
    }
}

要理解为什么这会有问题,你需要理解 viewModelScope.launch {} 在主线程上究竟做了什么。默认情况下,调度器是 Dispatchers.Main——不是Dispatchers.Main.immediate。区别在这里至关重要:

  • Dispatchers.Main 总是将代码块投递到主线程的消息循环(Looper)。即使你已经处于主线程,这个块也会在下一个循环迭代中执行。
  • Dispatchers.Main.immediate 如果已经处于主线程,则会同步执行这个块。

所以每次按键都会流经这个管道:

  1. 用户输入一个字符。Compose 更新其内部文本状态并调用你的 onValueChange
  2. 你的 onValueChange 调用 reduceOnAlbumName,后者调度了一个 launch 块。
  3. 当前帧完成。Compose 可能已经在绘制下一帧,其中包含用户刚输入的字符。
  4. 最终,Looper 执行你的 launch 块,它使用第 1 步中的 TextFieldValue 调用 updateState
  5. 这个状态更新作为“新”值流回 TextField——但此时用户可能已经又输入了更多字符。
  6. Compose 尽职尽责地用过期状态覆盖了用户正在输入的文本。

可见的症状:

  • 文本输入框闪烁,因为过期值不断覆盖最新值。
  • 光标向后跳跃到前一帧的位置。
  • 快速打字会丢字符。 这是最致命的一个。
  • 输入法(IME)组合状态被破坏,尤其是使用组合输入的语言(日语、韩语、中文)。

修复方案:

private fun reduceOnAlbumName(value: TextFieldValue) {
    updateState(state.copy(albumName = value))
}

直接。同步。状态更新在 onValueChange 返回之前完成,所以 Compose 看到的是一个一致的状态:它刚刚发给你的文本现在就在你的 StateFlow 里,消息队列中没有过期值等待覆盖下一次按键。

规则: 永远不要通过 launch 来路由 TextFieldValue 更新。如果你确实因为某些原因需要协程(例如,你想要对保存到磁盘的副作用做防抖),请同步执行状态更新,然后为副作用启动一个单独的协程。

罪魁祸首 #3:async/await 反模式

这个模式比较少见,但当你在代码审查中发现它时会更尴尬:

viewModelScope.launch {
    val user =async { repository.fetchUser(id) }.await()
    _state.value = State.Loaded(user)
}

async { x }.await() 是写 x 的一种更昂贵的方式。async 的全部意义在于启动工作,然后稍后await——在其他并发工作启动之后。如果你立即 await,你就白白分配了一个 Deferred 和一个协程。

修复方案:

viewModelScope.launch {
    val user = repository.fetchUser(id)
    _state.value = State.Loaded(user)
}

async 只有在你有两个或更多独立的挂起操作时才物有所值:

viewModelScope.launch {
    val userDeferred =async { repository.fetchUser(id) }
    val prefsDeferred =async { repository.fetchPrefs(id) }
    _state.value = State.Loaded(userDeferred.await(), prefsDeferred.await())
}

这才是合法的使用场景。其他情况都只是形式主义。

罪魁祸首 #4:在 viewModelScope 协程内部使用 withContext(Dispatchers.Main)

viewModelScope.launch {
    val data = withContext(Dispatchers.IO) { repository.load() }
    withContext(Dispatchers.Main) {
        _state.value = State.Loaded(data)
    }
}

第二个 withContext 纯属多余。viewModelScope 已经为外层launch 上下文使用了 Dispatchers.Main.immediate(技术上来说是 SupervisorJob() + Dispatchers.Main.immediate)。从 IO 块返回后,你会自动回到主线程。

修复方案:

viewModelScope.launch {
    val data = withContext(Dispatchers.IO) { repository.load() }
    _state.value = State.Loaded(data)
}

老实说,如果 repository.load() 是一个正确编写的 suspend 函数,它应该内部切换调度器(在仓库内部使用 withContext(Dispatchers.IO)),那么 ViewModel 中根本不需要任何调度器切换。把调度器关注点下推到拥有阻塞工作的那一层。

罪魁祸首 #5:从 init {} 启动协程来收集 Flow

init {
    viewModelScope.launch {
        repository.userFlow.collect { user ->
            _state.value = _state.value.copy(user = user)
        }
    }
}

这也能工作,但有一个微妙的问题:Flow 从 ViewModel 构造的那一刻起就被急切地收集了,即使没有 UI 在观察 ViewModel 的状态。对于一个由昂贵操作(如数据库查询、网络连接)支持的热流,你正在做没人消费的工作。

更地道的替代方案是 stateIn

val state: StateFlow<State>= repository.userFlow
    .map { user -> State(user = user) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = State.initial()
    )

WhileSubscribed(5_000) 的意思是:当 UI 订阅时开始收集,在最后一个订阅者离开后继续收集 5 秒(以应对配置变更),然后停止。没有显式的 launch。没有手动复制状态。为你处理好了取消。

那么什么时候应该使用协程?

协程确实非常棒。以下是你真正需要它们的情况:

在以下情况下使用协程:

  • 调用一个执行真正异步工作的 suspend 函数(网络、数据库、delay())。
  • 需要切换到不同的调度器,而且这个切换还没有在你调用的挂起函数内部处理好
  • 需要使用 async/await 运行并发工作(注意:是并发,不是顺序)。
  • 需要结构化并发来一起取消一组相关操作。
  • 需要收集一个 Flow,而且无法使用 stateIn/shareIn 来替代。

在以下情况下不要使用协程:

  • 仅仅从 StateFlowMutableStateFlow 中读取。
  • 调用你自己的同步函数。
  • 使用已经在内存中的值更新 UI 状态。
  • 想要感觉像个“真正的异步开发者”(我们都经历过 👀)。

下次提交 PR 前的快速自检

在把什么东西包进 viewModelScope.launch {} 之前,问问自己:

  1. 这个块内部是否有 suspend 调用?
  2. 我是否在切换调度器,并且这个切换在调用栈的更深处还没有被处理?
  3. 如果我移除 launch {} 并直接调用这段代码,任何东西会实际出问题吗——还是说只是感觉不够“异步”?

如果问题 1 和 2 的答案是“否”——而问题 3 的答案是“什么都不会坏”——那就删掉这个协程。未来的你、你的用户以及你应用的输入响应速度都会感谢你。

TL;DR

  • 不必要的 launch 块的问题不在于内存泄漏——而在于延迟执行、丢失返回值、弱化的异常处理和意外的交错执行。
  • viewModelScope.launch {} 默认使用 Dispatchers.Main,它会投递到消息循环。即使在主线程上,你的代码也要等到下一个循环迭代才执行,而不是立即执行。
  • 同步代码不需要协程。 读取 StateFlow、写入 StateFlow、调用内存中的逻辑——这些在调用线程上都没问题。
  • 永远不要把 TextFieldValue 更新放在 launch 块中。 调度器将更新投递到下一帧,这会与用户的输入产生竞态,导致丢字符、光标跳跃和 IME 组合状态破坏。
  • async { x }.await() 只是 x 加上多余步骤。只有在有多个并发操作时才使用 async
  • 不要从 viewModelScope 内部的协程中 withContext(Dispatchers.Main)——你已经在主线程上了。
  • 优先使用 stateIn/shareIn 而不是在 init {}launch { flow.collect { ... } }

少写协程。写好协程。你的应用会感觉更流畅,你的代码会更可预测,而且你会少花很多时间去琢磨为什么文本输入框每三个字就吃掉一个。

觉得有用?请留下评论,或把它分享给你团队里那位把所有东西都包进协程的开发者。你知道他们是谁。


原文链接https://proandroiddev.com/youre-spawning-too-many-coroutines-and-your-app-is-paying-the-price-79691375109a