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

Android开发者必看:协程取消导致内存泄漏的3个隐蔽陷阱
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又持有contcont又持有外部对象(如Activity),这就形成了一个完整的泄漏链。更糟糕的是,如果底层回调在取消后仍然触发,你的cont.resume会抛异常或执行无意义的逻辑。

如何排查与修复

  1. 始终在invokeOnCancellation中执行真正的反注册,例如socket.removeListener(listener)
  2. 对于RxJava/Future等混合场景,务必在取消钩子中调用disposable.dispose()/future.cancel(true)
  3. 使用leakCanary或Dump内存,检查是否出现大量ListenerContinuation实例。如果看到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官方提供的lifecycleScopeviewModelScope,它们会在合适的生命周期自动取消。
  • 如果必须自定义scope,请确保onDestroy中调用scope.cancel(),并且不要在底层类中长期持有scope实例。
  • 特别注意:不要在非静态内部类(例如Activity)中创建一个持有该Activity引用的自定义Scope并传入单例对象。正确的做法是构建一个全局的、与UI无关的scope,并通过显式传入Job来管理其生命周期。

总结

协程的取消并不是简单的“调用cancel就万事大吉”。在真实项目中,一定要检查每个挂起点是否真正可取消,外部回调是否在取消时被移除,以及自定义Scope的生命周期是否与持有者完全一致。记住三条黄金法则:清理动作要快不要包裹耗时操作;取消钩子中必须解绑所有外部监听;优先使用lifecycleScope/viewModelScope而非手动维护scope。只有理解了这些底层细节,才能避免协程带来的“优雅”泄漏。

阅读剩余
THE END