Add plugin for LeakCanary 2 (#1959)

Summary:
Add plugin for LeakCanary 2 as requested various times: https://github.com/facebook/flipper/issues/1379 https://github.com/facebook/flipper/issues/832  https://github.com/square/leakcanary/issues/1777

## Changelog

* Adds a leakcanary2 plugin for Android
* Adds support for leakcanary2 to existing desktop plugin

Pull Request resolved: https://github.com/facebook/flipper/pull/1959

Test Plan:
* Docs updated to show new implementation
* Should old leakcanary plugin in sample be replaced?

Reviewed By: mweststrate

Differential Revision: D26691637

Pulled By: passy

fbshipit-source-id: 5e236fa6cc124f0720a6b21b5ee7c117ccf96fbf
This commit is contained in:
Harold Martin
2021-03-01 09:10:05 -08:00
committed by Facebook GitHub Bot
parent 61eabf372a
commit 4d8be35d1a
10 changed files with 416 additions and 27 deletions

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'maven'
android {
compileSdkVersion rootProject.compileSdkVersion
buildToolsVersion rootProject.buildToolsVersion
defaultConfig {
minSdkVersion rootProject.minSdkVersion
targetSdkVersion rootProject.targetSdkVersion
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION"
implementation project(':android')
implementation deps.leakcanary2
compileOnly deps.jsr305
}
}
apply plugin: 'com.vanniktech.maven.publish'

View File

@@ -0,0 +1,8 @@
# This source code is licensed under the MIT license found in the LICENSE
# file in the root directory of this source tree.
POM_NAME=Flipper LeakCanary2 Plugin
POM_DESCRIPTION=LeakCanary2 plugin for Flipper
POM_ARTIFACT_ID=flipper-leakcanary2-plugin
POM_PACKAGING=aar

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) Facebook, Inc. and its affiliates.
~
~ This source code is licensed under the MIT license found in the LICENSE
~ file in the root directory of this source tree.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.facebook.flipper.plugins.leakcanary2">
</manifest>

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.flipper.plugins.leakcanary2
import com.facebook.flipper.android.AndroidFlipperClient
import leakcanary.DefaultOnHeapAnalyzedListener
import leakcanary.OnHeapAnalyzedListener
import shark.HeapAnalysis
import shark.HeapAnalysisSuccess
class FlipperLeakListener : OnHeapAnalyzedListener {
private val leaks: MutableList<Leak> = mutableListOf()
private val defaultListener = DefaultOnHeapAnalyzedListener.create()
override fun onHeapAnalyzed(heapAnalysis: HeapAnalysis) {
leaks.addAll(heapAnalysis.toLeakList())
AndroidFlipperClient.getInstanceIfInitialized()?.let { client ->
(client.getPlugin(LeakCanary2FlipperPlugin.ID) as? LeakCanary2FlipperPlugin)
?.reportLeaks(leaks)
}
defaultListener.onHeapAnalyzed(heapAnalysis)
}
private fun HeapAnalysis.toLeakList(): List<Leak> {
return if (this is HeapAnalysisSuccess) {
allLeaks.mapNotNull {
if (it.leakTraces.isNotEmpty()) {
it.leakTraces[0].toLeak(it.shortDescription)
} else {
null
}
}.toList()
} else {
emptyList()
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.flipper.plugins.leakcanary2
import com.facebook.flipper.core.FlipperConnection
import com.facebook.flipper.core.FlipperPlugin
private const val REPORT_LEAK_EVENT = "reportLeak2"
private const val CLEAR_EVENT = "clear"
class LeakCanary2FlipperPlugin : FlipperPlugin {
private val leaks: MutableList<Leak> = mutableListOf()
private val alreadySeenLeakSignatures: MutableSet<String> = mutableSetOf()
private var connection: FlipperConnection? = null
override fun getId() = ID
override fun onConnect(connection: FlipperConnection?) {
this.connection = connection
connection?.receive(CLEAR_EVENT) { _, _ -> leaks.clear() }
sendLeakList()
}
override fun onDisconnect() {
connection = null
}
override fun runInBackground() = false
internal fun reportLeaks(leaks: List<Leak>) {
for (leak in leaks) {
if (leak.signature !in alreadySeenLeakSignatures) {
this.leaks.add(leak)
alreadySeenLeakSignatures.add(leak.signature)
}
}
sendLeakList()
}
private fun sendLeakList() {
connection?.send(REPORT_LEAK_EVENT, LeakCanary2Report(leaks).toFlipperObject())
}
companion object {
const val ID = "LeakCanary"
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.flipper.plugins.leakcanary2
import com.facebook.flipper.core.FlipperArray
import com.facebook.flipper.core.FlipperObject
import com.facebook.flipper.core.FlipperValue
import shark.LeakTrace
import shark.LeakTraceObject
import java.util.UUID
internal data class LeakCanary2Report(val leaks: List<Leak>) : FlipperValue {
override fun toFlipperObject(): FlipperObject = FlipperObject.Builder()
.put("leaks", leaks.map { it.toFlipperObject() }.toFlipperArray())
.build()
}
internal data class Leak(
val title: String,
val root: String,
val elements: Map<String, Element>,
val retainedSize: String,
val signature: String,
val details: String
) : FlipperValue {
override fun toFlipperObject(): FlipperObject {
return FlipperObject.Builder()
.put("title", title)
.put("root", root)
.put("elements", elements.toFlipperObject())
.put("retainedSize", retainedSize)
.put("details", details)
.build()
}
private fun Map<String, FlipperValue>.toFlipperObject(): FlipperObject =
mapValues { it.value.toFlipperObject() }.toFlipperObject()
@JvmName("toFlipperObjectStringFlipperObject")
private fun Map<String, FlipperObject>.toFlipperObject(): FlipperObject =
asIterable()
.fold(FlipperObject.Builder()) { builder, entry ->
builder.put(entry.key, entry.value)
}
.build()
}
internal fun LeakTrace.toLeak(title: String): Leak {
val elements = getElements()
return Leak(
title = title,
elements = elements.toMap(),
retainedSize = retainedHeapByteSize?.let { "$it bytes" } ?: "unknown size",
signature = signature,
root = elements.first().first,
details = "$this"
)
}
private fun LeakTrace.getElements(): List<Pair<String, Element>> {
val referenceElements = referencePath.map { reference ->
val id = UUID.randomUUID().toString()
id to Element(id, reference.originObject)
}.toMutableList()
val leakId = UUID.randomUUID().toString()
referenceElements.add(leakId to Element(leakId, leakingObject))
return referenceElements.mapIndexed { index, pair ->
pair.first to if (index == referenceElements.lastIndex) pair.second else pair.second.copy(
children = listOf(referenceElements[index + 1].second.id)
)
}
}
internal data class Element(
val id: String,
val name: String,
val expanded: Boolean = true,
val children: List<String> = emptyList(),
val attributes: List<ElementAttribute>,
val decoration: String = ""
) : FlipperValue {
constructor(id: String, leakObject: LeakTraceObject) : this(
id = id,
name = "${leakObject.className} (${leakObject.typeName})",
attributes = listOf(
ElementAttribute("leaking", leakObject.leakingStatus.shortName),
ElementAttribute("retaining", leakObject.retaining)
)
)
override fun toFlipperObject(): FlipperObject {
return FlipperObject.Builder()
.put("id", id)
.put("name", name)
.put("expanded", expanded)
.put("children", children.toFlipperArray())
.put("attributes", attributes.toFlipperArray())
.put("data", EMPTY_FLIPPER_OBJECT)
.put("decoration", decoration)
.put("extraInfo", EMPTY_FLIPPER_OBJECT)
.build()
}
@JvmName("toFlipperArrayFlipperValue")
private fun Iterable<FlipperValue>.toFlipperArray(): FlipperArray =
map { it.toFlipperObject() }.toFlipperArray()
@JvmName("toFlipperArrayString")
private fun Iterable<String>.toFlipperArray(): FlipperArray =
fold(FlipperArray.Builder()) { builder, row -> builder.put(row) }.build()
}
internal fun Iterable<FlipperObject>.toFlipperArray(): FlipperArray =
fold(FlipperArray.Builder()) { builder, row -> builder.put(row) }.build()
private val LeakTraceObject.LeakingStatus.shortName: String
get() = when (this) {
LeakTraceObject.LeakingStatus.NOT_LEAKING -> "N"
LeakTraceObject.LeakingStatus.LEAKING -> "Y"
LeakTraceObject.LeakingStatus.UNKNOWN -> "?"
}
private val LeakTraceObject.retaining: String
get() = retainedHeapByteSize?.let { "$it bytes ($retainedObjectCount objects)" } ?: "unknown"
private val EMPTY_FLIPPER_OBJECT = FlipperObject.Builder().build()
data class ElementAttribute(
val name: String,
val value: String
) : FlipperValue {
override fun toFlipperObject(): FlipperObject {
return FlipperObject.Builder()
.put("name", name)
.put("value", value)
.build()
}
}

View File

@@ -15,6 +15,7 @@ buildscript {
classpath 'com.android.tools.build:gradle:4.1.2' classpath 'com.android.tools.build:gradle:4.1.2'
classpath 'com.vanniktech:gradle-maven-publish-plugin:0.14.2' classpath 'com.vanniktech:gradle-maven-publish-plugin:0.14.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.4.20"
} }
} }
@@ -101,6 +102,7 @@ ext.deps = [
mockito : 'org.mockito:mockito-core:2.26.0', mockito : 'org.mockito:mockito-core:2.26.0',
okhttp3 : 'com.squareup.okhttp3:okhttp:3.14.1', okhttp3 : 'com.squareup.okhttp3:okhttp:3.14.1',
leakcanary : 'com.squareup.leakcanary:leakcanary-android:1.6.3', leakcanary : 'com.squareup.leakcanary:leakcanary-android:1.6.3',
leakcanary2 : 'com.squareup.leakcanary:leakcanary-android:2.6',
testCore : 'androidx.test:core:1.1.0', testCore : 'androidx.test:core:1.1.0',
testRules : 'androidx.test:rules:1.1.0', testRules : 'androidx.test:rules:1.1.0',
// Plugin dependencies // Plugin dependencies

View File

@@ -36,6 +36,10 @@ type LeakReport = {
leaks: string[]; leaks: string[];
}; };
type LeakCanary2Report = {
leaks: Leak2[];
};
export type Fields = {[key: string]: string}; export type Fields = {[key: string]: string};
export type Leak = { export type Leak = {
title: string; title: string;
@@ -45,6 +49,15 @@ export type Leak = {
instanceFields: {[key: string]: Fields}; instanceFields: {[key: string]: Fields};
staticFields: {[key: string]: Fields}; staticFields: {[key: string]: Fields};
retainedSize: string; retainedSize: string;
details?: string;
};
export type Leak2 = {
title: string;
root: string;
elements: {[key: string]: Element};
retainedSize: string;
details: string;
}; };
const Window = styled(FlexRow)({ const Window = styled(FlexRow)({
@@ -72,11 +85,19 @@ export default class LeakCanary<PersistedState> extends FlipperPlugin<
init() { init() {
this.client.subscribe('reportLeak', (results: LeakReport) => { this.client.subscribe('reportLeak', (results: LeakReport) => {
this._addNewLeaks(processLeaks(results.leaks));
});
this.client.subscribe('reportLeak2', (results: LeakCanary2Report) => {
this._addNewLeaks(results.leaks.map(this._adaptLeak2));
});
}
_addNewLeaks = (incomingLeaks: Leak[]) => {
// We only process new leaks instead of replacing the whole list in order // We only process new leaks instead of replacing the whole list in order
// to both avoid redundant processing and to preserve the expanded/ // to both avoid redundant processing and to preserve the expanded/
// collapsed state of the tree view // collapsed state of the tree view
const newLeaks = processLeaks(results.leaks.slice(this.state.leaksCount)); const newLeaks = incomingLeaks.slice(this.state.leaksCount);
const leaks = this.state.leaks; const leaks = this.state.leaks;
for (let i = 0; i < newLeaks.length; i++) { for (let i = 0; i < newLeaks.length; i++) {
leaks.push(newLeaks[i]); leaks.push(newLeaks[i]);
@@ -84,10 +105,22 @@ export default class LeakCanary<PersistedState> extends FlipperPlugin<
this.setState({ this.setState({
leaks: leaks, leaks: leaks,
leaksCount: results.leaks.length, leaksCount: leaks.length,
}); });
}); };
}
_adaptLeak2 = (leak: Leak2): Leak => {
return {
title: leak.title,
root: leak.root,
elements: leak.elements,
elementsSimple: leak.elements,
staticFields: {},
instanceFields: {},
retainedSize: leak.retainedSize,
details: leak.details,
};
};
_clearLeaks = () => { _clearLeaks = () => {
this.setState({ this.setState({
@@ -154,6 +187,7 @@ export default class LeakCanary<PersistedState> extends FlipperPlugin<
return ( return (
<Sidebar position="right" width={600} minWidth={300} maxWidth={900}> <Sidebar position="right" width={600} minWidth={300} maxWidth={900}>
{instanceFields && (
<Panel heading={'Instance'} floating={false} grow={false}> <Panel heading={'Instance'} floating={false} grow={false}>
<ManagedDataInspector <ManagedDataInspector
data={instanceFields} data={instanceFields}
@@ -161,6 +195,8 @@ export default class LeakCanary<PersistedState> extends FlipperPlugin<
extractValue={this._extractValue} extractValue={this._extractValue}
/> />
</Panel> </Panel>
)}
{staticFields && (
<Panel heading={'Static'} floating={false} grow={false}> <Panel heading={'Static'} floating={false} grow={false}>
<ManagedDataInspector <ManagedDataInspector
data={staticFields} data={staticFields}
@@ -168,6 +204,12 @@ export default class LeakCanary<PersistedState> extends FlipperPlugin<
extractValue={this._extractValue} extractValue={this._extractValue}
/> />
</Panel> </Panel>
)}
{leak.details && (
<Panel heading={'Details'} floating={false} grow={false}>
<pre>{leak.details}</pre>
</Panel>
)}
</Sidebar> </Sidebar>
); );
} }
@@ -184,6 +226,7 @@ export default class LeakCanary<PersistedState> extends FlipperPlugin<
const elements = showFullClassPaths const elements = showFullClassPaths
? leak.elements ? leak.elements
: leak.elementsSimple; : leak.elementsSimple;
const selected = selectedIdx == idx ? selectedEid : null; const selected = selectedIdx == idx ? selectedEid : null;
return ( return (
<Panel <Panel

View File

@@ -0,0 +1,49 @@
---
id: leak-canary-plugin
title: LeakCanary Setup
sidebar_label: LeakCanary
---
Ensure that you already have an explicit dependency in your application's
`build.gradle` including the plugin dependency, e.g.
```groovy
dependencies {
debugImplementation 'com.facebook.flipper:flipper-leakcanary2-plugin:0.76.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.6'
}
```
Update your the `onCreate` method in you `Application` to add the LeakCanary2 plugin to Flipper and the Flipper listener to LeakCanary
```kt
import com.facebook.flipper.plugins.leakcanary2.FlipperLeakListener
import com.facebook.flipper.plugins.leakcanary2.LeakCanary2FlipperPlugin
...
override fun onCreate() {
super.onCreate()
setupFlipper()
/*
set the flipper listener in leak canary config
*/
LeakCanary.config = LeakCanary.config.copy(
onHeapAnalyzedListener = FlipperLeakListener()
)
SoLoader.init(this, false)
if (BuildConfig.DEBUG && FlipperUtils.shouldEnableFlipper(this)) {
val client = AndroidFlipperClient.getInstance(this)
/*
add leak canary plugin to flipper
*/
client.addPlugin(LeakCanary2FlipperPlugin())
client.start()
}
}
```
That's it!

View File

@@ -41,3 +41,6 @@ project(':litho-plugin').projectDir = file('android/plugins/litho')
include ':leakcanary-plugin' include ':leakcanary-plugin'
project(':leakcanary-plugin').projectDir = file('android/plugins/leakcanary') project(':leakcanary-plugin').projectDir = file('android/plugins/leakcanary')
include ':leakcanary2-plugin'
project(':leakcanary2-plugin').projectDir = file('android/plugins/leakcanary2')