Do you really understand LeakCanary? Check out these advanced usages

foreword

As we all know, LeakCanary, a memory leak detection tool produced by Square, can easily detect memory leaks in App. When we decide whether to introduce LeakCanary in the project, we often hear voices:

  • "LeakCanary is easy to plug in and requires no manual initialization."
  • "LeakCanary is good, but too laggy."
  • "LeakCanary is great, but not available online."

I thought so too for a time, until I seriously studied it and found out that the truth may not be that simple. This article is an attempt to re-argument the above point from some advanced usage of LeakCanary. The complete code will be attached at the end of the article, which can be used directly.

If you want to use some advanced usages of LeakCanary, first of all, we need to take the initiative to grasp the initialization timing of LeakCanary and add some custom configurations. Let's take a look at how to manually initialize LeakCanary?

How to initialize LeakCanary manually?

Normally, we only need to add the following line of code to use LeakCanary in the App.

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

automatic initialization

how did you do that? It is done by using the loading mechanism of ContentProvider. Simply speaking, the general process is as follows:

image.png

  1. ApplicationExecute attachBaseContextthe function in first;
  2. ContentProviderThen onCreatethe function in will be executed;
  3. Finally, it will go Applicationto onCreatethe function in ;

Then let's take a look at how LeakCanaryit is automatically initialized. First, it is declared in the AndroidManifest.xml file:

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

One thing to pay attention to is that the enabled state of the provider is determined by the value in the resource file, which is the key to disabling automatic initialization. MainProcessAppWatcherInstallerIt is defined as follows:

internal class MainProcessAppWatcherInstaller : ContentProvider() {

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

}
复制代码

It can be seen that the main logic of initialization is AppWatcher.manualInstall(application)the function . Its definition is roughly as follows:

  @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 虽好,但无法线上使用。”

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

Guess you like

Origin juejin.im/post/7133188148728692766