主线程只有一条,一帧只有 16.7 毫秒
「把重活放后台」这条建议你听了一百遍。这一章不重复它,而是把账算给你看:一份 120 毫秒的活放在主线程上,具体掉几帧、最长的那次卡顿多久、用户会不会看得见。
一帧的预算
60Hz 屏幕:一帧 = 1000 / 60 = 16.667 ms
90Hz:11.1 ms 120Hz:8.3 ms
这 16.667 毫秒里,主线程要做完:
处理输入事件 → 组合 → 布局 → 绘制 → 提交给渲染线程
超时了会怎样:这一帧没画完,屏幕重复显示上一帧 —— 就是「掉帧」。
连着掉几帧,用户就说「卡了一下」。
注意高刷屏的预算更紧。同一段代码在 60Hz 上勉强及格,在 120Hz 上就掉帧——而现在的中高端机基本都是高刷。
把账算出来
下面这台调度器有真的线程时间线:每个调度器有自己的线程数,每段 CPU 活占用一条线程的一段时间,然后按 16.667 毫秒一格去数「有几帧的活开不了工」。
默认那份 120 毫秒的活(差不多是解析一个中等大小的 JSON,或者给一张图做一次缩放):
放哪儿 线程数 主线程被占用 掉帧 最长卡顿 Dispatchers.Main 1 条 120.0 ms 7 帧 120.0 ms Dispatchers.Default 4 条 0.0 ms 0 帧 0.0 ms Dispatchers.IO 64 条 0.0 ms 0 帧 0.0 ms
7 帧。用户看到的是一次明显的顿挫。而这个数字随着活变重是线性涨的——把滑杆拖到 400 毫秒,就是二十几帧,那已经是「App 卡死了」的观感。
三个调度器的分工
| 线程数 | 给什么用 | 典型任务 | |
|---|---|---|---|
Dispatchers.Main | 1(就是主线程) | 碰 UI 的一切 | 更新 state、导航、显示 Toast |
Dispatchers.Default | = CPU 核数(至少 2) | 算东西:线程一直在忙 | 解析、排序、压缩、加解密、图像处理 |
Dispatchers.IO | 默认上限 64 | 等东西:线程大部分时间阻塞着 | 文件、数据库、网络(阻塞式的)、SharedPreferences |
为什么 IO 敢开 64 条而 Default 只开核数那么多?因为两者的瓶颈不一样:
Default 的活是「算」 → 瓶颈是 CPU 核数
开再多线程也不会更快,只会多花上下文切换的钱
IO 的活是「等」 → 瓶颈是对方(磁盘、网络)
线程阻塞的时候不占 CPU,多开几条只是多几个等待位
这两个不是两个独立的池子——它们背后是同一个池,只是各自有一个并发上限。所以 withContext(Dispatchers.IO) 从 Default 切过去通常不需要真的换线程,只是换了个「配额」。这让这次切换非常便宜。
还有一个很少人知道的 API,用来给某一类任务限流:
// 我这个功能最多同时占 4 条 IO 线程,别把 64 条全吃了 private val myIo = Dispatchers.IO.limitedParallelism(4)
做批量上传、批量下载的时候很有用——否则一个功能可以把整个 App 的 IO 线程全占满。
主线程安全:应该由谁来切
这是这一章最有价值的一条规矩,而且它常被写反。
// ❌ 要求调用方记得切
suspend fun parseJson(s: String): Data { … } // 内部纯 CPU
// 调用方:
withContext(Dispatchers.Default) { parseJson(s) } // ← 忘一次就掉帧
// ✅ 函数自己保证
suspend fun parseJson(s: String): Data = withContext(Dispatchers.Default) { … }
// 调用方:
parseJson(s) // 在哪儿调都安全
规矩叫主线程安全(main-safety):
一个 suspend 函数应该保证:在任何线程上调用它都不会卡住那个线程。
责任在函数自己,不在调用方。理由很实际:函数只有一个,调用方有很多个;让一个地方负责,比让十个地方记得住可靠得多。
Room 和 Retrofit 都遵守这条——它们的 suspend 方法内部已经切好了,所以你不需要再包一层 withContext(Dispatchers.IO)。包了也不错,但那是多余的一次调度。
① 以为 Dispatchers.IO 是「后台线程」的通用答案。把一段纯 CPU 的活丢进 IO,它确实不卡主线程了,但它可能占着一条本该用来等网络的线程,而且 64 条线程同时算东西会把 CPU 抢得一塌糊涂。算东西用 Default。
② 以为加了 suspend 就自动去后台了。第 9 章说过:suspend 只是给了运行时切换的机会,切不切要看有没有 withContext。一个没有任何挂起点、也没有 withContext 的 suspend 函数,就是在调用者的线程上老老实实跑完。
Compose 的 Main 和别的 Main 不太一样
有一个细节值得知道:Dispatchers.Main 和 Dispatchers.Main.immediate 的差别。
Main——总是往主线程的消息队列里投递一次,哪怕你已经在主线程上Main.immediate——已经在主线程就直接跑,不投递
差别在一帧的时序上会体现出来:用 Main 的话,你的状态更新会排到下一次消息循环,可能就错过了这一帧。所以 Compose 内部、以及 lifecycleScope、repeatOnLifecycle 这些地方用的都是 Main.immediate。
你自己写的时候:更新 UI 状态用 Main.immediate,没有特别理由不要用 Main。不过大多数时候你根本不需要写——viewModelScope 默认就是 Main.immediate。
那到底什么该切后台
一个能用的判据:这段代码在一台三年前的中低端机上会跑多久?
| 大概耗时 | 怎么办 |
|---|---|
| < 1 ms | 随便放。切一次线程本身也要成本,不值得 |
| 1 – 8 ms | 看情况。如果它每帧都跑(在 composable body 里、在滚动回调里),就该挪走 |
| > 8 ms | 一定挪走。这已经吃掉了一帧预算的一半 |
| 不确定(取决于数据量) | 按最坏情况算。「通常 3 毫秒」的东西在用户那儿可能是 300 毫秒 |
最后一行是最容易踩的:你在测试数据上跑 10 条记录,用户有 10000 条。凡是耗时正比于数据量的操作,一律当成重活处理。
不要靠猜,Android Studio 有现成的工具直接告诉你哪一帧超时了:
Android Studio ▸ Profiler ▸ 选进程 ▸ Capture System Activities(系统跟踪)
▸ 操作一遍那个卡的地方 ▸ 停止
▸ 看 Display 那一行的 Frame Lifecycle:
绿色 = 按时 红色 = 超时(掉帧)
▸ 点一个红条,下面会告诉你这一帧卡在哪个阶段
命令行版本(更快,适合做回归):
adb shell dumpsys gfxinfo <你的包名> framestats
线上版本:
JankStats 库(androidx.metrics)——把掉帧数据上报回来,
因为你的开发机永远比用户的机器快。
- 一帧 16.667 ms(60Hz)/8.3 ms(120Hz)。主线程只有一条。
- Default 给「算」(线程数 = 核数),IO 给「等」(64 条),Main 只给 UI。
- 主线程安全是函数自己的责任,不是调用方的。
- 「> 8 ms 就挪走」是个够用的判据,而且要按最坏数据量算。
卷 III 到此结束
四章下来,协程这一侧的三个动词也齐了:
- 活下来——状态机对象上的那几个
L$字段(第 9 章) - 重跑——
resumeWith之后从label指定的分支接着跑(第 9 章) - 取消——挂牌子 + 你自己看牌子(第 10、11 章)
下一卷把这三个动词再搬一次,搬到 Flow 上。你会发现collect 一次就是「跑一遍配方」,热流的 replay 就是「让它活下来」,collectLatest 就是「把上一个取消掉」——同一套东西,第三次出现。
这一章的一句话
主线程只有一条,一帧只有 16.667 毫秒。一份 120 毫秒的活放错地方就是 7 帧。而「切到哪儿」这件事,应该由那个函数自己负责,不是由每个调用方去记。
下一章:Flow 是什么?大多数人的答案是「一条数据流」。这个比喻会让你写出「同一个页面发两遍网络请求」的代码——它不是一条河,是一张菜谱。