LeakCanary 你真的了解么?看看这些高级用法
The following article is from Rethink Android Author madroid
众所周知,Square 出品的内存泄漏检测工具 LeakCanary 可以很方便的检测出 App 中存在的内存泄漏问题。当我们决定要不要在项目中引入 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 的加载机制来做的。简单讲大致流程如下:
那下面就看一下 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 中是默认配置关注内存泄漏的类型,支持的有 Activity、Fragment、RootView 和 Service。
手动初始化
想要对 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 来处理跨进程通讯,处理的方式也是非常巧妙,只要添加依赖就可以做到跨进程。大致思路如下:
其中 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。
使用 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 效果是一样的,都会切换到子进程,日志如下:
小结
无论是使用 koom-fast-dump 还是 leakcanary-android-process ,都可以解决 LeakCanary dump 内存时卡顿的问题。默认情况下,使用 leakcanary-android-process 更加方便,如果是想要想要自定义 HeapDump 相关逻辑话,使用 koom-fast-dump 会相对简单一点。
通过上面的介绍可知,LeakCanary 可以通过配置 Config 来自定义 HeapDump 逻辑,除此之外还可以监听 LeakCanary 的主要事件,然后做一些我们想要的事情,比如把相关问题上传到 Crash 平台或者是质量平台上,方便从宏观的角度治理内存泄漏问题。
解决了卡顿问题之后,在线上使用 LeakCanary 似乎也不是那么遥不可及了,下面我们看一下如何在线上使用 LeakCanary。
想要在线上使用 LeakCanary 首要要确定以下问题:
监听 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)
}
}
将堆栈打印出来的效果如下:
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-android 与 leakcanary-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 环境
以上逻辑的代码已上传至 gist ,感兴趣的同学可以自取。
https://gist.github.com/madroidmaq/9207e24820838ccc0b1a2558d83df7ef
首先,正常在 Debug 环境中使用 LeakCanary 的确是添加一行依赖就能搞定了,包括对多进程的开启也是如此,真的算是开箱即用了。由此可见其设计功底了。
在 Release 环境使用,也有对应的方案。但是整体方案还处于实验阶段,建议控制好使用范围。一种是云端开启采样方式开启,另一种就是在内测版本中使用控制好使用范围。
回过头再来看我们之前对 LeakCanary 留下的刻板印象:
读到这里我相信你对上面的问题已经有了自己的看法了。古云说:“士别三日,当刮目相待”,对于这些在持续更新的技术也应如此,要时刻保持开放、学习的心态。唯有如此,才有突破。
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!