Android 面试系列 | 内存泄露:从"手动配对"到"架构自愈"

1 阅读8分钟

前言

内存泄露是 Android 面试的必考题,但很多候选人的回答还停留在「用静态内部类 + WeakReference 解决 Handler 泄露」这个层面。

这个答案在 2018 年是正确的,但在 2026 年的 Kotlin + Jetpack 项目里,它暴露了一个更深层的问题:你还在靠「记住要手动配对」来防止内存泄露吗?

现代 Android 开发的答案是:把资源的生命周期托管给框架,让架构本身变得"自愈"

本文会先梳理经典的泄露场景,再逐一给出现代解决方案,最后附上高频面试问答。


一、先搞清楚:什么是内存泄露?

内存泄露(Memory Leak)的本质是:对象已经不再使用,但仍然被 GC Roots 持有引用,垃圾回收器无法释放这部分内存。

在 Android 中,最危险的泄露场景是 Activity 被长生命周期对象持有,因为一个 Activity 背后挂着它的所有 View、Bitmap、各种资源,一旦泄露,代价极大。

image.png


二、经典泄露场景(传统写法的问题)

2.1 非静态内部类 Handler

image.png

根因:非静态内部类(含匿名类)持有外部类引用。只要 Handler 里有未处理的消息,整条引用链就无法被 GC。

旧方案是静态内部类 + WeakReference,本质上只是"绕开"了问题,并没有从根本上解决。


2.2 单例持有 Activity Context

image.png

根因:开发者的一个粗心,就能让整个 Activity 无法释放。这种依赖手动约束的方式不可靠。


2.3 动画未取消

image.png


2.4 广播接收器未注销

image.png

上面这些问题有一个共同点:都依赖开发者在正确的地方手动执行清理操作。一旦忘记,就泄露了。


三、现代解决方案:把清理职责交给框架

3.1 Handler → 协程 + lifecycleScope

lifecycleScope 与 Activity/Fragment 的生命周期绑定,onDestroy 时自动取消所有协程,任务不会"逃逸"。

image.png

需要在特定生命周期区间内重复执行的任务(如轮询、动画),用 repeatOnLifecycle

image.png

核心优势:Structured Concurrency(结构化并发)保证父协程取消时,所有子协程级联取消,不会有"漏网之鱼"。


3.2 单例持有 Context → Hilt 依赖注入

Hilt 用 @ApplicationContext 注解在编译期限制只能注入 Application 级别的 Context,从类型系统上杜绝了误传 Activity 的可能。

image.png

核心优势:依赖的生命周期由 Hilt 的 Scope 声明管理(@Singleton@ActivityScoped@ViewModelScoped),作用域一目了然,不依赖开发者的记忆。


3.3 动画未取消 → repeatOnLifecycle

动画的启停同样可以交给 repeatOnLifecycle 管理:

image.png

如果使用 Jetpack Compose,动画是声明式的,随 Composable 进出 Composition 自动启停,完全不需要手动干预:

image.png


3.3.1 深挖:动画怎么变成 suspend 函数的?

上面的 playLoadingAnimation() 是一个 suspend fun,但 ObjectAnimator 本身是基于回调的异步 API,两者怎么桥接?

关键原语是 suspendCancellableCoroutine,它专门用来把回调 API 包装成 suspend 函数:

image.png

有了这个桥接,就可以写出完全协程化的动画:

取消的完整流程:

image.png

统一的设计思想suspendCancellableCoroutineinvokeOnCancellationcallbackFlowawaitClose 本质相同——都是协程取消时执行清理逻辑的钩子,前者用于单次异步操作,后者用于持续的数据流。

如果项目用 Compose,动画本身就是协程原生的,完全不需要桥接:

image.png


3.4 广播未注销 → Flow

系统广播:用 callbackFlow 封装

callbackFlow 的精髓在于 awaitClose——协程被取消时,awaitClose 里的代码一定会执行,等价于在 finally 块里调用 unregisterReceiver

image.png


四、架构整合:Clean Architecture + Hilt + Coroutines

将防泄露方案整合到 Clean Architecture 三层体系,依赖方向单一向内,泄露路径从架构层面被切断:

image.png

Domain 层 — 零 Android 依赖

image.png

Data 层 — 只持有 ApplicationContext

image.png

Presentation 层

image.png

与 MVVM 的核心差异:Domain 层的 Use Case 将 ViewModel 与数据来源完全隔离——ViewModel 只调用 getUser(),不感知 Repository 或 DataSource 的存在。各层职责边界强制收窄,Context 只存活于 @Singleton 的 Data 层,Activity 引用链被彻底阻断。


五、对比总结

泄露场景旧方案现代方案核心原理
Handler 延迟任务静态内部类 + WeakReferencelifecycleScope + 协程生命周期自动取消
单例持有 Context手动传 applicationContextHilt @ApplicationContext编译期类型限制
动画未取消onDestroy 手动 cancelrepeatOnLifecycle + suspendCancellableCoroutine挂起点感知取消,invokeOnCancellation 清理
系统广播未注销配对 register/unregistercallbackFlow + 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(...) } 替代,lifecycleScopeonDestroy 时自动取消协程,任务不会逃逸。


Q2:Hilt 如何从架构层面避免单例泄露 Activity?

Hilt 通过 Scope 注解管理依赖生命周期。@ApplicationContext 在编译期限制只能注入 Application 类型的 Context,无法注入 Activity,从类型系统上杜绝误传。


Q3:callbackFlow 的 awaitClose 解决了什么问题?

awaitClose 保证当协程被取消时(包括 lifecycleScopeonDestroy 取消时),清理代码一定执行,等价于 finally 块。结合 lifecycleScope,广播的注册/注销与 Activity 生命周期完全自动绑定,不再依赖手动配对。


Q4:repeatOnLifecyclelaunchWhenStarted 的区别?

launchWhenStarted 在 Activity 进入后台时只是挂起协程,协程本身和持有的引用仍然存在;repeatOnLifecycle 在离开目标状态时会真正取消协程,回到前台重新创建,内存更安全。官方已明确推荐使用 repeatOnLifecyclelaunchWhenStarted 系列 API 被标记为不推荐使用。


Q5:View 动画是回调 API,怎么让它支持协程取消?

suspendCancellableCoroutine 桥接:协程挂起等待动画结束回调,invokeOnCancellation 钩子保证协程被取消时同步调用 ObjectAnimator.cancel(),动画立即停止并释放 View 引用。这与 callbackFlowawaitClose 是同一套设计思想,区别在于前者针对单次异步操作,后者针对持续数据流。


总结

协程解决「任务持有引用」,Hilt 解决「谁提供正确的 Context」,Lifecycle 解决「何时清理资源」。

三者配合,让内存泄露从「靠开发者记住手动配对」变成「架构自动保障」。