Android开发者必看:协程取消导致内存泄漏的3个隐蔽陷阱

陷阱一:withContext(NonCancellable)中的假取消
很多开发者会在协程被取消时,使用withContext(NonCancellable)来执行清理工作,例如关闭数据库、写入日志等。这个用法本身是安全的,但陷阱在于:如果你把本可以被取消的耗时操作也放进了这个块中,那么协程将无法响应取消,导致该协程占用的资源(如IO线程、回调注册)迟迟无法释放,最终泄漏。
viewModelScope.launch {
try {
// 业务操作
delay(1000)
withContext(NonCancellable) {
// 错误示范:不可取消的耗时写库
repository.writeLargeData() // 无法响应取消
}
} finally {
// 清理
}
}
当用户退出页面时,viewModelScope会取消协程,但withContext(NonCancellable)内部的writeLargeData()仍然会执行完,并且因为它是挂起函数内部的阻塞/耗时操作,它会一直运行直到结束。如果这个操作持有Activity的引用,或持有大Bitmap,内存就瞬间被钉住。
解决方案
- 将真正的清理动作(如关闭连接、释放监听器)放在
NonCancellable中,这些动作应当极快完成。 - 对于耗时且可取消的业务操作,永远不要包裹在
NonCancellable里。应使用try/finally并在finally中执行轻量清理。 - 如果需要确保写库操作本身不被取消,考虑使用
runCatching捕获取消异常,并让写库操作通过suspendCancellableCoroutine注册到可中断的通道。
陷阱二:外部回调器未解绑,协程“假取消”但回调仍在
假设你使用协程封装了一个网络请求库的监听器模式,例如Firebase或OkHttp的Callback。你将回调包装到了suspendCancellableCoroutine中,并在协程取消时调用回调的取消方法。但常见的陷阱是:协程取消了,但你没有真正注销底层回调,导致底层库继续持有协程的Continuation引用,或者继续回调已取消的协程,引发内存泄漏与崩溃。
suspend fun fetchFromSocket(): String = suspendCancellableCoroutine { cont ->
val listener = object : SocketListener {
override fun onData(result: String) {
cont.resume(result)
}
}
socket.addListener(listener)
// 注意:这里如果协程被取消,我们却忘了移除listener!
cont.invokeOnCancellation {
// 只做日志,没有移除listener
Log.d("TAG", "cancelled")
}
}
当协程被取消时,invokeOnCancellation被调用,但socket仍然持有listener的引用,而listener又持有cont,cont又持有外部对象(如Activity),这就形成了一个完整的泄漏链。更糟糕的是,如果底层回调在取消后仍然触发,你的cont.resume会抛异常或执行无意义的逻辑。
如何排查与修复
- 始终在invokeOnCancellation中执行真正的反注册,例如
socket.removeListener(listener)。 - 对于RxJava/Future等混合场景,务必在取消钩子中调用
disposable.dispose()/future.cancel(true)。 - 使用
leakCanary或Dump内存,检查是否出现大量Listener或Continuation实例。如果看到Continuation被非协程对象持有,通常是此问题。
陷阱三:自定义CoroutineScope与生命周期绑定错位
许多开发者为了使用方便,会在Activity中创建一个自己的CoroutineScope,例如val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)。然后用它启动任务,并在onDestroy中调用scope.cancel()。这个模式看起来没问题,但容易犯以下错误:
class MyActivity : AppCompatActivity() {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
var job: Job? = null
override fun onResume() {
super.onResume()
job = scope.launch {
// 模拟一个长期任务
while (isActive) {
// 处理某种数据,并持有 view 引用
updateView()
delay(1000)
}
}
}
override fun onDestroy() {
super.onDestroy()
// 错误:只取消了单个job,却忘了取消scope?
job?.cancel() // 假设忘了scope.cancel()
}
}
如果你只取消了其中一个子Job,而scope仍然活跃,并且有其他协程(例如启动时创建的常驻协程)仍然运行,那么这些协程会继续持有Activity引用。即使你调用scope.cancel(),也可能因为SupervisorJob的特性,导致某个子Job不受影响(例如子Job用NonCancellable启动的独立作用域)。
更隐蔽的场景:将scope传入底层类
如果你把自定义scope传给了Reposiroty或系统服务,而该服务通过scope.launch重新启动协程,那么Activity销毁后,服务仍然持有scope引用。因为你的scope是Activity的成员变量,即使你调用了scope.cancel(),如果底层服务持有的是scope.coroutineContext中的Job,而Job被取消后,服务仍然尝试用这个Job去launch,会直接抛出异常,或者因为传入的是CoroutineScope对象本身,造成Activity无法被回收。
最佳实践
- 使用Android官方提供的
lifecycleScope或viewModelScope,它们会在合适的生命周期自动取消。 - 如果必须自定义scope,请确保
onDestroy中调用scope.cancel(),并且不要在底层类中长期持有scope实例。 - 特别注意:不要在非静态内部类(例如Activity)中创建一个持有该Activity引用的自定义Scope并传入单例对象。正确的做法是构建一个全局的、与UI无关的scope,并通过显式传入Job来管理其生命周期。
总结
协程的取消并不是简单的“调用cancel就万事大吉”。在真实项目中,一定要检查每个挂起点是否真正可取消,外部回调是否在取消时被移除,以及自定义Scope的生命周期是否与持有者完全一致。记住三条黄金法则:清理动作要快不要包裹耗时操作;取消钩子中必须解绑所有外部监听;优先使用lifecycleScope/viewModelScope而非手动维护scope。只有理解了这些底层细节,才能避免协程带来的“优雅”泄漏。