协程速通 · Android

4. 调度器 Dispatchers

调度器决定协程代码跑在哪个线程 / 线程池。切换用 withContext(Dispatcher),它是挂起函数——切走时不阻塞当前线程,干完再恢复回来。这是协程能「看起来顺序写、实际不卡线程」的核心。

对照你熟悉的 XML 开发

以前在 Activity 里开线程要 new Thread / AsyncTask / Handler.post 手动切;协程里只要 withContext(Dispatchers.IO) 一行,剩下的「切回来」编译器帮你管。

一、四个内置调度器

调度器底层用途Android 典型场景
Dispatchers.Main主线程 LooperUI 操作更新 View、给 StateFlow/LiveData 赋值、弹 Toast
Dispatchers.IO最多 64 线程的线程池IO 密集型Retrofit 网络、Room 数据库、文件读写、SharedPreferences
Dispatchers.DefaultCPU 核数线程池CPU 密集型JSON 大解析、排序、图片压缩、复杂算法
Dispatchers.Unconfined不限定几乎不用测试、或「在哪个线程被唤起就在哪个线程继续」的特殊控制流

二、Dispatchers 全家福(按使用频率)

频率API作用典型场景
🔥 极高withContext(Dispatchers.IO)切到 IO 线程执行并带回结果所有网络 / 数据库调用
🔥 极高Dispatchers.Main主线程(UI)ViewModel 里默认已在 Main,给 UI 状态赋值
🔥 极高Dispatchers.DefaultCPU 密集线程池解析、计算、压缩
高Dispatchers.Main.immediate若已在主线程则不重新派发连续多次切 Main 时省一次多余调度
高Dispatchers.IO.limitedParallelism(n)限制该用途最大并发数数据库 / 单连接资源,避免被 64 并发打满
中Handler.asCoroutineDispatcher()把 Looper/Handler 当调度器想调度到某个特定 Handler 线程
中Executor.asCoroutineDispatcher()复用已有线程池接老项目已有的 ExecutorService
低newSingleThreadContext(...)专属单线程访问非线程安全资源(用完要 close)
低Dispatchers.Unconfined不限定线程测试 / 特殊控制流

三、实战场景

3.1 网络 / 数据库切 IO,UI 留 Main

// Repository 层:把线程切换藏在实现里,对外只暴露 suspend
class UserRepository(private val api: UserApi, private val db: UserDao) {
    suspend fun getUser(id: String): User =
        withContext(Dispatchers.IO) {          // 网络 + 落库都在 IO
            val user = api.fetchUser(id)
            db.insert(user)
            user
        }
}

// ViewModel 层:viewModelScope 默认就在 Main,直接拿结果更新 UI
class UserViewModel(private val repo: UserRepository) : ViewModel() {
    private val _ui = MutableStateFlow<User?>(null)
    val ui: StateFlow<User?> = _ui.asStateFlow()

    fun load(id: String) = viewModelScope.launch {
        _ui.value = repo.getUser(id)          // 不用再手动切 Main,已经在 Main
    }
}
边界

耗时切换只在 Repository 做;ViewModel / UI 永远在 Main,直接赋值即可。别在 Activity 里写 withContext,也别在 Repository 里碰 Context 或 View。

3.2 Main vs Main.immediate:省一次没必要的派发

Dispatchers.Main 即使你已经在主线程,也会先把协程「挂起再派发回主线程」,多一次调度;Main.immediate 会先判断是否已在主线程,在就直接往下跑。

viewModelScope.launch(Dispatchers.Main.immediate) {
    showLoading()                 // 已在主线程,立即执行,不排队
    val data = withContext(Dispatchers.IO) { repo.load() }
    render(data)                  // 恢复后仍在主线程
}
什么时候用 immediate

当你确定调用方大概率已在 Main(比如 ViewModel 里一连串 UI 操作)时,用 Main.immediate 能省掉一次无意义的线程往返。多数 Android 框架作用域默认已是 Main.immediate。

3.3 CPU 密集用 Default,别用 IO

suspend fun parseHugeJson(raw: String): List<Item> =
    withContext(Dispatchers.Default) {        // 解析是 CPU 活,用 Default
        Gson().fromJson(raw, Array<Item>::class.java).toList()
    }

别混用:网络 / 文件是 IO,用 Dispatchers.IO;纯计算是 CPU,用 Dispatchers.Default。把 CPU 重活丢到 IO,会挤占 IO 池、还可能触发默认 IO 池 64 线程上限。

3.4 限制并发:limitedParallelism

默认 Dispatchers.IO 最多 64 个并发。若某资源(如单个 SQLite 连接、第三方限流 API)扛不住,用 limitedParallelism 单独限流,且不影响全局 IO 池。

// 数据库写入最多同时 4 个,其余排队,避免打爆连接
private val dbDispatcher = Dispatchers.IO.limitedParallelism(4)

suspend fun saveAll(list: List<User>) =
    withContext(dbDispatcher) { list.forEach { db.insert(it) } }

3.5 把协程调度到自定义线程

// 把已有的 Handler 变成调度器(如某个后台 Looper 线程)
val workerDispatcher = Handler(workerLooper).asCoroutineDispatcher()
// 或复用老线程池
val poolDispatcher = myExecutor.asCoroutineDispatcher()

四、真实开发中会踩的坑

坑 1:在主线程做重活 → ANR:viewModelScope 默认在 Main。如果你在里面直接 repo.parseBigFile() 却没切 Dispatchers.Default/IO,解析就在主线程跑,列表卡死甚至 ANR。记住:凡耗时,必 withContext。

坑 2:以为 withContext 是「异步并行」:withContext 是顺序的——它等里面的活干完才返回。想并行要用 async(见 07 章)。在 withContext(IO) 里再套 withContext(IO) 只是多一次无谓切换,没有性能收益。

坑 3:delay 当 sleep 用:delay(1000) 是非阻塞的挂起(让出线程);Thread.sleep(1000) 是阻塞整个线程。在主线程用 Thread.sleep 必 ANR,协程里永远用 delay。

坑 4:把调度器当成「线程」:一个 Dispatchers.IO 背后是线程池;同一个调度器上的不同协程可能跑在不同线程。不要假设「下一个挂起恢复还在同一线程」——除非用 Main.immediate 或单线程调度器。

五、小结

  • UI 用 Main,网络 / 文件用 IO,计算用 Default。
  • 切换一律 withContext(...),它是挂起、不阻塞。
  • 耗时操作切换放进 Repository,ViewModel / UI 保持主线程的干净写法。
  • 需要限流用 limitedParallelism;已在主线程想省切换用 Main.immediate。

Kotlin 协程速通 · Android 开发 · 内容基于 kotlinx.coroutines 1.7+