检测 Android 应用中的内存泄漏

原作者: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 经 Activity 引用长生命周期 Service
正常引用链:Fragment 的 View 经 Activity 引用长生命周期 Service。Lily Chen / Dropbox,2021;原文图像,并非本次运行结果。原图
Activity 反向持有已退出 Fragment 时的保留链
Activity 反向持有已退出 Fragment 时的保留链。Lily Chen / Dropbox,2021;原文图像,并非本次运行结果。原图

长生命周期服务反过来引用 Fragment 的 View

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

从GC Root可达的长生命周期服务,强引用已销毁的Fragment View,View再引用Activity,导致两者不能被回收;修复时应切断服务到View的引用。
依据原文引用关系自主绘制的示意图,箭头表示强引用,非工具截图。
长生命周期 Service 持有 View,连带保留 Activity
长生命周期 Service 持有 View,连带保留 Activity。Lily Chen / Dropbox,2021;原文图像,并非本次运行结果。原图

如何发现泄漏

知道泄漏如何发生后,再来看看怎样检测。显而易见的第一步,是检查应用是否曾因 OutOfMemoryError 崩溃。原作者的判断是:如果不是某个页面本身就需要超过手机可用量的内存,应用中很可能存在泄漏。

译者纠错:OOM 是排查入口,并不能单独证明存在泄漏。大对象分配、缓存增长、并发工作量或其他内存压力也可能造成 OOM。必须结合堆转储和对象生命周期判断。

这种方式只告诉我们出了问题,却没有定位根因。泄漏可能发生在任何地方,而崩溃日志只指向最终把内存推过上限的页面,不一定指向真正开始泄漏的位置。

你可以检查所有操作轨迹,寻找相同点,但通常不容易揪出罪魁祸首。我们看看其他选择。

LeakCanary

LeakCanary 是 Android 上很好用的内存泄漏检测库。按照接入文档,在 build.gradle 中添加依赖后,下次安装、运行应用,它就会一同运行。当我们在应用里操作时,LeakCanary 会偶尔暂停应用,转储堆内存,并提供检测到的泄漏引用链。

仅这一步就比之前好得多。但过程依然手动,而且每位开发者只有自己遇到过的泄漏的本地副本。还可以继续改善。

LeakCanary 加上 Bugsnag

LeakCanary 提供了一个方便的代码方案,把发现的泄漏上传到 Bugsnag。这样,内存泄漏就能像应用中的其他警告或崩溃一样被追踪。还可以借助 Bugsnag 集成能力连接 Jira 等项目管理工具,让问题更容易被看见,也更明确由谁负责。原文展示了 Bugsnag 连接 Jira 的截图。

原文链接指向当时的 recipes 页面;目前相应入口为 Uploading analysis results。这是对集成方式的说明,本次没有上传任何日志或堆文件。

Bugsnag 中汇总的 LeakUploader 报告列表;原文另述与 Jira 联动
Bugsnag 中汇总的 LeakUploader 报告列表;原文另述与 Jira 联动。Lily Chen / Dropbox,2021;原文图像,并非本次运行结果。原图

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
    }
}

单个测试方法或整个测试类都可以使用这个注解跳过检测。

原文的 OutOfMemoryError 报告;OOM 本身不能独立证明存在内存泄漏
原文的 OutOfMemoryError 报告;OOM 本身不能独立证明存在内存泄漏。Lily Chen / Dropbox,2021;原文图像,并非本次运行结果。原图

如何修复泄漏

介绍过发现和呈现问题的方式后,再来看看如何理解与修复它。

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。

Android Studio 内存分析器截图;界面提示该检测可能产生假阳性
Android Studio 内存分析器截图;界面提示该检测可能产生假阳性。Lily Chen / Dropbox,2021;原文图像,并非本次运行结果。原图

暂时性泄漏

还有一些泄漏只会持续很短时间。我们遇到过一个由 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 及相应权利人所有。来源原图与编辑原创示意图分别标注;所引用第三方代码不统一推定为同一种开放许可证。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容