前言
内存泄露是 Android 面试的必考题,但很多候选人的回答还停留在「用静态内部类 + WeakReference 解决 Handler 泄露」这个层面。
这个答案在 2018 年是正确的,但在 2026 年的 Kotlin + Jetpack 项目里,它暴露了一个更深层的问题:你还在靠「记住要手动配对」来防止内存泄露吗?
现代 Android 开发的答案是:把资源的生命周期托管给框架,让架构本身变得"自愈" 。
本文会先梳理经典的泄露场景,再逐一给出现代解决方案,最后附上高频面试问答。
一、先搞清楚:什么是内存泄露?
内存泄露(Memory Leak)的本质是:对象已经不再使用,但仍然被 GC Roots 持有引用,垃圾回收器无法释放这部分内存。
在 Android 中,最危险的泄露场景是 Activity 被长生命周期对象持有,因为一个 Activity 背后挂着它的所有 View、Bitmap、各种资源,一旦泄露,代价极大。
二、经典泄露场景(传统写法的问题)
2.1 非静态内部类 Handler
根因:非静态内部类(含匿名类)持有外部类引用。只要 Handler 里有未处理的消息,整条引用链就无法被 GC。
旧方案是静态内部类 + WeakReference,本质上只是"绕开"了问题,并没有从根本上解决。
2.2 单例持有 Activity Context
根因:开发者的一个粗心,就能让整个 Activity 无法释放。这种依赖手动约束的方式不可靠。
2.3 动画未取消
2.4 广播接收器未注销
上面这些问题有一个共同点:都依赖开发者在正确的地方手动执行清理操作。一旦忘记,就泄露了。
三、现代解决方案:把清理职责交给框架
3.1 Handler → 协程 + lifecycleScope
lifecycleScope 与 Activity/Fragment 的生命周期绑定,onDestroy 时自动取消所有协程,任务不会"逃逸"。
需要在特定生命周期区间内重复执行的任务(如轮询、动画),用 repeatOnLifecycle:
核心优势:Structured Concurrency(结构化并发)保证父协程取消时,所有子协程级联取消,不会有"漏网之鱼"。
3.2 单例持有 Context → Hilt 依赖注入
Hilt 用 @ApplicationContext 注解在编译期限制只能注入 Application 级别的 Context,从类型系统上杜绝了误传 Activity 的可能。
核心优势:依赖的生命周期由 Hilt 的 Scope 声明管理(
@Singleton、@ActivityScoped、@ViewModelScoped),作用域一目了然,不依赖开发者的记忆。
3.3 动画未取消 → repeatOnLifecycle
动画的启停同样可以交给 repeatOnLifecycle 管理:
如果使用 Jetpack Compose,动画是声明式的,随 Composable 进出 Composition 自动启停,完全不需要手动干预:
3.3.1 深挖:动画怎么变成 suspend 函数的?
上面的 playLoadingAnimation() 是一个 suspend fun,但 ObjectAnimator 本身是基于回调的异步 API,两者怎么桥接?
关键原语是 suspendCancellableCoroutine,它专门用来把回调 API 包装成 suspend 函数:
有了这个桥接,就可以写出完全协程化的动画:
取消的完整流程:
统一的设计思想:
suspendCancellableCoroutine的invokeOnCancellation和callbackFlow的awaitClose本质相同——都是协程取消时执行清理逻辑的钩子,前者用于单次异步操作,后者用于持续的数据流。
如果项目用 Compose,动画本身就是协程原生的,完全不需要桥接:
3.4 广播未注销 → Flow
系统广播:用 callbackFlow 封装
callbackFlow 的精髓在于 awaitClose——协程被取消时,awaitClose 里的代码一定会执行,等价于在 finally 块里调用 unregisterReceiver。
四、架构整合:Clean Architecture + Hilt + Coroutines
将防泄露方案整合到 Clean Architecture 三层体系,依赖方向单一向内,泄露路径从架构层面被切断:
Domain 层 — 零 Android 依赖
Data 层 — 只持有 ApplicationContext
Presentation 层
与 MVVM 的核心差异:Domain 层的 Use Case 将 ViewModel 与数据来源完全隔离——ViewModel 只调用
getUser(),不感知 Repository 或 DataSource 的存在。各层职责边界强制收窄,Context 只存活于@Singleton的 Data 层,Activity 引用链被彻底阻断。
五、对比总结
| 泄露场景 | 旧方案 | 现代方案 | 核心原理 |
|---|---|---|---|
| Handler 延迟任务 | 静态内部类 + WeakReference | lifecycleScope + 协程 | 生命周期自动取消 |
| 单例持有 Context | 手动传 applicationContext | Hilt @ApplicationContext | 编译期类型限制 |
| 动画未取消 | onDestroy 手动 cancel | repeatOnLifecycle + suspendCancellableCoroutine | 挂起点感知取消,invokeOnCancellation 清理 |
| 系统广播未注销 | 配对 register/unregister | callbackFlow + awaitClose | 协程取消时自动清理 |
六、检测工具
- LeakCanary:开发期首选,集成一行依赖,自动检测并生成泄露引用链报告
- Android Profiler(Memory) :实时查看堆内存增长,GC 后抓 Heap Dump 分析
- MAT(Eclipse Memory Analyzer) :深度分析 hprof 文件,通过 Dominator Tree 定位根因
七、面试高频问答
Q1:Handler 为什么会造成内存泄露?如何用现代方案解决?
非静态内部类持有外部类(Activity)引用,Handler 有未处理消息时,引用链
MessageQueue → Handler → Activity阻止 GC。现代方案用
lifecycleScope.launch { delay(...) }替代,lifecycleScope在onDestroy时自动取消协程,任务不会逃逸。
Q2:Hilt 如何从架构层面避免单例泄露 Activity?
Hilt 通过 Scope 注解管理依赖生命周期。
@ApplicationContext在编译期限制只能注入 Application 类型的 Context,无法注入 Activity,从类型系统上杜绝误传。
Q3:callbackFlow 的 awaitClose 解决了什么问题?
awaitClose保证当协程被取消时(包括lifecycleScope在onDestroy取消时),清理代码一定执行,等价于finally块。结合lifecycleScope,广播的注册/注销与 Activity 生命周期完全自动绑定,不再依赖手动配对。
Q4:repeatOnLifecycle 和 launchWhenStarted 的区别?
launchWhenStarted在 Activity 进入后台时只是挂起协程,协程本身和持有的引用仍然存在;repeatOnLifecycle在离开目标状态时会真正取消协程,回到前台重新创建,内存更安全。官方已明确推荐使用repeatOnLifecycle,launchWhenStarted系列 API 被标记为不推荐使用。
Q5:View 动画是回调 API,怎么让它支持协程取消?
用
suspendCancellableCoroutine桥接:协程挂起等待动画结束回调,invokeOnCancellation钩子保证协程被取消时同步调用ObjectAnimator.cancel(),动画立即停止并释放 View 引用。这与callbackFlow的awaitClose是同一套设计思想,区别在于前者针对单次异步操作,后者针对持续数据流。
总结
协程解决「任务持有引用」,Hilt 解决「谁提供正确的 Context」,Lifecycle 解决「何时清理资源」。
三者配合,让内存泄露从「靠开发者记住手动配对」变成「架构自动保障」。