Appearance
Android 主线程消息循环:Looper / Handler / MessageQueue
在模块中的位置:运行时入口组。与 运行时承载模型入口、JS event loop / Promise / Worker、Dart 事件循环与队列 一起构成"单线程消息调度机制"的完整对照。 相关主题:Kotlin Dispatchers(
Dispatchers.Main底层就是 Handler 调度)
一句话定义
Android 的主线程(UI 线程)是一个 Looper 驱动的消息循环:Handler 是"往某个 Looper 队列投递消息 / 处理消息"的入口,MessageQueue 是按执行时间排序的待处理队列。UI 更新必须回到主线程,本质就是因为"往主线程的 Looper 队列投了一条消息"。
代码索引
对应 Lab:looper-handler-demo
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| Looper / Handler / MessageQueue / HandlerThread | 入队顺序、延迟排序、子线程回传与串行队列 | LooperHandlerDemoActivity.kt |
为什么需要
很多 Android 工程师天天写 runOnUiThread、View.post、Handler.postDelayed,但真正问到下面这些问题时就开始含糊:
- 为什么不能在子线程更新 UI?
- 一句话答:因为 Android View 体系非线程安全,
ViewRootImpl校验checkThread()会直接抛出CalledFromWrongThreadException。(详见下文 §1)
- 一句话答:因为 Android View 体系非线程安全,
- 为什么
runOnUiThread/View.post能"切回"主线程?- 一句话答:因为它们底层将 Runnable 封装为 Message 投递到了主线程的
MessageQueue,由主线程Looper循环取出执行。(详见下文 §1、§2)
- 一句话答:因为它们底层将 Runnable 封装为 Message 投递到了主线程的
- 为什么主线程卡顿会触发 ANR,子线程卡顿不会?
- 一句话答:系统 AMS/InputDispatcher 监控的是主线程
MessageQueue的响应耗时,子线程卡顿不影响系统事件循环分发。(详见下文 §3)
- 一句话答:系统 AMS/InputDispatcher 监控的是主线程
Handler.postDelayed是"精确定时"吗?- 一句话答:不是,它按单链表按时间排序插入队列,若前面有长任务卡住,延迟任务会被滞后执行。(详见下文 §2)
Dispatchers.Main和 Handler 是什么关系?- 一句话答:
Dispatchers.Main底层就是通过主线程Handler.post把协程 Continuation 恢复任务调度到主线程执行。(详见下文 §4)
- 一句话答:
如果说不清,工程上就很容易出现:
- 把
postDelayed当精确定时器用,任务堆积或时序错乱 - 在子线程直接改 View 抛
CalledFromWrongThreadException,靠 try-catch 兜底而不是理解机制 - 主线程 Handler 队列被长任务阻塞,以为"页面慢"其实是消息队列堵车
- 以为
HandlerThread随便起线程就线程安全,其实它只是"带 Looper 的工作线程"
底层机制
1. 主线程 = 一个死循环 + 一个消息队列
对应 Lab:looper-handler-demo
ActivityThread.main() 里做了两件事:Looper.prepareMainLooper() 然后 Looper.loop()。loop() 是个死循环:
text
Looper.loop()
└─ 循环:
├─ MessageQueue.next() // 取下一条消息(没有就阻塞等待)
├─ msg.target.dispatchMessage(msg) // 交给绑定的 Handler 处理
└─ 处理完继续取下一条1
2
3
4
5
2
3
4
5
- Looper:消息循环的"发动机",一个线程最多一个(
ThreadLocal保存)。 - MessageQueue:按
when(执行时间)排序的队列,next()在没消息时会让线程休眠(内部用 epoll 等待唤醒,不是忙等)。 - Handler:消息的"投递口 + 处理口"。
handler.post(runnable)本质是把 Runnable 包装成Message,target指向该 Handler,塞进构造 Handler 时绑定那个 Looper 的队列。
java
// 工作线程里往主线程投递一条消息
runOnUiThread(() -> textView.setText("done"));
// 等价于 mainHandler.post(() -> textView.setText("done"));
// 等价于 mainHandler.sendMessage(Message.obtain(mainHandler, () -> textView.setText("done")));1
2
3
4
2
3
4
预期现象:无论从哪个线程调用 post,消息最终都在主线程的 loop 循环里被取出来执行——这就是"切回主线程"的真正含义:不是线程切换,是跨线程投递 + 单线程消费。
观察重点:Handler 绑定的是"创建它时所在线程的 Looper"(new Handler() 在子线程会因无 Looper 抛异常),所以"哪个 Handler"决定了"消息进哪个队列"。
比喻(银行柜台) :主线程像只有一个柜员的银行网点。Looper 是柜员本人,MessageQueue 是取号队列,Handler 是你手里的叫号窗口——你在任何地方都能往这个窗口塞业务单(Message),但办理永远只有柜员一个人,且按号码顺序。塞得再多,柜员一次只能办一件。
2. 为什么不能在子线程更新 UI
不是"技术上不能",而是契约:UI 框架(View/Window)不是线程安全的,Android 规定所有 UI 操作必须在主线程。ViewRootImpl.checkThread() 检查当前线程是不是主线程,不是就抛 CalledFromWrongThreadException。
java
// 错误:子线程直接改 View
new Thread(() -> textView.setText("x")).start(); // 崩溃:Only the original thread...1
2
2
正确做法是把改动投递回主线程:
java
new Thread(() -> {
final String result = heavyCompute(); // 子线程做重活
textView.post(() -> textView.setText(result)); // 把结果投回主线程消费
}).start();1
2
3
4
2
3
4
对照点:这和我们前面学的"协程 withContext(Main) 恢复后 render() 回到主线程"是同一件事——Kotlin 的 Dispatchers.Main 底层就是一个基于 Handler 的调度器(HandlerContext,主线程 Handler + Looper.getMainLooper())。协程"回主线程"= 往主线程 Looper 队列投消息。
3. postDelayed 不是精确定时器
postDelayed(r, delayMs) 只是把消息的 when 设为 now + delayMs,最早在这个时间之后执行;如果队列前面有长任务,实际执行会推迟:
java
mainHandler.postDelayed(taskA, 0); // 进队列
mainHandler.post(longTask); // 先执行,可能耗时 2s
// taskA 实际触发远晚于 0ms —— 它排在这条长任务后面1
2
3
2
3
预期现象:postDelayed 保证"不早于",不保证"准点"。精确时序场景(倒计时、动画节拍)应使用 Choreographer(帧回调)或 AlarmManager(系统级定时),而不是堆 Handler 消息。
4. HandlerThread:带 Looper 的工作线程
HandlerThread = 一个在 run() 里先 Looper.prepare() 再 Looper.loop() 的线程。它让"串行队列执行"的模式也能用在后台:
java
HandlerThread worker = new HandlerThread("db-writer");
worker.start();
Handler dbHandler = new Handler(worker.getLooper());
dbHandler.post(() -> writeDb()); // 串行排队执行,天然单线程顺序写1
2
3
4
5
2
3
4
5
预期现象:多个 post 在 HandlerThread 上串行执行(同一时刻只有一条),适合"必须保顺序"的任务(日志落盘、DB 写、文件追加)。
常见误区:HandlerThread 只是"单线程 + 队列",不等于线程安全——如果多个线程共享一个可变对象又各自直接改它(不经 handler post),照样竞态。
5. IdleHandler:队列空闲时做"顺手事"
MessageQueue.addIdleHandler() 注册的回调,在消息队列暂时为空时执行一次(返回 false 则移除):
java
Looper.myQueue().addIdleHandler(() -> {
preloadNextPageData(); // 首帧渲染完的空闲时间做预加载
return false; // 只执行一次
});1
2
3
4
2
3
4
预期现象:适合延迟初始化、预加载、内存整理这类"有空才做、不做也行"的任务;不适合放必须执行的关键逻辑(空闲不保证发生)。
6. ANR 的调度本质
ANR(Application Not Responding)是主线程没有及时处理完关键输入:
| ANR 类型 | 超时 | 触发条件 |
|---|---|---|
| Input 事件 | 5 秒 | 触摸/按键事件 5s 没处理完 |
| Broadcast | 10 秒(前台)/ 60 秒(后台) | 广播接收器超时 |
| Service | 20 秒 | Service 生命周期回调超时 |
本质:主线程 Looper 队列被前面的长任务(等锁、get()、大计算、Binder 阻塞)堵住,后面的关键消息(Input)迟迟处理不到 → 系统判定 ANR。子线程卡顿不触发 ANR,因为它不是"UI 响应"的担当线程——这正是"主线程是唯一响应线程"的代价。
排障第一步:抓 ANR trace(/data/anr/ 或 DropBox),看主线程栈顶在哪——是等锁(Object.wait / synchronized)、等 Future.get()、还是长计算;再顺着等待链找是哪个后台线程/线程池/锁把它拖住。
Android / Flutter / Web / Backend 对照
| 维度 | Android 主线程 | Flutter UI isolate | JS / Web | Dart 事件循环 |
|---|---|---|---|---|
| 单线程调度器 | Looper + Handler + MessageQueue | 引擎 UI 线程上的事件循环 | event loop | event loop |
| 投递入口 | Handler.post / View.post / runOnUiThread | scheduleMicrotask / Future / setState | Promise.then / setTimeout | Future / Stream |
| 高优先级队列 | 无 microtask(只有普通消息 + IdleHandler) | microtask queue | microtask queue | microtask queue |
| 判定"卡死" | ANR(Input 5s) | jank(帧超时) | 页面无响应提示 | jank |
| 跨线程回到 UI | 投消息到主线程 Looper | Isolate.run + 结果回 UI isolate | postMessage | 同 isolate 内回调 |
对照点:Android Looper、JS event loop、Dart event loop 本质都是"单线程 + 消息队列调度";主要差异在优先级队列设计——JS/Dart 有 microtask 层,Android 没有对应的 microtask(只有普通消息 + 空闲回调 IdleHandler)。理解这一点,"为什么 Promise.then 比 setTimeout 早"的直觉,在 Android 侧对应的就是"消息队列顺序与阻塞"。
常见场景
1. 耗时任务后回主线程更新 UI
网络/DB/计算放子线程(或协程 Dispatchers.IO),结果投回主线程再改 View——View.post、runOnUiThread、协程 withContext(Main) 三选一,本质都是"往主线程 Looper 投消息"。
2. 串行后台队列(HandlerThread)
需要"保顺序 + 单线程消费"的后台任务(日志写盘、DB 写、文件追加),用 HandlerThread 串行化,避免多线程写同一文件竞态。
3. 空闲时机做预加载(IdleHandler)
首帧渲染完成后的空闲窗口预加载下一页数据、做内存整理,不抢用户交互的响应时间。
常见误配、事故后果与排障
| 坑 | 现象 | 修法 |
|---|---|---|
| 子线程直接改 View | CalledFromWrongThreadException 崩溃 | 一律 View.post / runOnUiThread / withContext(Main) |
| 主线程 Handler 队列被长任务堵住 | 点击无响应、掉帧、最终 ANR | 长任务移出主线程;排查等锁 / get() / 大计算 |
postDelayed 当精确定时器 | 时序漂移、任务堆积 | 帧驱动用 Choreographer;系统定时用 AlarmManager |
| 滥用 HandlerThread | 以为"线程 = 安全" | 只经 handler post 访问共享对象,或仍加锁/不可变 |
new Handler() 在无 Looper 线程 | 抛 Can't create handler inside thread | 用 HandlerThread.getLooper() 或 Handler(Looper.getMainLooper()) |
排障口诀:主线程问题先看"队列里堵了什么"——ANR trace 的栈顶就是答案;再顺着等待链(锁 / Future / Binder)找源头线程。
与相近概念对比
| 概念 | Android 里对应什么 | 本质区别 |
|---|---|---|
Java Executor | 线程池 | Handler 绑定特定线程的队列;Executor 按策略调度到池内任意线程 |
Kotlin Dispatchers.Main | Handler 驱动的调度器 | 协程恢复时经 Handler 投回主线程队列 |
JS setTimeout | postDelayed | 都进"定时消息",都是"不早于",不是准点 |
Dart Future | Handler.post 的异步 | 同属单线程事件调度,Dart 有 microtask 优先级层 |
AsyncTask(已废弃) | 曾经的"线程+回调"封装 | 本质还是 Handler 回主线程,但生命周期缺陷被协程取代 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| looper-handler-demo | 消息入队顺序、postDelayed 排序、HandlerThread 串行任务与子线程回传 | LooperHandlerDemoActivity.kt |
复习检查题
Handler.post(runnable)为什么能让代码"回到主线程"执行?答:
post把 Runnable 包装成Message投进绑定该 Handler 的 Looper 的队列;主线程的loop()死循环从队列取出消息并dispatchMessage,所以在主线程执行。"切回主线程"本质是跨线程投递 + 单线程消费。为什么主线程卡顿会 ANR,子线程卡顿不会?
答:ANR 是"主线程未及时处理关键消息(Input 5s / Broadcast 10s)"的判定;主线程是唯一响应 UI 的线程,它的 Looper 队列被前面长任务堵住就触发 ANR。子线程不承担 UI 响应,卡住不会触发 ANR。
postDelayed能当精确定时器吗?答:不能。它只保证消息"不早于 delay 后执行",队列前面有长任务会推迟;精确时序应使用
Choreographer(帧驱动)或AlarmManager(系统级定时)。Dispatchers.Main和 Handler 是什么关系?答:
Dispatchers.Main底层就是基于主线程Handler(HandlerContext,绑定Looper.getMainLooper())的调度器;协程"回到 Main"= 往主线程 Looper 队列投恢复任务。HandlerThread 为什么不是"线程安全"的代名词?
答:HandlerThread 只是"一个带 Looper 的串行线程",保证经 handler 投递的任务串行执行;但多个线程直接改共享可变对象(不经 handler)仍然竞态。
速记
- 主线程 = Looper 死循环 + MessageQueue + Handler 投递口
post/runOnUiThread/withContext(Main)本质都是"投消息回主线程"postDelayed是"不早于",不是准点- HandlerThread = 带 Looper 的后台串行队列,不等于线程安全
- ANR = 主线程队列被堵,Input 5s 没处理完
- Android 没有 microtask 层;JS / Dart 的 microtask 优先级在 Android 侧没有直接对应