LeakCanary+Jenkins 內存泄漏監控實踐

背景

公司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,即在<application android:name=...>和</application>之間添加<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平臺上。至於實現方法,如果提供接口當然是直接用調用接口;如果不提供接口,也可以嘗試直接向數據庫添加信息。

總之,發現內存泄漏的關鍵還是解決,如果不解決,上面所做的一切都是白費。

當然,上面的一些方法可能並不完美,不過都是我在最近一段時間的工作中慢慢摸索得到的。如果你有更好的解決方案,歡迎交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章