launch 不是用来切线程的:为什么我越来越少在它后面写 Dispatchers.IO

在 Kotlin 协程开发中,这种代码几乎随处可见:

kotlin 复制代码
viewModelScope.launch(Dispatchers.IO) {
    val user = repository.getUser()
}

很多开发者已经习惯了这种写法。

需要执行网络请求?

kotlin 复制代码
launch(Dispatchers.IO)

需要访问数据库?

kotlin 复制代码
launch(Dispatchers.IO)

需要调用 Repository?

kotlin 复制代码
launch(Dispatchers.IO)

久而久之,launch(Dispatchers.IO) 似乎变成了协程代码中的"标准模板"。

但随着对协程和异步执行模型理解的深入,我想说:

大部分 launch 并不需要 Dispatchers.IO

因为:

launch 的核心价值不是"切线程",而是"创建异步任务"。

而数据层应该自己负责它所依赖的底层执行模型。


一、我们是不是误解了 launch

先看一个最简单的例子:

kotlin 复制代码
viewModelScope.launch {
    val user = repository.getUser()
    updateUi(user)
}

很多人第一反应是:

这里没有 Dispatchers.IO,那网络请求不是跑在 Main 线程了吗?

这个问题本身其实就暴露了一个误区。

我们把:

kotlin 复制代码
launch

和:

kotlin 复制代码
线程切换

绑定得太紧了。

实际上,launch 的核心语义是:

创建一个新的子协程,也就是一个新的异步任务。

例如:

kotlin 复制代码
coroutineScope {
    launch {
        loadUser()
    }

    launch {
        loadConfig()
    }
}

这里真正重要的是:

text 复制代码
             coroutineScope
              /         \
             /           \
        launch A       launch B
           │              │
       loadUser()      loadConfig()
             \          /
              \        /
              全部完成

我们使用 launch,是因为我们需要两个并发任务。

这里的重点是:

并发。

所以更准确的理解应该是:

text 复制代码
launch
  ↓
创建新的 Coroutine
  ↓
创建新的 Job
  ↓
参与结构化并发
  ↓
由 Dispatcher 决定如何调度

因此:

kotlin 复制代码
launch(Dispatchers.IO)

并不是:

"launch 就是切换到 IO 线程。"

而应该理解成:

创建一个新的并发任务,并指定它使用 Dispatchers.IO 进行调度。

这是两个不同的概念。


二、launchwithContext 解决的问题并不一样

如果我们只是需要把一段代码放到某个 Context 中执行:

kotlin 复制代码
withContext(Dispatchers.IO) {
    blockingOperation()
}

它和:

kotlin 复制代码
launch(Dispatchers.IO) {
    blockingOperation()
}

表达的意图完全不同。

withContext 更接近:

当前任务,暂时使用另一个 Context 执行这段代码。

launch 更接近:

创建一个新的子任务。

可以简单理解为:

text 复制代码
withContext
    ↓
我还是当前这个任务
    ↓
只是换一个 Context 执行

而:

text 复制代码
launch
    ↓
我要创建一个新的任务
    ↓
让它参与并发执行

所以:

kotlin 复制代码
val user = withContext(Dispatchers.IO) {
    database.queryUser()
}

表达的是:

我需要执行一个 IO 操作,执行完以后继续当前流程。

而:

kotlin 复制代码
launch(Dispatchers.IO) {
    database.queryUser()
}

表达的是:

我现在要创建一个新的并发任务。

这两个需求本身就不一样。

因此:

不要把 launch 当成线程切换 API。


三、为什么我越来越少写 launch(Dispatchers.IO)

因为现代 Android 的 Data 层,很多 API 本身已经具备自己的异步执行模型。

例如 Retrofit:

kotlin 复制代码
interface UserApi {

    @GET("user")
    suspend fun getUser(): User
}

Repository:

kotlin 复制代码
class UserRepository(
    private val api: UserApi
) {

    suspend fun getUser(): User {
        return api.getUser()
    }
}

ViewModel:

kotlin 复制代码
viewModelScope.launch {
    val user = repository.getUser()
    updateUi(user)
}

这里:

kotlin 复制代码
viewModelScope.launch

负责的是:

启动一个生命周期属于 ViewModel 的协程任务。

而:

kotlin 复制代码
repository.getUser()

负责的是:

获取数据。

真正的网络请求执行模型,由 Retrofit/OkHttp 这一层负责。

因此,我们没有必要机械地写:

kotlin 复制代码
viewModelScope.launch(Dispatchers.IO) {
    repository.getUser()
}

因为:

ViewModel 并不拥有网络请求的执行模型。

它只是在发起一个任务。


四、Data 层应该成为执行模型的拥有者

这也是我认为非常重要的一个架构原则:

谁拥有执行模型,谁负责调度。

例如 Data 层使用的是异步 API:

kotlin 复制代码
suspend fun getUser(): User {
    return api.getUser()
}

那么:

text 复制代码
ViewModel
    ↓
launch
    ↓
Repository
    ↓
Retrofit suspend API
    ↓
底层异步执行

上层不需要额外添加:

kotlin 复制代码
Dispatchers.IO

因为底层已经有自己的执行模型。

但是,如果 Data 层使用的是阻塞 API:

kotlin 复制代码
fun queryUser(): User {
    return blockingDatabase.query()
}

那么 Data 层就应该负责隔离阻塞操作:

kotlin 复制代码
suspend fun queryUser(): User {
    return withContext(Dispatchers.IO) {
        blockingDatabase.query()
    }
}

这样上层依然可以:

kotlin 复制代码
viewModelScope.launch {
    val user = repository.queryUser()
}

整个调用链就变成:

text 复制代码
ViewModel
    │
    │ launch
    │
    ▼
Repository
    │
    │ 发现底层是 Blocking API
    │
    ▼
withContext(IO)
    │
    ▼
Blocking IO

这里就形成了非常清晰的职责边界:

ViewModel 负责启动任务。
Data 层负责保证 API 的 Main-safe。
底层执行模型决定是否真的需要 Dispatcher。


五、Data 层应该保证 Main-safe

Data 层应该做到的是:

调用者不需要知道 Data 层内部是如何执行的。

例如:

kotlin 复制代码
viewModelScope.launch {
    repository.getUser()
}

无论这个 launch 默认运行在哪里,调用都不能导致:

text 复制代码
Main Thread
    ↓
Repository
    ↓
Blocking IO
    ↓
ANR

因此,Data 层的契约应该是:

即使调用者在 Main 上调用,我也不会阻塞 Main。

这就是 Main-safe。

例如:

kotlin 复制代码
class UserRepository {
    suspend fun getUser(): User {
        return withContext(Dispatchers.IO) {
            blockingApi.getUser()
        }
    }
}

那么调用者完全可以:

kotlin 复制代码
viewModelScope.launch {
    repository.getUser()
}

而不需要关心内部到底使用了:

kotlin 复制代码
Dispatchers.IO

还是:

kotlin 复制代码
Dispatchers.Default

甚至未来底层 API 改成真正的异步 API:

kotlin 复制代码
suspend fun getUser(): User {
    return asyncApi.getUser()
}

上层代码也不需要改变。

这就是职责的隔离。


六、如果底层已经异步,再套一层 IO,只是调度冗余

假设底层 API 已经是异步的:

kotlin 复制代码
suspend fun getUser(): User

上层:

kotlin 复制代码
viewModelScope.launch(Dispatchers.IO) {
    repository.getUser()
}

很多时候,这个 Dispatchers.IO 并没有解决真正的问题。

因为:

text 复制代码
ViewModel
    ↓
launch(IO)
    ↓
Repository
    ↓
Suspend API
    ↓
底层异步执行模型

此时就出现一种"调度冗余":

text 复制代码
上层调度一次
    ↓
底层自己调度
    ↓
实际执行

上层只是为了"保险"加了一层 IO。

但实际上:

它只是 Kotlin 协程生态中的一种 Dispatcher。

真正需要关注的问题应该是:

底层 API 到底是不是阻塞的?

如果是阻塞的:

text 复制代码
Blocking
    ↓
需要隔离
    ↓
可能使用 IO

如果本身已经是异步挂起:

text 复制代码
Suspend / Async
    ↓
已经有执行模型
    ↓
通常不需要上层额外切 IO

所以,与其问:

"这个请求要不要使用 Dispatchers.IO?"

不如先问:

"这个 API 的执行模型是什么?"


七、不要让 launch(IO) 变成条件反射

下面这种代码:

kotlin 复制代码
viewModelScope.launch(Dispatchers.IO) {
    repository.getUser()
}

应该让我们多问一个问题:

为什么这里必须是 IO?

而不是:

为什么这里没有 IO?

如果答案只是:

"因为网络请求。"

那么可能需要重新思考。

如果答案是:

"因为 Repository 内部调用了阻塞 API。"

那么这个 IO 是否应该由 Repository 内部负责?

如果答案是:

"因为数据库操作。"

那么这个数据库 API 是阻塞的,还是已经是 suspend / Flow?

如果答案是:

"因为大家都这么写。"

那么可能就应该删除它。

协程代码真正成熟的标志之一,就是:

开始减少那些没有明确理由的 Dispatcher。


八、重新理解 Coroutine 的三个核心角色

我认为可以把协程中的三个概念简单拆开:

text 复制代码
CoroutineScope
    ↓
生命周期边界

launch
    ↓
创建并发任务

Dispatcher
    ↓
调度策略

因此:

kotlin 复制代码
viewModelScope.launch {
    repository.getUser()
}

表达的是:

text 复制代码
ViewModel 生命周期
        ↓
创建一个任务
        ↓
调用 Repository

而:

kotlin 复制代码
withContext(Dispatchers.IO) {
    blockingOperation()
}

表达的是:

text 复制代码
当前任务
    ↓
执行一段阻塞操作
    ↓
需要使用 IO 调度策略

如果把所有东西都写成:

kotlin 复制代码
launch(Dispatchers.IO)

其实是把:

text 复制代码
生命周期
并发
调度

三个概念全部揉在一起了。

最终代码看起来虽然简单,但职责边界反而变得模糊。


九、我现在更推荐的思考方式

我不会再看到:

kotlin 复制代码
launch {
}

第一反应就是:

"这里是不是漏了 Dispatchers.IO?"

我会先问三个问题:

第一:我需要并发吗?

如果需要:

kotlin 复制代码
launch

如果不需要,只是希望改变执行 Context:

kotlin 复制代码
withContext

第二:底层 API 是阻塞的吗?

如果是:

kotlin 复制代码
withContext(Dispatchers.IO)

或者由更靠近阻塞源的 Data 层负责。

如果本身已经是:

kotlin 复制代码
suspend

或者:

kotlin 复制代码
Flow

并且底层已经有合理的异步执行模型,那么通常不需要再机械地套一层 IO。


第三:谁应该负责调度?

我的答案是:

谁拥有执行模型,谁负责调度。

ViewModel 不应该默认决定整个调用链使用什么 Dispatcher。

Data 层应该保证 Main-safe。

底层库应该负责自己的执行模型。

上层只需要表达业务并发关系。


总结

协程真正解决的问题,从来不只是"线程切换"。

如果把协程理解成:

text 复制代码
Thread
    ↓
换成
Coroutine

那么我们最终还是会陷入:

kotlin 复制代码
launch(Dispatchers.IO)
launch(Dispatchers.Default)
withContext(Dispatchers.IO)

不断切换 Dispatcher 的思维。

但如果从结构化并发的角度看,协程的核心其实是:

text 复制代码
Scope
    ↓
管理生命周期

launch
    ↓
创建并发任务

withContext
    ↓
改变执行 Context

Dispatcher
    ↓
提供调度策略

Job
    ↓
管理任务关系和取消

所以:

launch 的价值不是"切线程",而是"创建并发任务"。
withContext 的价值不是"开启异步",而是"在另一个 Context 中执行代码"。
Dispatchers.IO 不是协程的"异步开关",而是一种调度策略。

而在现代 Android 应用中,如果 Data 层使用的底层 API 本身已经是异步的,那么 ViewModel 层大多数情况下完全可以:

kotlin 复制代码
viewModelScope.launch {
    repository.getUser()
}

把:

text 复制代码
并发

交给 launch

把:

text 复制代码
执行模型

交给 Data 层和底层库,

把:

text 复制代码
调度策略

交给真正拥有执行模型的那一层。

我们最终会发现:

好的协程代码,不应该到处写 Dispatchers.IO,而是让每一层只负责自己真正应该负责的事情。

相关推荐
爱和冰阔落1 小时前
【Linux】从匿名管道到进程池:任务派发、fd 继承 Bug 与完整实现
android·linux·运维·c++
weixin_440784119 小时前
【HandlerThread实现原理】
android·java·开发语言
唐诺11 小时前
Android USB ADB 调试方案完全解析
android·adb·usb
寰宇星城16 小时前
AppActuator - 让 AI Agent 直接理解并操作运行中的 Android App
android
波罗丁牌16 小时前
uni-app 实现视频聊天、屏幕分享,支持Android、HarmonyOS、iOS
android·uni-app·音视频
峥嵘life16 小时前
Android WiFi 热点 Channel 信道 和 Frequency 频率 转换总结
android·开发语言
jieyucx17 小时前
【高级利用】条件竞争与逻辑漏洞:与服务器赛跑的艺术
android·运维·服务器·web安全·文件上传
凡泰AI18 小时前
APP同时覆盖了iOS、安卓和鸿蒙,如何选择混合开发架构才能减少重复建设,提高功能上线效率~
android·ios·harmonyos·mpaas·uni·小程序容器
叶辞树19 小时前
死锁问题分析
android