详情页点开一张图片,应用要读取文件、解码缩略图,再把结果显示出来。把这些工作直接放在点击回调里,主线程可能卡住;于是有人写
Thread { ... }.start(),也有人换成Handler、协程或 WorkManager。它们看起来都能"把活放到后台",但并不在做同一件事。
一句话结论:Thread 和 HandlerThread 会创建具体线程,线程池负责复用线程;协程与 WorkManager 主要管理任务的调度和生命周期,不能简单理解成"另一种 new Thread"。
本文讨论 Android 应用侧的常见选择,不追踪 ART 和 Linux 内核如何实现线程。示例以 Kotlin 和现代 AndroidX 的概念为主;AndroidX 库的具体 API、依赖版本要以项目配置为准。AsyncTask 从 Android 11(API 30)起已弃用,只作为旧代码辨认对象。
1. 先分清:创建线程,还是提交任务?
Android 主线程负责事件分发、界面更新和绘制。耗时工作占住它,输入与绘制就要等待。真正要设计的是:谁执行任务、任务完成后结果交给谁、页面离开时谁取消或清理。
| 方式 | 会不会为这次任务新建一条线程 | 更适合的工作 | 生命周期要点 |
|---|---|---|---|
Thread |
调用 start() 后创建一条线程 |
很少量、一次性的简单后台工作或教学实验 | 自己处理结果回传、取消和异常。 |
ExecutorService / ThreadPoolExecutor |
按池配置创建并复用工作线程 | 多个短任务、需要限制并发的工作 | 明确队列容量、拒绝策略与关闭时机。 |
HandlerThread |
启动一条自带 Looper 的线程 |
需要串行处理消息、依赖 Handler 回调的工作 |
停用时调用 quitSafely() 或相应清理。 |
| Kotlin 协程 | 一个协程通常不对应一条专属线程 | 随页面或 ViewModel 生命周期运行的异步任务 | 选择合适的 CoroutineScope 和 Dispatcher,配合取消。 |
| WorkManager | 不承诺立刻创建线程,也不承诺马上执行 | 可延后、需要跨进程退出继续安排的后台任务 | 由系统条件和调度决定执行时间,任务需可重试。 |
表里还有几个经常被误叫成"开启线程"的东西。Runnable、Callable 是任务接口,FutureTask 是可承载结果的任务包装;它们交给 Thread 或执行器后才会运行。Handler.post() 则把任务投递到它绑定的 Looper 所在的线程 :绑定主 Looper 就在主线程执行,绑定 HandlerThread.looper 才会在那条后台线程执行。
2. Thread 与线程池:从能跑到能控制
直接创建线程最容易看懂:
kotlin
val mainHandler = Handler(Looper.getMainLooper())
Thread({
val result = decodeThumbnail() // 示例:阻塞式读取和解码
mainHandler.post { showThumbnail(result) }
}, "thumbnail-worker").start()
调用 start() 后,新线程执行 decodeThumbnail();它完成后向主线程消息队列投递更新。这里的 decodeThumbnail() 和 showThumbnail() 是业务占位函数,示例省略了异常处理、页面销毁检查与取消逻辑。Thread(...).run() 只会在当前线程 直接执行 run(),不会开启新线程。
每次点击都创建一条原始线程,任务多时会遇到并发失控、资源消耗和难以统一取消的问题。线程池把"提交工作"和"创建多少线程"分开:业务调用 execute() 或 submit(),池决定复用哪条工作线程、是否排队以及队列满时怎样拒绝。
在生产项目里,我会先问池由谁持有、最多同时跑几个任务、最多排队多少个、拒绝时如何反馈。Executors.newFixedThreadPool() 适合展示复用概念,但默认队列可以持续增长;有明确流量上限的业务可直接配置有界队列的 ThreadPoolExecutor。任务执行中抛异常、Future.cancel() 的协作式取消、shutdown() 的时机也要按持有者设计。不要在主线程上调用 Future.get() 等待结果,它可能把界面卡住。
3. HandlerThread 为什么不等于普通的 Thread?
普通 Thread 的 run() 结束,线程就结束。HandlerThread 会为自己的线程准备 Looper 和消息队列,方便外部通过 Handler 连续投递任务。下面是一个串行工作者的最小结构:
kotlin
import android.os.Handler
import android.os.HandlerThread
import java.io.Closeable
class SerialWorker : Closeable {
private val thread = HandlerThread("serial-worker").apply { start() }
private val handler = Handler(thread.looper)
fun submit(task: Runnable): Boolean = handler.post(task)
override fun close() {
thread.quitSafely()
}
}
创建 SerialWorker 时启动一条线程;submit() 把任务放进它的消息队列,按该线程的消息循环执行。close() 应由持有者在结束使用时调用;之后 post() 可能返回 false,调用方不能假设任务已经排上。若只是要串行执行普通工作、完全不需要 Looper,单线程执行器通常更直接。
Handler(Looper.getMainLooper()) 与上面的 Handler(thread.looper) 用的是同一种投递 API,目标线程却不同。排查"为什么 UI 仍然卡"时,先看 Handler 绑定了哪个 Looper,不要只看见 post() 就认定工作已到后台。
4. 协程与 WorkManager:重点在任务归属
Kotlin 协程能让异步流程按顺序写,但 suspend 函数本身不会自动创建线程。以页面详情数据为例,viewModelScope.launch 默认从主线程上下文启动,遇到真正的阻塞式读取时再切到 Dispatchers.IO:
kotlin
fun loadDetail() {
viewModelScope.launch {
try {
val detail = withContext(Dispatchers.IO) {
repository.readBlocking()
}
uiState.value = DetailState.Success(detail)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: IOException) {
uiState.value = DetailState.Error(error)
}
}
}
这是 ViewModel 内的可改造片段:repository、uiState 和 DetailState 由具体业务提供。阻塞读取进入共享的 IO 调度器,结果回到原协程上下文后更新 UI 状态。viewModelScope 随 ViewModel 清理而取消;取消通常需要协作,阻塞 I/O 可能还要关闭底层资源。若调用的库已经提供正确实现的挂起 API,不必机械地为每次调用再包一层 withContext(Dispatchers.IO)。
如果任务必须在用户离开页面后继续安排,例如稍后同步离线记录、满足网络条件后上传日志,就应看 WorkManager。它把任务交给 Android 的后台调度体系,支持约束、重试和持久安排;它不适合"用户点按钮后必须立刻拿到结果并刷新当前界面"的工作 。即使用 CoroutineWorker,也不意味着"一次工作对应一条新线程",执行时机仍受系统调度与约束影响。
lifecycleScope 适合跟 Activity 或 Fragment 生命周期绑定的工作,viewModelScope 适合跟 ViewModel 绑定的工作;WorkManager 的范围更长。选它们时,先决定工作应该活多久,再决定在哪个执行器或调度器上运行。
5. 具体怎么选,哪些坑要避开?
| 遇到的任务 | 我会先选 | 理由 |
|---|---|---|
| 页面内一次请求或文件读取 | 生命周期明确的协程;阻塞调用切到合适的 Dispatcher | 结果、异常和取消能跟页面或 ViewModel 对齐。 |
| 多个独立的 Java/SDK 阻塞任务 | 有并发与队列上限的 ExecutorService |
集中管理线程数量和拒绝。 |
串行的消息或依赖 Looper 的回调 |
HandlerThread |
需要一条可持续投递消息的专用线程。 |
| 退出页面后仍需按条件执行 | WorkManager | 工作由持久调度管理,不依赖当前界面存活。 |
| 只把结果交回 UI | 主线程 Handler 或协程回主调度器 |
这是切回主线程,不是在创建后台线程。 |
旧教程里的 AsyncTask 也做"后台执行后回调主线程",但它从 API 30 起已弃用,新代码不应围绕它设计。RxJava 的 Scheduler 同样是调度任务的抽象;subscribeOn(Schedulers.io()) 不能简单读成"每次订阅 new 一条线程"。
最容易出错的不是忘了写 start(),而是后台工作已经完成,原页面却已经不存在。把任务交给正确的所有者,并在回传结果时检查它是否仍有效;网络错误、取消、队列拒绝和资源释放也要有明确路径。这样选出的"开启线程方式",才真正服务于 Android 的生命周期。