背景
公司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腳本主要使用到的命令是sed
和cp
,sed
用來修改代碼,cp
用來複制文件。其中,LeakUploadService.java
和LeakCanaryWithoutDisplay.java
是固定的,因此可以提前寫好備用,然後直接cp
到相應目錄。
Shell腳本主要分爲以下幾個部分:
-
修改
build.gradle
,添加LeakCanary依賴 -
修改
AndroidManifest.xml
,註冊service -
修改主Application類,安裝LeakCanary
-
複製
LeakUploadService.java
(以及上傳數據庫可能需要用到的其他java文件)到主Application類所在的包下 -
複製
LeakCanaryWithoutDisplay.java
到com/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平臺上。至於實現方法,如果提供接口當然是直接用調用接口;如果不提供接口,也可以嘗試直接向數據庫添加信息。
總之,發現內存泄漏的關鍵還是解決,如果不解決,上面所做的一切都是白費。
當然,上面的一些方法可能並不完美,不過都是我在最近一段時間的工作中慢慢摸索得到的。如果你有更好的解決方案,歡迎交流。