原作者:Lily Chen|Dropbox Tech|2021 年 3 月 23 日|原文。中文翻译与技术审校:未完纪,2026 年 10 月 5 日。
版本说明:本文为原文正文的完整翻译,保留历史日志和全部示例。LeakCanary、AndroidX 和 MvRx/Mavericks 的接口需按项目版本核对;文末提供静态审校与当前文档入口。本次未运行 Android 应用、测试或样例。
应用为对象分配内存,却没有在对象不再使用时释放它,就会发生内存泄漏。随着时间推移,泄漏内存不断累积,会造成性能下降,甚至崩溃。任何程序、任何平台都可能泄漏;Android 应用尤其常见,因为 Activity 生命周期比较复杂。
ViewModel、LifecycleObserver 等 Android 开发模式有助于避免泄漏。但如果仍沿用旧模式,或者不知道该注意什么,错误就很容易溜进代码。
几个常见例子
持有长生命周期服务的引用
先看一个常见结构:Activity 持有某个长生命周期服务,Fragment 及其 View 持有 Activity 的引用。假如 Activity 又以某种方式引用了自己的子 Fragment,那么只要 Activity 还活着,Fragment 就也会继续存活。
即使 Fragment 已经收到 onDestroy,它仍可能被 Activity 留在内存里,直到 Activity 自己执行 onDestroy。这段时间里,Fragment 已经不会再被使用,却仍占着内存。
译者说明:判断可回收性要沿着“从 GC Root 到对象”的强引用方向检查;短生命周期对象单向引用服务,本身并不足以证明该对象泄漏。关键是它是否仍被可达的长生命周期对象保留。


长生命周期服务反过来引用 Fragment 的 View
如果换一个方向,服务拿到了 Fragment View 的引用,会发生什么?首先,这个 View 会在服务的整个存活期间一直存在。其次,由于 View 又持有父 Activity 的引用,Activity 也会跟着泄漏。只要服务还活着,Fragment View 和 Activity 就持续浪费内存。


如何发现泄漏
知道泄漏如何发生后,再来看看怎样检测。显而易见的第一步,是检查应用是否曾因 OutOfMemoryError 崩溃。原作者的判断是:如果不是某个页面本身就需要超过手机可用量的内存,应用中很可能存在泄漏。
译者纠错:OOM 是排查入口,并不能单独证明存在泄漏。大对象分配、缓存增长、并发工作量或其他内存压力也可能造成 OOM。必须结合堆转储和对象生命周期判断。
这种方式只告诉我们出了问题,却没有定位根因。泄漏可能发生在任何地方,而崩溃日志只指向最终把内存推过上限的页面,不一定指向真正开始泄漏的位置。
你可以检查所有操作轨迹,寻找相同点,但通常不容易揪出罪魁祸首。我们看看其他选择。
LeakCanary
LeakCanary 是 Android 上很好用的内存泄漏检测库。按照接入文档,在 build.gradle 中添加依赖后,下次安装、运行应用,它就会一同运行。当我们在应用里操作时,LeakCanary 会偶尔暂停应用,转储堆内存,并提供检测到的泄漏引用链。
仅这一步就比之前好得多。但过程依然手动,而且每位开发者只有自己遇到过的泄漏的本地副本。还可以继续改善。
LeakCanary 加上 Bugsnag
LeakCanary 提供了一个方便的代码方案,把发现的泄漏上传到 Bugsnag。这样,内存泄漏就能像应用中的其他警告或崩溃一样被追踪。还可以借助 Bugsnag 集成能力连接 Jira 等项目管理工具,让问题更容易被看见,也更明确由谁负责。原文展示了 Bugsnag 连接 Jira 的截图。
原文链接指向当时的 recipes 页面;目前相应入口为 Uploading analysis results。这是对集成方式的说明,本次没有上传任何日志或堆文件。

LeakCanary 加上集成测试
另一种自动化方式,是把 LeakCanary 接入 CI 测试。原文同样从官方代码方案出发,其文档大意如下:
LeakCanary 为 UI 测试中的泄漏检测提供专门的依赖包。它提供一个运行监听器,等待测试结束;如果测试成功,就查找仍被保留的对象,在需要时触发堆转储并分析。
注意,LeakCanary 会增加测试时间,因为它需要检查受监听测试后的保留对象,并在需要时进行堆转储。对我们来说,由于采用了选择性测试和分片,额外时间几乎可以忽略。这是 Dropbox 当时的测试环境经验。
最终,内存泄漏就像其他构建失败或测试失败一样出现在 CI 中,并留下泄漏发生时的引用链。
在 CI 上运行 LeakCanary,帮助我们在代码进入生产环境之前学会更好的编程模式,尤其是在使用新库时。例如,在给 MvRx 编写 mock 时,它抓到了下面这个泄漏:
<failure>Test failed because application memory leaks were detected: ==================================== HEAP ANALYSIS RESULT ==================================== 4 APPLICATION LEAKS References underlined with "~~~" are likely causes. Learn more at https://squ.re/leaks. 198449 bytes retained by leaking objects Signature: 6bf2ba80511dcb6ab9697257143e3071fca4 ┬───
│ GC Root: System class
│ ├─ com.airbnb.mvrx.mocking.MockableMavericks class
│ Leaking: NO (a class is never leaking)
│ ↓ static MockableMavericks.mockStateHolder
│ ~~~~~~~~~~~~~~~
├─ com.airbnb.mvrx.mocking.MockStateHolder instance
│ Leaking: UNKNOWN
│ ↓ MockStateHolder.delegateInfoMap
│ ~~~~~~~~~~~~~~~
├─ java.util.LinkedHashMap instance
│ Leaking: UNKNOWN
│ ↓ LinkedHashMap.header
│ ~~~~~~
├─ java.util.LinkedHashMap$LinkedEntry instance
│ Leaking: UNKNOWN
│ ↓ LinkedHashMap$LinkedEntry.prv
│ ~~~
├─ java.util.LinkedHashMap$LinkedEntry instance
│ Leaking: UNKNOWN
│ ↓ LinkedHashMap$LinkedEntry.key
│ ~~~
╰→ com.dropbox.product.android.dbapp.photos.ui.view.PhotosFragment instance
Leaking: YES (ObjectWatcher was watching this because com.dropbox.product.android.dbapp.photos.ui.view.PhotosFragment received Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
key = 391c9051-ad2c-4282-9279-d7df13d205c3
watchDurationMillis = 7304
retainedDurationMillis = 2304 198427 bytes retained by leaking objects
Signature: d1c9f9707034dd15604d8f2e63ff3bf3ecb61f8
日志保留英文以便按类名和字段名定位。这里的关键路径是系统类根 → MockableMavericks.mockStateHolder → delegateInfoMap → LinkedHashMap 中的条目 → 已收到 onDestroy() 且已脱离 FragmentManager 的 PhotosFragment。波浪线指出可疑引用。
原来我们在测试结束后没有正确清理 mock。加上几行代码就能避免:
@After
fun teardown() {
scenario.close()
val holder = MockableMavericks.mockStateHolder
holder.clearAllMocks()
}
你也许会问:既然泄漏只出现在测试中,真的有必要修吗?这由你来决定。就像代码检查器一样,泄漏检测能指出代码异味和不好的编程模式,也能帮助工程师写出更健壮的代码。在这个例子里,我们因此知道了 clearAllMocks() 的存在。泄漏有多严重、是否必须修复,最终仍由工程师判断。
对于不想执行泄漏检测的测试,我们写了一个简单的注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface SkipLeakDetection {
/**
* The reason why the test should skip leak detection.
*/
String value();
}
然后在继承 LeakCanary FailOnLeakRunListener() 的类里重写方法:
override fun skipLeakDetectionReason(description: Description): String? {
return when {
description.getAnnotation(SkipLeakDetection::class.java) != null ->
"is annotated with @SkipLeakDetection"
description.testClass.isAnnotationPresent(SkipLeakDetection::class.java) ->
"class is annotated with @SkipLeakDetection"
else -> null
}
}
单个测试方法或整个测试类都可以使用这个注解跳过检测。

如何修复泄漏
介绍过发现和呈现问题的方式后,再来看看如何理解与修复它。
LeakCanary 提供的泄漏引用链,是诊断时最有用的工具。它会打印与泄漏对象相关的一条引用链,并解释为什么该对象被认为已经泄漏。
LeakCanary 已有很好的阅读和使用引用链的文档,这里不再重复。下面重点讨论我最常遇到的两类泄漏。
View
在 Fragment 中,把 View 定义为类字段非常常见,例如 Java 的 private TextView myTextView;,或现在越来越常见的 Kotlin 写法 private lateinit var myTextView: TextView。常见到我们容易忽略:这些写法都可能造成泄漏。
除非在 Fragment 的 onDestroyView 中把字段清空,否则 View 引用会随着 Fragment 自身的生命周期存活,而不是随着它应当遵循的 Fragment View 生命周期存活。lateinit 的非空字段不能直接赋成 null。
最简单的场景是:当前页面为 FragmentA,切换到 FragmentB 后,FragmentA 被放进返回栈。FragmentA 本身没有销毁,但它的 View 已经销毁。如果 View 被绑定在 FragmentA 自身的生命周期上,它们就会在不需要时继续驻留内存。
多数情况下,这类泄漏比较小,不至于造成可见的性能问题或崩溃。但当 View 保留了其他对象、数据、图片,以及 view/data binding 等结构时,就更容易出问题。
因此,应尽可能避免把 View 存成类字段;如果需要存,就务必在 onDestroyView 中正确清理。
说到 view/data binding,Android 的 View Binding 文档明确指出:必须清空该字段,以避免泄漏。原文引用的代码如下:
private var _binding: ResultProfileBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
_binding = ResultProfileBinding.inflate(inflater, container, false)
val view = binding.root
return view
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
把这么多样板代码放到每个 Fragment 里很繁琐。另外,原作者建议避免滥用 !!:变量为 null 时它会抛出空指针异常,应明确处理空值。为解决重复代码的问题,我们创建了 Fragment 可以实现的 ViewBindingHolder,以及 DataBindingHolder:
interface ViewBindingHolder<B : ViewBinding> {
var binding: B?
// Only valid between onCreateView and onDestroyView.
fun requireBinding() = checkNotNull(binding)
fun requireBinding(lambda: (B) -> Unit) {
binding?.let {
lambda(it)
}}
/**
* Make sure to use this with Fragment.viewLifecycleOwner
*/
fun registerBinding(binding: B, lifecycleOwner: LifecycleOwner) {
this.binding = binding
lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
owner.lifecycle.removeObserver(this)
this@ViewBindingHolder.binding = null
}
})
}
}
interface DataBindingHolder<B : ViewDataBinding> : ViewBindingHolder<B>
这样,Fragment 就能以简洁的方式完成三件事:需要 binding 时检查它是否存在;只在 binding 存在时执行特定代码;在 View 销毁时自动清空 binding。

暂时性泄漏
还有一些泄漏只会持续很短时间。我们遇到过一个由 EditText 相关异步任务造成的例子:任务的持续时间略长于 LeakCanary 默认等待时间,所以工具报告了泄漏,但不久后内存就被正常清理了。
如果怀疑是这种暂时性保留,可以用 Android Studio 内存分析器检查。在分析器中开始会话,执行复现步骤,然后等久一点再转储和检查堆;额外等待后,泄漏可能已经消失。原文的 Memory Profiler 截图展示了这类随后被清理的情况。
经常测试,尽早修复
希望这份概览能帮助你在自己的应用中发现并解决内存泄漏。就像其他缺陷一样,最好经常测试、尽早修复,别等不好的模式深入代码库后再处理。
开发者还应记住:即使泄漏没有明显影响你自己手机上的应用表现,使用低端机型、低内存手机的用户,也会受益于你为此做的工作。祝你排查顺利!
2026 年技术审校:保留示例,明确适用边界
审校方式:静态阅读原文日志、Java/Kotlin 片段和当前官方文档;未执行、未编译、未在设备或 CI 上测试。
- 旧版测试接入:当前 LeakCanary UI 测试文档使用
LeakAssertions.assertNoLeaks()、DetectLeaksAfterTestSuccess和内置跳过注解。不要把 2021 年FailOnLeakRunListener片段直接混入新版本。应固定项目版本后按相应 API 迁移,并明确断言在 Activity 销毁前还是销毁后执行。 - 跳过理由没有被使用:原自定义注解要求填
value(),但监听器只返回固定字符串,真正填写的理由丢失。应读取方法或类注解中的value并写入检测报告;同时处理描述中没有testClass的情况。 - 清理不能被异常短路:
scenario.close()若抛错,后面的clearAllMocks()就不会执行。可以用try/finally保证 mock 清理,同时保留原始错误。 - 两个 requireBinding 的语义不同:无参数版本的
checkNotNull会在缺失时抛异常,并不是自动安全;带 lambda 的版本则静默不执行。建议分别命名为“要求存在”与“存在时执行”,避免调用方误判任务已经完成。 - 必须绑定 View 生命周期:
registerBinding应使用Fragment.viewLifecycleOwner,在它已经可用的阶段(例如onViewCreated)调用。传 Fragment 自身会延长 View 的保留时间。还应避免多次注册留下旧观察器;否则旧生命周期结束时可能把更新后的 binding 清空。 - 不要机械延长等待时间:暂时保留应结合异步任务是否合理、能否取消以及延迟堆转储结果判断;仅提高阈值可能遮住实际问题。
- 调试与报告范围:官方文档建议完整 LeakCanary 依赖用于 debug 构建。上传到 Bugsnag 的数据可能包括类名、路径、元数据和引用链,接入时应核对收集内容与访问范围;本次没有配置账户、发送报告或上传堆。
改动标记:原文代码与日志保留;对“OOM 即泄漏”和“每个测试都会转储”的概括进行了明确限定;补充当前 API 文档与逐项修改建议。原图未冒充实际截图,引用关系以原创 SVG 重绘。
版权与来源:原文和六幅原图归 Lily Chen / Dropbox 及相应权利人所有。来源原图与编辑原创示意图分别标注;所引用第三方代码不统一推定为同一种开放许可证。












暂无评论内容