LeakCanary 你真的了解么?看看这些高级用法

4,539 阅读8分钟

前言

众所周知,Square 出品的内存泄漏检测工具 LeakCanary 可以很方便的检测出 App 中存在的内存泄漏问题。当我们决定要不要在项目中引入 LeakCanary 的时候,经常也会听到声音:

  • “LeakCanary 接入简单,无需手动初始化。”
  • “LeakCanary 虽好,但就是太卡。”
  • “LeakCanary 虽好,但无法线上使用。”

一度我也是这么认为的,直到我认真研究了下才发现,事实可能并没有那么简单。本文就是尝试从 LeakCanary 的一些高级用法,来重新论证上述的观点。 文末会附上完整代码,可直接使用。

想要使用 LeakCanary 的一些高级用法,首先就是需要我们主动掌握 LeakCanary 的初始化时机,添加一些自定义的配置,下面就看一下如何手动初始化 LeakCanary ?

如何手动初始化 LeakCanary ?

正常情况下,我们只要添加下面一行代码,就可以在 App 中使用 LeakCanary 了。

dependencies {
  // debugImplementation because LeakCanary should only run in debug builds.
  debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
}

自动初始化

这是怎么做到的?是采用了 ContentProvider 的加载机制来做的。简单讲大致流程如下:

image.png

  1. 先执行 Application中的attachBaseContext 函数;
  2. 然后会执行 ContentProvider 中的 onCreate 函数;
  3. 最后才会走到 Application 中的 onCreate 函数中;

那下面就看一下 LeakCanary 是怎么自动初始化的,首先是在 AndroidManifest.xml  文件中声明:

    <application>
        <provider
            android:name="leakcanary.internal.MainProcessAppWatcherInstaller"
            android:authorities="${applicationId}.leakcanary-installer"
            android:enabled="@bool/leak_canary_watcher_auto_install"
            android:exported="false" />
    </application>

有一个需要关注的点就是,provider 的 enabled 状态是通过资源文件中的值来决定的,这就是禁用自动初始化的关键。MainProcessAppWatcherInstaller 定义如下:

internal class MainProcessAppWatcherInstaller : ContentProvider() {

  override fun onCreate(): Boolean {
    val application = context!!.applicationContext as Application
    AppWatcher.manualInstall(application)
    return true
  }

}

可见,初始化的主要逻辑就是 AppWatcher.manualInstall(application) 函数。其定义大致如下:

  @JvmOverloads
  fun manualInstall(
    application: Application,
    retainedDelayMillis: Long = TimeUnit.SECONDS.toMillis(5),
    watchersToInstall: List<InstallableWatcher> = appDefaultWatchers(application)
  ) {

appDefaultWatchers 中是默认配置关注内存泄漏的类型,支持的有 ActivityFragmentRootViewService

手动初始化

想要对 LeakCanary 添加一些自定义的配置,就需要禁用自动初始化的逻辑,上面也有提到在资源文件中添加 leak_canary_watcher_auto_install **值即可,如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <bool name="leak_canary_watcher_auto_install">false</bool>
</resources>

手动初始化的时候,我们就可以根据自己的需要添加想要检测的类型,如果我们不想检测 RootView 的类型,则可以如下定义:

val watchersToInstall = AppWatcher.appDefaultWatchers(application)
  .filter { it !is RootViewWatcher }
AppWatcher.manualInstall(
  application = application,
  watchersToInstall = watchersToInstall
)

初始化的时候的确是可以做到开箱即用,对于想要延迟初始化以及自定义配置的话,也可以很方便的支持。

下面就会开始探索如何解决 LeakCanary 卡顿相关的问题。

如何解决卡顿?

LeakCanary 造成卡顿的原因就是在主进程中 dump hprof 文件,.hprof 通常会有上百兆,整个过程至少会持续 20 秒(中位数)以上。所以在这个过程中,用户有任何繁琐的操作都会使 App 不堪重负表现卡顿,如果是性能差的老机器,什么都不操作都可能出现 ANR 的问题。

针对上述问题通过用的解决方案就是把整个 dump hprof 文件的过程放到一个单独的进程中做,这样就会尽可能少的影响主进程的操作。快手开源的 KOOM 库采用的也是这种方式,当然 LeakCanary 本身也提供了多进程的方式。

使用 leakcanary-android-process

使用时需要引入 leakcanary-android-process 模块,如下:

dependencies {
  // debugImplementation because LeakCanary should only run in debug builds.
  debugImplementation 'com.squareup.leakcanary:leakcanary-android-process:2.9.1'
}

此依赖包中使用 WorkManager 来处理跨进程通讯,处理的方式也是非常巧妙,只要添加依赖就可以做到跨进程。大致思路如下:

  1. leakcanary-android-process 包中定义 RemoteLeakCanaryWorkerService 并在 AndroidManifest 文件中声明为单独的进程;
  2. leakcanary-android-core 包中会判断 RemoteLeakCanaryWorkerService 类是否存在,如存在则使用 WorkManager 启动子进程进行 Dump 操作,否则在子线程中处理。

其中 RemoteLeakCanaryWorkerService 定义如下:

<manifest xmlns:android="<http://schemas.android.com/apk/res/android>"
    package="com.squareup.leakcanary">

  <application>
    <service
      android:name="leakcanary.internal.RemoteLeakCanaryWorkerService"
      android:exported="false"
      android:process=":leakcanary" />
  </application>

</manifest>

使用 WorkManager dump 内存的逻辑如下:

// EventListener 是 LeakCanary 的事件回调,这里仅仅处理了 Dump 内存的事件
object RemoteWorkManagerHeapAnalyzer : EventListener {

  private const val REMOTE_SERVICE_CLASS_NAME = "leakcanary.internal.RemoteLeakCanaryWorkerService"

  override fun onEvent(event: Event) {
    if (event is HeapDump) {
      val application = InternalLeakCanary.application
      val heapAnalysisRequest =
        OneTimeWorkRequest.Builder(RemoteHeapAnalyzerWorker::class.java).apply {
          val dataBuilder = Data.Builder()
            .putString(ARGUMENT_PACKAGE_NAME, application.packageName)
            .putString(ARGUMENT_CLASS_NAME, REMOTE_SERVICE_CLASS_NAME)
          setInputData(event.asWorkerInputData(dataBuilder))
          with(WorkManagerHeapAnalyzer) {
            addExpeditedFlag()
          }
        }.build()
      SharkLog.d { "Enqueuing heap analysis for ${event.file} on WorkManager remote worker" }
      val workManager = WorkManager.getInstance(application)
      workManager.enqueue(heapAnalysisRequest)
    }
  }
}

最终效果如下,在 dump 事件前后,打印日志的进程由 25405 变成 25426

image.png

使用 KOOM

除了使用 LeakCanary 自带的跨进程方案之外,还可以使用 KOOM 库中的一个包 koom-fast-dump ,在 LeakCanary 的配置方式如下:

LeakCanary.config = LeakCanary.config.copy(
  heapDumper = HeapDumper {
    // 核心代码就这一行,注意此方法会等待子进程返回采集结果,不要在UI线程调用!
    ForkJvmHeapDumper.getInstance().dump(it.absolutePath)
  })

LeakCanary 默认的 dump 使用的是 Debug.dumpHprofData() ,代码如下:

object AndroidDebugHeapDumper : HeapDumper {
  override fun dumpHeap(heapDumpFile: File) {
    Debug.dumpHprofData(heapDumpFile.absolutePath)
  }
}

使用 koom-fast-dump 与 LeakCanary 自带的包 leakcanary-android-process 效果是一样的,都会切换到子进程,日志如下:

image.png

小结

无论是使用 koom-fast-dump 还是 leakcanary-android-process,都可以解决 LeakCanary 卡顿的问题。

koom-fast-dump 是将 dump 内存(生成 .hprof 文件)的逻辑放到了子进程,而 leakcanary-android-process 是将分析 .hprof 文件放到了子进程。两者可以配合使用。

感谢 @彭旭锐 指出不足地方。以下是原文: 无论是使用 koom-fast-dump 还是 leakcanary-android-process,都可以解决 LeakCanary dump 内存时卡顿的问题。默认情况下,使用 leakcanary-android-process更加方便,如果是想要想要自定义 HeapDump 相关逻辑话,使用 koom-fast-dump会相对简单一点。

通过上面的介绍可知,LeakCanary 可以通过配置 Config 来自定义 HeapDump 逻辑,除此之外还可以监听 LeakCanary 的主要事件,然后做一些我们想要的事情,比如把相关问题上传到 Crash 平台或者是质量平台上,方便从宏观的角度治理内存泄漏问题。

如何在线上使用?

解决了卡顿问题之后,在线上使用 LeakCanary 似乎也不是那么遥不可及了,下面我们看一下如何在线上使用 LeakCanary。

想要在线上使用 LeakCanary 首要要确定以下问题:

  1. 如何获取 LeakCanary 分析内存泄漏的结果?
  2. 内存泄漏的结果以何种形式上报到质量平台上?
  3. 如何确定合理的监控采集时机,做到尽可能小的影响用户?

监听 LeakCanary 事件

监听 LeakCanary dump 以及内存分析事件可以通过 LeakCanary.Config 进行配置,SDK 内部内置了一下监听器,如下:

object LeakCanary {
  data class Config(
		// ...

    val eventListeners: List<EventListener> = listOf(
      LogcatEventListener,
      ToastEventListener,
      LazyForwardingEventListener {
        if (InternalLeakCanary.formFactor == TV) TvEventListener else NotificationEventListener
      },
      when {
          RemoteWorkManagerHeapAnalyzer.remoteLeakCanaryServiceInClasspath ->
            RemoteWorkManagerHeapAnalyzer
          WorkManagerHeapAnalyzer.validWorkManagerInClasspath -> WorkManagerHeapAnalyzer
          else -> BackgroundThreadHeapAnalyzer
      }
    ),
  ) {
	}
}

可以看出,我们在控制台看到的日志打印(LogcatEventListener)、App中的通知提醒(NotificationEventListener)等逻辑都是在此处配置的。包括上面提到使用子进程 dump 内存的逻辑就是在 RemoteWorkManagerHeapAnalyzer 内部实现的。

我们想要获得对应的分析结果也需要通过此方式。我们通过实现 EventListener 接口即可获取对接的结果,实现大致如下:

private class RecordToService : EventListener {

  /**
   * SDK 内部事件回调,可以在此处过滤出内存泄漏的结果
   */
  override fun onEvent(event: EventListener.Event) {
    if (event !is EventListener.Event.HeapAnalysisDone<*>) {
      return
    }

    if (event is EventListener.Event.HeapAnalysisDone.HeapAnalysisSucceeded) {
      record(event.heapAnalysis)
    }
  }

  /**
   * 处理内存泄漏的结果
   */
  private fun record(heapAnalysis: HeapAnalysisSuccess) {
    val allLeaks = heapAnalysis.allLeaks
    // 处理结果
  }
}

事件定义好之后通过以下配置进行初始化:

class LeakCanaryConfig {
  // 初始化配置
  fun init(app: Application) {

    val eventListeners = LeakCanary.config.eventListeners.toMutableList().apply {
      // 将我们自定义的事件添加到事件列表中,也可以根据自己的需求删除一些线上不需要的事件
			add(RecordToService())
    }
    LeakCanary.config = LeakCanary.config.copy(
      eventListeners = eventListeners
    )
  }
}

到这了我们就已经能够拿到 LeakCanary 分析的内存泄漏结果了。但是这里的结果,跟我们平时使用的 Crash 上报信息并不能直接匹配,因为这里并没有直接可以使用的堆栈信息,需要我们自己进行拼接。

下面就看一下如何通过 LeakCanary 中的信息构造对应的 Throwable。

构建 Throwable

这部分基本没有什么难点,直接按照 LeakTrace 对象中的字段进行拼接即可,下面是完整的代码。

internal class LeakCanaryThrowable(private val leakTrace: LeakTrace) : Throwable() {

    override val message: String
        get() = leakTrace.leakingObject.message()

    override fun getStackTrace(): Array<StackTraceElement> {
        val stackTrace = mutableListOf<StackTraceElement>()
        stackTrace.add(StackTraceElement("GcRoot", leakTrace.gcRootType.name, "GcRoot.kt", 42))
        for (cause in leakTrace.referencePath) {
            stackTrace.add(buildStackTraceElement(cause))
        }
        return stackTrace.toTypedArray()
    }

    private fun buildStackTraceElement(reference: LeakTraceReference): StackTraceElement {
        val file = reference.owningClassName.substringAfterLast(".") + ".kt"
        return StackTraceElement(reference.owningClassName, reference.referenceDisplayName, file, 0)
    }

    private fun LeakTraceObject.message(): String {
        return buildString {
            append("发现内存泄漏问题,")
            append(
                if (retainedHeapByteSize != null) {
                    val humanReadableRetainedHeapSize = humanReadableByteCount(retainedHeapByteSize!!.toLong())
                    "$className, Retaining $humanReadableRetainedHeapSize in $retainedObjectCount objects."
                } else {
                    className
                }
            )
        }
    }

    private fun humanReadableByteCount(bytes: Long): String {
        val unit = 1000
        if (bytes < unit) return "$bytes B"
        val exp = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt()
        val pre = "kMGTPE"[exp - 1]
        return String.format("%.1f %sB", bytes / unit.toDouble().pow(exp.toDouble()), pre)
    }
}

将堆栈打印出来的效果如下:

image.png

LeakCanaryThrowable 构建后之后就可以根据自己团队使用的 Crash 上报 SDK 进行上传了。

调整监控策略

到目前为止 LeakCanary 虽然可以在子进程 dump内存并且分析结果了,但是在线上版本运行多少对性能还是有些影响的。为了尽可能减少这些影响,就需要调整 LeakCanary 监控的时机了,尽量是在用户不使用当前 App 的时候进行处理。

可能的场景就是 App 切到后台或者是手机息屏时才开始处理相关的任务,LeakCanary 也提供了应该的工具包,首先需要引入 leakcanary-android-release 包,如下:

dependencies {
  // LeakCanary for releases
  releaseImplementation 'com.squareup.leakcanary:leakcanary-android-release:${leakCanaryVersion}'
}

下面就需要对之前的 LeakCanaryConfig 类进行改造了,需要添加 BackgroundTrigger 以及 ScreenOffTrigger ,这两个触发器的逻辑大致如下:

class LeakCanaryConfig {

    fun init(app: Application) {

        // App 进入后台触发器
        BackgroundTrigger(
            application = app,
            analysisClient = analysisClient,
            analysisExecutor = analysisExecutor,
            analysisCallback = analysisCallback
        ).start()

        // 手机息屏触发器
        ScreenOffTrigger(
            application = app,
            analysisClient = analysisClient,
            analysisExecutor = analysisExecutor,
            analysisCallback = analysisCallback
        ).start()
    }
}

可能会觉得就算是这样配置,也会觉得不是那么放心,其实也可以通过云端下发配置的方式来动态控制是否开启 LeakCanary 的监控功能。如下,通过 HeapAnalysisClient 自定义拦截器

	private val analysisClient by lazy {
        HeapAnalysisClient(
            heapDumpDirectoryProvider = {
                File("")
            },
            // stripHeapDump: remove all user data from hprof before analysis.
            config = HeapAnalysisConfig(stripHeapDump = true),
            // Default interceptors may cancel analysis for several other reasons.
            interceptors = listOf(flagInterceptor) + HeapAnalysisClient.defaultInterceptors(app)
        )
    }

	private val flagInterceptor = object : HeapAnalysisInterceptor {

        override fun intercept(chain: HeapAnalysisInterceptor.Chain): HeapAnalysisJob.Result {
            // 通过开关控制任务是否进行
            if(enable) {
                chain.job.cancel("cancel reason")
            }
            return chain.proceed()
        }
    }

除了我们上面自定义的拦截器之外,SDK内部还预制了一些极端情况的场景,如下:

fun defaultInterceptors(application: Application): List<HeapAnalysisInterceptor> {
      return listOf(
				// 仅支持特定 Android 版本
        GoodAndroidVersionInterceptor(),
				// 存储空间太小也不支持
        MinimumDiskSpaceInterceptor(application),
				// 可用内存太小也不支持
        MinimumMemoryInterceptor(application),
        MinimumElapsedSinceStartInterceptor(),
        OncePerPeriodInterceptor(application),
        SaveResourceIdsInterceptor(application.resources)
      )
    }

有了上述逻辑的综合加持,在线上版本中使用 LeakCanary 的影响范围可能并没有现象中的大。当然 LeakCanary 官方对这部分内容还是持谨慎态度的,leakcanary-android-release 本身还是处于试验阶段。

当然如果有内测渠道,可以先在内测的版本中跑起来。

小结

其实 leakcanary-androidleakcanary-android-release 两个包的依赖图大致如下:

+--- project :leakcanary-android-release
|    +--- project :shark-android
|    |    \--- project :shark
|    |         \--- project :shark-graph
|    |              \--- project :shark-hprof
|    |                   \--- project :shark-log
|    \--- project :leakcanary-android-utils
+--- project :leakcanary-android
|    +--- project :leakcanary-android-core (*)
|    +--- project :leakcanary-object-watcher-android
|    \--- org.jetbrains.kotlin:kotlin-stdlib
+--- project :leakcanary-android-core
|    +--- project :shark-android
|    +--- project :leakcanary-object-watcher-android-core
|    +--- project :leakcanary-object-watcher-android-androidx
|    \--- project :leakcanary-object-watcher-android-support-fragments

可见,:leakcanary-android-release 模块并没有依赖 :leakcanary-android ,仅有 :shark-android:leakcanary-android-utils 模块是通用的。

分析源码可以知,:leakcanary-android-release:leakcanary-android两个包在 HeapDump 以及结果处理上都有差异,leakcanary-android-release 模块也无法使用 leakcanary-android 中的多进程逻辑,因为其内部写死是使用 Debug.dumpHprofData 的。好在其触发条件比较苛刻,小范围使用影响可控。

使用 LeakCanary 采集内存泄漏的建议方式如下:

  • Debug 环境

    • 添加 leakcanary-android 依赖,使用默认的一些事件监听器(日志、通知),方便定位排除问题;
    • 添加 leakcanary-android-process 依赖,在子进程中处理耗时任务,优化开发体验;
    • 自定义事件监听器,上报对应的结果;
  • Release 环境

    • leakcanary-android-release 依赖,仅在一些特定的情况下触发任务,减少对用户使用的影响;
    • 自定义事件监听器,上报对应的结果;

以上逻辑的代码已上传至 gist ,感兴趣的同学可以自取。

总结

首先,正常在 Debug 环境中使用 LeakCanary 的确是添加一行依赖就能搞定了,包括对多进程的开启也是如此,真的算是开箱即用了。由此可见其设计功底了。

在 Release 环境使用,也有对应的方案。但是整体方案还处于实验阶段,建议控制好使用范围。一种是云端开启采样方式开启,另一种就是在内测版本中使用控制好使用范围。

回过头再来看我们之前对 LeakCanary 留下的刻板印象:

  • “LeakCanary 虽好,但就是太卡。”
  • “LeakCanary 虽好,但无法线上使用。”

读到这里我相信你对上面的问题已经有了自己的看法了。古云说:“士别三日,当刮目相待”,对于这些在持续更新的技术也应如此,要时刻保持开放学习的心态,唯有如此,才有突破。