4. 调度器 Dispatchers
调度器决定协程代码跑在哪个线程 / 线程池。切换用 withContext(Dispatcher),它是挂起函数——切走时不阻塞当前线程,干完再恢复回来。这是协程能「看起来顺序写、实际不卡线程」的核心。
以前在 Activity 里开线程要 new Thread / AsyncTask / Handler.post 手动切;协程里只要 withContext(Dispatchers.IO) 一行,剩下的「切回来」编译器帮你管。
一、四个内置调度器
| 调度器 | 底层 | 用途 | Android 典型场景 |
|---|---|---|---|
Dispatchers.Main | 主线程 Looper | UI 操作 | 更新 View、给 StateFlow/LiveData 赋值、弹 Toast |
Dispatchers.IO | 最多 64 线程的线程池 | IO 密集型 | Retrofit 网络、Room 数据库、文件读写、SharedPreferences |
Dispatchers.Default | CPU 核数线程池 | CPU 密集型 | JSON 大解析、排序、图片压缩、复杂算法 |
Dispatchers.Unconfined | 不限定 | 几乎不用 | 测试、或「在哪个线程被唤起就在哪个线程继续」的特殊控制流 |
二、Dispatchers 全家福(按使用频率)
| 频率 | API | 作用 | 典型场景 |
|---|---|---|---|
| 🔥 极高 | withContext(Dispatchers.IO) | 切到 IO 线程执行并带回结果 | 所有网络 / 数据库调用 |
| 🔥 极高 | Dispatchers.Main | 主线程(UI) | ViewModel 里默认已在 Main,给 UI 状态赋值 |
| 🔥 极高 | Dispatchers.Default | CPU 密集线程池 | 解析、计算、压缩 |
| 高 | 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) // 恢复后仍在主线程
}
当你确定调用方大概率已在 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。