Kotlin 协程实战避坑:90% 的人都会踩的生命周期泄漏问题
协程很轻,但"轻"不代表不会泄漏。
真正出事的不是协程本身,而是:协程比启动它的页面/组件活得更久。
一、先说结论:协程泄漏的本质
协程泄漏不是"内存一下子爆了",而是这几种慢性中毒:
- Activity / Fragment 销毁了,协程还在跑
- 网络结果回来后更新已经销毁的 View → crash
- ViewModel 里持有 Activity / Context 强引用
- Flow 一直在收集,界面都不可见了还在消费数据
- 自定义 Scope 忘了
cancel(),僵尸协程越积越多
一句话:
没有"父作用域"管着的协程,迟早变成泄漏。
二、最经典的坑:GlobalScope.launch
ini
// ❌ 生产环境最常见反面教材
GlobalScope.launch {
val user = api.getUser()
textView.text = user.name // Activity 都没了你还更新?
}
GlobalScope 跟 App 进程同寿:
- 不随 Activity 销毁
- 不随 Fragment 销毁
- 不随 ViewModel 清除
- 协程里拿了
this@Activity,Activity 就 GC 不掉
后果:
- 内存占用悄悄涨
- 回包后
IllegalStateException: Fragment not attached - 老用户数据覆盖新用户数据(金融/聊天场景尤其致命)
✅ 正确姿势:
ini
// Activity / Fragment 里
lifecycleScope.launch {
val user = api.getUser()
textView.text = user.name
}
// ViewModel 里
viewModelScope.launch {
_uiState.value = uiState.copy(user = api.getUser())
}
三、viewModelScope ≠ lifecycleScope,用错也会"逻辑泄漏"
很多人以为:"都用生命周期 Scope 就安全了。"
但选错 Scope,一样出问题。
1. viewModelScope
-
归属:ViewModel
-
销毁时机:
ViewModel.onCleared() -
抗旋转:✅ 屏幕旋转不会取消
-
适合:
- 网络请求
- 数据库读写
- 业务状态计算
kotlin
class UserViewModel : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch {
_state.value = State.Loading
_state.value = State.Success(repo.getUser(id))
}
}
}
2. lifecycleScope
-
归属:Activity / Fragment
-
销毁时机:组件 DESTROYED
-
旋转会重建 → 协程也会被取消重启
-
适合:
- 一次性 UI 操作
- 弹窗、动画、临时任务
3. Fragment 里还有一个隐藏坑:viewLifecycleOwner
arduino
// ❌ 用 Activity 的 lifecycle 收集 Flow
lifecycleScope.launch {
viewModel.userFlow.collect { ... }
}
Fragment 里 View 没了,但 Fragment 本身还没死,这时候更新 View 就会炸。
✅ 正确:
ini
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.userFlow.collect {
binding.tvName.text = it.name
}
}
}
ViewModel 出状态,UI 用 repeatOnLifecycle 收状态。
四、Flow 是重灾区:launchIn(GlobalScope) 直接劝退
arduino
// ❌ 永远别这么写
viewModel.userFlow
.onEach { textView.text = it.name }
.launchIn(GlobalScope)
正确写法二选一:
写法 1:Fragment / Activity
scss
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.userFlow.collect {
render(it)
}
}
}
写法 2:ViewModel 里绑定自己的 Scope
kotlin
fun observeUser() {
userFlow
.onEach { _uiState.value = it }
.launchIn(viewModelScope)
}
五、ViewModel 持有关键引用:更隐蔽的泄漏
kotlin
class MyViewModel : ViewModel() {
var activity: AppCompatActivity? = null // ❌ 大坑
fun doWork() {
viewModelScope.launch {
delay(10_000)
activity?.showToast("完成") // Activity 早死了
}
}
}
ViewModel 存活时间长于 Activity,
一旦它拿着 Activity / View / Context,GC 直接失效。
✅ 正确做法:
- ViewModel 只持有数据
- UI 状态用
StateFlow/LiveData - 需要 Context?传
Application或用系统服务 - 回调?用事件总线 / Channel / 单向数据流
六、自定义 CoroutineScope:用得好是架构,用不好是炸弹
有时候你确实需要自定义 Scope,比如:
- 应用级同步
- 登录态刷新
- 离线队列
- 长连接心跳
❌ 错误写法
kotlin
class SyncManager {
private val scope = CoroutineScope(Dispatchers.IO)
fun start() {
scope.launch { syncLoop() }
}
// 没人 cancel
}
✅ 正确写法
kotlin
class SyncManager(
private val applicationScope: CoroutineScope // 明确所有者
) {
fun start() {
applicationScope.launch {
syncLoop()
}
}
}
如果是自己建的 Scope:
kotlin
class MyActivity : AppCompatActivity() {
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Main + job)
override fun onDestroy() {
super.onDestroy()
job.cancel() // ✅ 必须
}
}
经验法则:
自己 new 的 Scope,自己负责埋单。
七、协程"取消了",但代码还在跑?
Kotlin 协程是协作式取消 ,不是线程 stop()。
scss
viewModelScope.launch {
while (true) { // ❌ 不检查取消
doHeavyWork()
}
}
取消信号只会在挂起点生效。
✅ 正确:
scss
viewModelScope.launch {
while (isActive) {
doHeavyWork()
delay(1000)
}
}
或者:
kotlin
suspend fun work() {
ensureActive()
doSomething()
}
⚠️ 千万别这么写:
php
try {
...
} catch (e: Exception) {
// 吞掉 CancellationException ❌
}
应该是:
php
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// 真正的业务异常
}
八、有些工作"不该被协程管":别硬撑
下面这些需求,协程本来就不适合:
- 退出页面也要上传完
- App 被杀也要继续同步
- 登录后必须跑完的离线队列
- 大文件上传 / 下载
这时候别再想 Scope 了,直接用:
WorkManagerForegroundService+ Worker- 本地任务队列 + 启动时补跑
协程是"进程内并发工具",不是"持久化任务系统"。
九、一张速查表(面试/Code Review 都能用)
| 场景 | 该用哪个 |
|---|---|
| Activity / Fragment 里临时 UI 任务 | lifecycleScope |
| Fragment 里收集 Flow | viewLifecycleOwner + repeatOnLifecycle |
| ViewModel 里请求/状态/业务逻辑 | viewModelScope |
| Compose 里点击触发协程 | rememberCoroutineScope() |
| 应用级后台任务 | 自定义 Application Scope / WorkManager |
| 退出页面也要完成 | WorkManager / 任务队列 |
| 图省事全局跑 | ❌ GlobalScope |
十、防泄漏 Checklist
写 launch 前问自己三个问题:
- 这个协程归谁所有?
- 组件死了它该不该死?
- 它有没有拿 Activity / View / Context?
Code Review 再加三条:
- ❌ 禁止
GlobalScope - ❌ 禁止 Repository 里自己
CoroutineScope().launch - ✅ 所有 Flow 收集都有生命周期边界
最后一句人话总结
协程不是"开了就完事",
每一个 launch,都必须有一个会死掉的爸爸。
没有 Scope 的协程,是野孩子;
有 Scope 但不对的协程,是定时炸弹;
有生命周期、有状态边界、尊重取消的协程,才是生产级代码。