移动性能测试 LeakCanary+Jenkins 内存泄漏监控实践

越昂超英 · 2016年09月27日 · 最后由 yw 回复于 2017年01月17日 · 6522 次阅读
本帖已被设为精华帖!

背景

公司 Android 产品的 OOM 崩溃率持续增长,为了检测出内存泄漏问题,决定使用LeakCanary。为了持续发现内存泄漏问题,尝试将 LeakCanary 与 Jenkins 相结合。本文着重于 LeakCanary 与 Jenkins 的结合,不会对 LeakCanary 和 Jenkins 本身做过多介绍,敬请谅解。

思路

  • 将 LeakCanary 接入 Android 产品

    • 在 Jenkins 平台完成 LeakCanary 代码接入和 Debug 包的构建
    • 通过 Shell 脚本实现代码修改
    • 发现泄漏信息后自动上传到数据库
  • 使用 Monkey 进行随机操作触发泄漏

  • 使用 Jenkins Pipeline 实现持续集成流程

LeakCanary 接入方法

关于 LeakCanary 的详细信息可以看这里:LeakCanary 中文使用说明

主要修改点有两处:一处是在项目的build.gradle中加入 LeakCanary 的引用:

dependencies {
    debugCompile 'com.squareup.leakcanary:leakcanary-android:1.4'
    releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4'
    testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4'
}

另一处是在项目的主 Application 类(即AndroidManifest.xml<application>标签中android:name的值)中安装 LeakCanary:

import com.squareup.leakcanary.*

public class YourApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        // 安装LeakCanary
        LeakCanary.install(this);
    }
}

按照最基础的LeakCanary.install(this);方式接入 LeakCanary 后,发现泄漏都会产生一个通知,点击可以查看具体的 leak trace。考虑到持续集成,这里希望每次发现泄漏后,将相关信息自动上传到数据库。并且由于查看 leak trace 的界面是一个新的 Activity,在跑 Monkey 的过程中也会存在干扰,导致相当一段时间不在产品本身的界面中操作。因此,这里需要做一些修改。

修改点 1:发现泄漏后自动上传到数据库

1、新建LeakUploadService

在 Application 类所在的 package 内新建一个LeakUploadService类,继承DisplayLeakService类:

import com.squareup.leakcanary.*

public class LeakUploadService extends DisplayLeakService {

    @Override
    protected void afterDefaultHandling(HeapDump heapDump, AnalysisResult result, String leakInfo) {
        if (!result.leakFound || result.excludedLeak){
            return;
        }
        // 下面是处理泄漏数据和上传数据库的代码
    }
}

其中,发生泄漏的类名为result.className.toString();,其余信息诸如软件包名、软件版本号、leak trace 等,均在leakInfo中,形如:

In com.example.leakcanary:1.0:1.
* com.example.leakcanary.MainActivity has leaked:
* GC ROOT thread java.lang.Thread.<Java Local> (named 'AsyncTask #3')
* references com.example.leakcanary.MainActivity$2.this$0 (anonymous subclass of android.os.AsyncTask)
* leaks com.example.leakcanary.MainActivity instance

* Retaining: 131 KB.
* Reference Key: 53591da9-6668-423c-90d1-ff83a797d94a
* Device: HTC htc HTC M9w himauhl_htccn_chs_2
* Android Version: 6.0 API: 23 LeakCanary: 1.4-SNAPSHOT 44787a1
* Durations: watch=5140ms, gc=172ms, heap dump=1381ms, analysis=18835ms

* Details:
...

一般将软件包名、版本号、leak trace(第一行下面,* Retaining: 131 KB.之上的部分)、泄漏大小等信息上传到数据库即可,有了这些信息,研发就可以定位问题了。

处理方法也很简单,就是对 String 对象进行一些操作。这里我是将发送泄漏的类名、软件包名、软件版本号、整个泄漏信息(除了 Details 部分)上传到数据库,因此代码如下:

String className = result.className.toString();
String pkgName = leakInfo.trim().split(":")[0].split(" ")[1]
String pkgVer = leakInfo.trim().split(":")[1]
String leakDetail = leakInfo.split("\n\n")[0] + "\n\n" + leakInfo.split("\n\n")[1];

至于上传数据库的代码,这里就不提供了(这个是之前同事写的,我直接拿来用了)。

2、注册 service

接下来需要在AndroidManifest.xml中注册 service,即在和之间添加<service android:name="xxx.LeakUploadService" android:exported="false"/>,其中 xxx 为LeakUploadService所在的 package。

3、修改安装方式

最后修改主 Application 类中的安装方式,改为:

public class ExampleApplication extends Application {

    private RefWatcher refWatcher;
    protected RefWatcher installLeakCanary(){return LeakCanary.install(this, LeakUploadService.class, AndroidExcludedRefs.createAppDefaults().build());}
    @Override
    public void onCreate() {
        super.onCreate();
        // 安装LeakCanary
        refWatcher = installLeakCanary();        
    }
}

做了如上操作后,每次发送泄漏,都会自动将发送泄漏的类名、软件包名、软件版本号、泄漏信息等上传到数据库了。

修改点 2:屏蔽DisplayLeakActivity

DisplayLeakActivity类是 LeakCanary 展示 leak trace 的类,这个类的存在,会导致跑 Monkey 的过程中,多次进入这个 Activity,在其中操作,从而减少在软件本身界面中的操作时间。屏蔽这个类后,不会再在应用列表中生成一个名为 “Leaks” 的软件,发送泄漏后,点击通知栏里的泄漏提醒,也不会进入 leak trace 的展示页面。但考虑到泄漏的信息已经上传到数据库,所以这么做也无可厚非。

通过查阅 LeakCanary 源码可以发现,是否开启DisplayLeakActivity,是由leakcanary-android/src/main/java/com/squareup/leakcanary/LeakCanary.java中的enableDisplayLeakActivity()函数决定的,该函数为:

public static void enableDisplayLeakActivity(Context context) {
    setEnabled(context, DisplayLeakActivity.class, true);
}

只需要把true改成false即可屏蔽DisplayLeakActivity类。

但我采用的接入方法是直接在build.gradle中添加远程依赖,并且我也不想修改成本地依赖。因此我首先想到的方法是新建一个类,让其继承LeakCanary类,然后重写enableDisplayLeakActivity()函数。但是LeakCanary类是一个 final 类,无法被继承。

由于LeakCanary类正好是在前面接入 LeakCanary 时添加代码LeakCanary.install(this);用到的类,因此我想到的方法是自己创建一个新的类LeakCanaryWithoutDisplay,位置在com/squareup/leakcanary下(需要自己新建这个 package),里面的代码直接复制LeakCanary类,然后做如下修改:

  • public final class LeakCanary {改为public final class LeakCanaryWithoutDisplay {

  • private LeakCanary() {改为private LeakCanaryWithoutDisplay() {

  • 修改enableDisplayLeakActivity()函数,将true改为false

  • 将主 Application 类中安装 LeakCanary 的代码LeakCanary.install();改为LeakCanaryWithoutDisplay.install();

这样就 OK 了。

将接入 LeakCanary 的所有修改整理成 Shell 脚本

由于 Jenkins 打包每次都会拉取最新代码,因此需要将接入 LeakCanary 的修改整理成 Shell 脚本,这样每次拉取最新代码后,执行这个 Shell 脚本,然后再打出的包才是后面流程所需要的包。

编写 Shell 脚本主要使用到的命令是sedcpsed用来修改代码,cp用来复制文件。其中,LeakUploadService.javaLeakCanaryWithoutDisplay.java是固定的,因此可以提前写好备用,然后直接cp到相应目录。

Shell 脚本主要分为以下几个部分:

  • 修改build.gradle,添加 LeakCanary 依赖

  • 修改AndroidManifest.xml,注册 service

  • 修改主 Application 类,安装 LeakCanary

  • 复制LeakUploadService.java(以及上传数据库可能需要用到的其他 java 文件)到主 Application 类所在的包下

  • 复制LeakCanaryWithoutDisplay.javacom/squareup/leakcanary

由于不同项目的脚本存在一定差异,这里就不给出具体的 Shell 脚本了。

新建 Jenkins 项目,用于构建包含 LeakCanary 的 Debug 包

在 Jenkins 中新建项目,配置源码管理。

将编写的 Shell 脚本和相关文件拷贝到 Jenkins 项目文件夹中。

添加构建步骤,选择 “Execute shell”,执行 Shell 脚本,命令为:

cd $WORKSPACE
sh ../your_shell_file_name.sh

添加构建步骤,选择 “Invoke Gradle script”,生成 Debug 包,配置如下图:

新建 Jenkins Pipeline 项目,用于实现完整流程

结合我司实际环境,Jenkins 项目的打包是在 master 节点上进行的,而跑 Monkey 的操作是在另一台服务器上进行的(这台服务器是 Jenkins 的一个从节点,搭建了STF服务,连有多台测试机),记这个从节点的标签为 “Linux-for-stf”。

Pipeline 的步骤如下图所示:

包括 5 个步骤:

  • 首先在 master 节点构建接入了 LeakCanary 的 apk 包

  • 然后将 apk 包拷贝到 STF 节点

  • 接着在 STF 节点安装 apk 包

  • 其次在 STF 节点上选择一台手机跑 Monkey 触发泄漏

  • 最后发送邮件提醒(项目是否构建成功)

对应 Pipeline script 可以简化为:

node('master') {
    stage 'build Job1'
    build 'Job1'

    stage 'scp apk to stf node'
    def apkDir="/home/test/.jenkins/jobs/Job1/workspace/app/build/outputs/apk"
    def destDir="stf@linux-for-stf:/home/stf/jenkins/apks"
    sh "scp $apkDir/test.apk $destDir"
}
node('Linux-for-stf') {
    stage 'install apk'
    def device="ABCD1234"
    def apkFile="/home/stf/jenkins/apks/test.apk"
    sh "adb -s $device install -r $apkFile"

    stage 'run monkey'
    sh "adb -s $device shell monkey -p com.example.ExampleApp -s 100 --ignore-crashes --ignore-timeouts --throttle 700 -v 10000"
}
node('master') {
    stage 'send email'
    mail to: 'test@gmail.com',
    subject: "Job '${env.JOB_NAME}' (${env.BUILD_NUMBER}) finished",
    body: "Please go to ${env.BUILD_URL} and verify the build"
}

这里会遇到一个问题:这个 Pipeline 项目完成后会发送一封邮件,但如果中途某一步出了问题,就直接结束运行,从而导致成功可以收到邮件、失败却收不到邮件的情况。解决方法是,使用try catch。改进的 Pipeline script 为:

try {
    node('master') {
        stage 'build Job1'
        build 'Job1'

        stage 'scp apk to stf node'
        def apkDir="/home/test/.jenkins/jobs/Job1/workspace/app/build/outputs/apk"
        def destDir="stf@linux-for-stf:/home/stf/jenkins/apks"
        sh "scp $apkDir/test.apk $destDir"
    }
    node('Linux-for-stf') {
        stage 'install apk'
        def device="ABCD1234"
        def apkFile="/home/stf/jenkins/apks/test.apk"
        sh "adb -s $device install -r $apkFile"

        stage 'run monkey'
        sh "adb -s $device shell monkey -p com.example.ExampleApp -s 100 --ignore-crashes --ignore-timeouts --throttle 700 -v 10000"
    }
    node('master') {
        stage 'send email'
        mail to: 'test@gmail.com',
        subject: "Job '${env.JOB_NAME}' (${env.BUILD_NUMBER}) succeeded",
        body: "Please go to ${env.BUILD_URL} and verify the build"
    }
} catch (Exception e) {
    node('master') {
        stage 'send email'
        echo '$e'
        mail to: 'test@gmail.com',
        subject: "Job '${env.JOB_NAME}' (${env.BUILD_NUMBER}) failed",
        body: "Please go to ${env.BUILD_URL} and verify the build"
    }
}

这样持续了一段时间后发现,跑 Monkey 过程中经常会下拉状态栏,然后点击里面的快捷按钮,而且经常会点击到 Wifi 按钮,从而导致 Wifi 被关闭。Wifi 被关闭,意味着发现的泄漏信息无法上传到数据库,因此这个问题必须要解决。然而从网上搜索的结果来看并不理想,针对这个问题,大部分人都表示没有什么好的方法。

好在有一个 GitHub 项目被我发现了,叫simiasque。这是一款 Android 软件,通过全局遮罩遮住状态栏位置来防止 Monkey 下拉状态栏,测试效果非常好。使用方法也很简单,安装 demo 下的 apk 文件,打开软件,点击 “Hide status bar” 按钮即可。更方便的是,作者也提供了启动和关闭的命令,分别是:

开启:

adb shell am broadcast -a org.thisisafactory.simiasque.SET_OVERLAY --ez enable true

关闭:

adb shell am broadcast -a org.thisisafactory.simiasque.SET_OVERLAY --ez enable false

接下来要做的,就是在 Pipeline script 跑 Monkey 的命令之前加上安装和开启 simiasque 的命令,跑完 Monkey 后再加上关闭 simiasque 的命令即可。

最终的 Pipeline script 大致是这样的:

try {
    node('master') {
        stage 'build Job1'
        build 'Job1'

        stage 'scp apk to stf node'
        def apkDir="/home/test/.jenkins/jobs/Job1/workspace/app/build/outputs/apk"
        def destDir="stf@linux-for-stf:/home/stf/jenkins/apks"
        sh "scp $apkDir/test.apk $destDir"
    }
    node('Linux-for-stf') {
        stage 'install apk'
        def device="ABCD1234"
        def apkFile="/home/stf/jenkins/apks/test.apk"
        sh "adb -s $device install -r $apkFile"

        stage 'run monkey'
        sh "adb -s $device install -r /home/stf/jenkins/simiasque-debug.apk"
        sh "adb -s $device shell am broadcast -a org.thisisafactory.simiasque.SET_OVERLAY --ez enable true"
        sh "adb -s $device shell monkey -p com.example.ExampleApp -s 100 --ignore-crashes --ignore-timeouts --throttle 700 -v 10000"
        sh "adb -s $device shell am broadcast -a org.thisisafactory.simiasque.SET_OVERLAY --ez enable false"
    }
    node('master') {
        stage 'send email'
        mail to: 'test@gmail.com',
        subject: "Job '${env.JOB_NAME}' (${env.BUILD_NUMBER}) succeeded",
        body: "Please go to ${env.BUILD_URL} and verify the build"
    }
} catch (Exception e) {
    node('master') {
        stage 'send email'
        echo '$e'
        mail to: 'test@gmail.com',
        subject: "Job '${env.JOB_NAME}' (${env.BUILD_NUMBER}) failed",
        body: "Please go to ${env.BUILD_URL} and verify the build"
    }
}

到此为止,Jenkins Pipeline 的步骤基本是完成了。接下来,设置好每天运行 1 次,泄漏信息尽收数据库之中。

后续流程

以上流程完成后,LeakCanary+Jenkins 的内存泄漏监控实践基本上是完成了。但在公司的实际项目中,这样仅仅是发现和收集了泄漏信息,最关键的还是让研发修改这些问题。由于不同公司采用的 Bug 平台不尽相同,不同 Bug 平台的区别可能也很大,因此这里就不具体展开后面的步骤了。大体思路就是,定期将数据库中的泄漏信息提交到 Bug 平台上。至于实现方法,如果提供接口当然是直接用调用接口;如果不提供接口,也可以尝试直接向数据库添加信息。

总之,发现内存泄漏的关键还是解决,如果不解决,上面所做的一切都是白费。

当然,上面的一些方法可能并不完美,不过都是我在最近一段时间的工作中慢慢摸索得到的。如果你有更好的解决方案,欢迎交流。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 15 条回复 时间 点赞

隐藏状态栏这个太屌

把遍历的环节交给 monkey,还是无法达到真实效果,我们将 leakcanary 的能力集成在测试包中,在人肉测试过程中,去发现内存溢出吧,全是真实操作行为,有机会跟你们分享一下 _^

LeakCanary 有时候蛮好用的,但是每个公司的 APP 的技术架构不一样,如 Native APP、Web APP、Hybird APP,所以 Leak 报的内容也不一样。
我们公司是模块化开发,用起来不是太方便,如果 AAR 改成 APP 会方便很多。我们的机制是 APP 引用各个 AAR,AAR 里面是获取不到 APP 的对象,AAR 是被引用关系。且如果 APP 中含第三方库必须在 application 中调用 install 方法,然后会返回一个 watcher 对象,不方便在于 AAR 都访问不到的 application 类。

思寒_seveniruby 将本帖设为了精华贴 09月28日 11:23

加精理由: 使用了 Jenkins2.0 的 pipeline 技术让流程清晰. 讲解了 LeakCanary 的内部 api 应用

nice,看到了一个新颖的点,屏蔽 display 类

—— 来自 TesterHome 官方 安卓客户端

思路不错 严重地学习了🔞

@ntflc “修改点 2:屏蔽 DisplayLeakActivity 类” 这个有更好的解决办法
只需在LeakCanary.install(this); 后加上LeakCanaryInternals.setEnabled(this, DisplayLeakActivity.class, false);即可

#9 楼 @ntflc 我感谢你才对,我看到你的这个才给到我更好的解决办法

leakcanary 内置了 activity onDestoryed 时进行 activity 泄露监控,其他的泄露点还是得在代码里插桩进行特定引用 watch 。有什么办法让他自动 watch 更多的引用吗?

—— 来自 TesterHome 官方 安卓客户端

匿名 #5 · 2016年10月09日

#2 楼 @jili0503 期待分享

将接入 LeakCanary 的所有修改整理成 Shell 脚本

我是这样做的:
build.gradle 文件

buildTypes {
        release {
            buildConfigField "boolean", "LEAKCANARY_ON", "false"
        }
        leakcanary {
            buildConfigField "boolean", "LEAKCANARY_ON", "true"
        }
    }

Application 类

import com.squareup.leakcanary.*

public class YourApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        // 安装LeakCanary
        if(BuildConfigUtils.getBuildConfigValue(this.getPackageName(), "LEAKCANARY_ON")) {
            LeakCanary.install(this);
        }
    }
}

./gradlew clean assembleLeakcanary包含 LeakCanary
./gradlew clean assembleRelease不包含 LeakCanary

可以参考自由的使用 gradle 构建你的应用

这个想法不错,之前也用过 leakcanary 没有想到把 jenkins 加入

这个东西很实用,让 app 质量更高

怎么屏蔽通知栏的通知

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册