1. 程式人生 > >初創團隊的Android應用質量保障之道

初創團隊的Android應用質量保障之道

                     

穩定性與記憶體優化

隨著Android技術的發展各種開源庫層出不窮,開發一個Android應用已經變得容易了很多。然而開發一個商業應用並不是單純是實現業務需求那麼簡單,開發完成只是基礎,後續還需要經過QA同學的嚴格測試。然而對於小型創業公司來說,我們並沒有BAT等大廠裡的測試平臺、方案研究員,我們QA資源比較有限,如果將一切發現問題的重擔都交給測試部門,不但耗費的測試周期長,而且有一些問題將難以發現。例如某個crash只會在某個場景下復現,某個記憶體洩漏只有在使用者執行了某個操作才會出現,而QA同學在測試時並不一定能夠執行到那條crash的測試路徑。對於記憶體洩漏來說,即使測試到了那條路徑,但可能他們並不是在測試記憶體問題,因此即使出現了記憶體洩漏也難以發現。然而由於記憶體洩漏導致的OOM、空指標正是導致應用崩潰的兩大原因,因此儘早的發現並且解決掉這類問題對於應用質量來說至關重要。

也許有同學會說通過LeakCanary可以很方便的為我們檢測記憶體洩漏,但是問題是我們並不能保證我的研發、QA同學在每個版本都會通過LeakCanaey檢測各個頁面的記憶體問題,因為人不是機器,你不能保證每一次都會進行手動迴歸。而如果在開發中直接引入LeakCanary會拖慢你的開發速度。因此,找到一種低成本、高收益的自動化測試方案來保證應用的穩定性對於創業小團隊來說還是非常有價值的。

這篇文章我就來分享一下我們是如何保證應用的穩定性、避免記憶體洩漏的。首先我列一下幾個要點:

  1. Jenkins 持續整合
  2. 單元測試
  3. Monkey 壓力測試 以及 log收集
  4. 定製 LeakCanary 實現配合Monkey測試的記憶體檢測

一、Jenkins 持續整合平臺

在敏捷方法中,持續整合是其基石,持續整合的核心是自動化測試。Jenkins是一個可擴充套件的持續整合平臺,它提供了豐富的外掛能夠讓開發人員完成各種任務。它主要作用有如下兩個方面:

  1. 持續、自動地構建或者測試軟體專案;
  2. 定時地執行任務。

對於Android專案來說,你可以理解為它可以定期的拉取程式碼,然後打包你的應用,並且執行一些特定的任務,例如打包之後執行單元測試、壓力測試、UI自動化測試、上傳到fir.im 上等。Jenkins的執行流程大致如圖 1-1 所示 :

圖 1-1    

通過定時觸發Jenkins構建任務,它能夠自動從github拉取程式碼、打包apk、執行我們的測試任務,最後我們可以將結果通過郵件傳送給相關人員。例如我們的Jenkins每隔兩個小時就會執行一次單元測試(如果程式碼有改動),然後將結果傳送給相關人員。假如有一位同事進行了程式碼重構,但是引入了錯誤,那麼單元測試將會快速的發現問題,並且最後通過郵件將報告發送給相關人員。相關人員通過報告發現錯誤之後就會盡快修復bug, 而不需要等到測試階段經過各種測試路徑之後才能發現問題。如果這個問題在QA測試階段沒有被覆蓋到,那麼就會導致有問題apk交付給使用者。

關於如何搭建Jenkins平臺我就不做過多介紹,這方面的資源比較多,大家可以參考下面兩篇文章。

二、單元測試

說到自動化測試,成本最低的應該是單元測試。單元測試成本最低,但是收益卻非常高。因為它是最基礎的測試,正所謂“九層之臺,起於壘土;千里之行,始於足下”,只有基礎牢固了才能保證更高層次的正確性。但是由於國內開發人員對於單元測試認識不多,所以能夠寫單元測試的開發人員並不是很多,也正因為如此在2015年我才在《Android開發進階:從小工到專家》的第九章詳細講述了單元測試,也是希望將這些知識儘早的推薦給早期接觸Android開發的同學,因此本文不會再次介紹如何寫單元測試。言歸正傳,這些測試策略其實很早就有總結過,最著名的就是Martin Fowler的測試金字塔,如圖 2-1所示。

 

Martin Fowler是世界著名的面向物件分析設計、UML、模式等方面的專家,敏捷開發方法的創始人之一,現為ThoughtWorks公司的首席科學家,出版過《重構:改善既有程式碼的設計》、《企業應用架構模式》等名著。

圖 2-1 中將自動測試分為了三個層次,從下到上依次為單元測試、業務邏輯測試、UI測試,越往上測試成本越高、測試的效率越低,也就是說單元測試是整個測試金字塔中投入最少、收益最高、測試效率最高的測試型別。

舉個具體的例子,假如我們的應用中有資料庫快取功能,那麼我們如何快速驗證資料庫儲存模組是否正確?通常的流程我們是執行應用得到UI上的資料,然後記錄當前的資料,資料儲存之後,然後再重新進入應用,再與之前記錄的資料做對比,反覆執行這個過程來來確保資料的正確性。每次釋出新版本之前測試人員都得執行上述測試流程,枯燥無味不說,還容易出錯、浪費時間,而如果我們有單元測試,那麼我們只需要執行一次單元測試,如果測試通過我們就認為資料庫快取模組基本沒有問題,再簡單配合我們的人工測試就可以通過測試,這樣一來效率就提高了很多。

這三個層次的自動化測試的分配比例從下到上通常為 70% 、20%、10%,可見單元測試在整個自動化測試中佔據了非常大的比例。通過單元測試,我們能夠獲得如下收益:

  1. 便於後期重構。用單元測試儘量覆蓋程式中的每一項功能的正確性,這樣就算是開發後期,也可以有保障地增加功能或者更改程式結構,而不用擔心這個過程中會破壞原來的功能,因為單元測試為程式碼的重構提供了保障。只要重構程式碼之後單元測試全部執行通過,那麼,在很大程度上表示這次重構沒有引入新的Bug,當然這是建立在完整、有效的單元測試覆蓋率的基礎上;
  2. 優化設計。編寫單元測試將使使用者從呼叫者的角度觀察、思考,特別是使用測試驅動開發的開發方式,迫使設計者把程式設計成易於呼叫和可測試,並且解除軟體中的耦合。
  3. 具有迴歸性。自動化的單元測試避免了程式碼出現迴歸,編寫完成之後,可以隨時隨地地快速執行測試。而不是將程式碼部署到裝置上,然後再手動地覆蓋各種執行路徑,這樣的行為效率低下、浪費時間。
  4. 提高你對程式碼的信心。通過單元測試,相當於我們從另一個角度審視了我們的程式碼,並且驗證了它們的正確性,這樣一來使得我們對於程式碼更有信心,而不是在上線之後還擔心基礎程式碼會出現問題。

當我們有單元測試之後,我們就可以在Jenkins上執行Gradle任務(需要安裝Gradle外掛),以此來執行我們的單元測試。首先需要新增構建步驟,然後選擇”Invoke Gradle Scripts”, 然後在Gradle任務下如圖 2-2 所示的任務:

圖 2-2

配置好之後我們就將Android裝置(或者使用模擬器外掛)連線到jenkins主機上,然後觸發Jenkins任務啟動單元測試的任務,Jenkins就會執行我們配置的Gradle指令碼 assembleDebug connectedDebugAndroidTest --continue 任務,這個任務會打包一個debug版的apk包,然後安裝被測專案、測試專案,最後執行工程中的單元測試。如果我們配置了郵件外掛,那麼我們也可以將測試報告(測試報告存放在 build/reports/androidTests/connected/flavors/測試的flavor/index.html)通過郵件傳送給相關人員。如表 2-1 所示:

           
郵件通知測試成功測試失敗

表 2-1

假如測試失敗,那麼我們通過測試報告就知道是哪個測試執行失敗,以及為什麼失敗,然後相關人員就可以快速的修復bug,將基礎bug扼殺在搖籃之中。

還是回到前文提到的,寫單元測試需要一定的知識,怎麼編寫單元測試不是難點,難點是怎麼讓你的程式碼可以測試,這些涉及到解耦、依賴注入等知識,雖然說很淺顯,但是很多工程師並沒有真正領會到這些,因此能夠寫單元測試的工程師是少之又少。也正是因為這樣,在小公司執行單元測試才會顯得困難。

三、Monkey壓力測試與記憶體洩漏檢測

將基礎的bug扼殺於單元測試後我們還要面臨高層次的測試問題,例如在某些頁面的某些情況下應用會發生崩潰,但是測試的時候我們沒有測試到該場景,因此就上線之後發現某個頁面崩潰直線上升。由於測試資源、測試時間有限,這種情況難以避免,為了儘量避免這種情況我們可以通過Monkey進行壓力測試。

Monkey是一款壓力測試工具,它能夠根據使用者指定的事件比例向指定的應用傳送事件,比如觸控事件、點選事件、螢幕旋轉等,通過Monkey測試能夠讓應用處在一個未知的測試環境下(通俗點講就是有規律的在應用內亂點),這個時候我們往往能夠發現QA同學沒有測出來的bug,從另一個層面保證應用的質量。

在執行Monkey的過程中,如果應用產生了崩潰、ANR等,它都會輸出日誌,測試結束之後如果測試失敗我們只需要檢視錯誤日誌就可以發現問題所在。通過這種自動化的測試、日誌收集,我們就能夠邊開發、邊測試,儘早的發現、修復bug。

要在Jenkins中實現壓力自動化測試,我們需要如下幾步:

  1. 通過gradle命令生成apk,並且安裝
  2. 執行 monkey 指令碼進行測試
  3. 獲取並且傳送測試報告

生成apk我們可以通過新增gradle 指令碼命令實現,方式與圖2-2中一樣,只需要我們將Switches的值修改為”assembleDebug”。然後在Jenkins中我們可以為一個專案新增構建任務,任務型別為 “Execute Shell”, 如圖 3-1 所示:

圖 3-1   

Execute Shell中的內容就是我們要執行的指令碼,作用分別為:

  1. unlock.sh - 裝置解鎖,然後才可以讓Monkey執行下一步的壓力測試。
  2. 啟動真正的壓力測試, 即執行 start_monkey.sh 指令碼;
  3. 分析測試日誌,判定測試的成功與失敗;

其中start_monkey.sh最為重要,核心指令碼如下所示:

#! /bin/bashproject=你的jenkins專案名稱app_package=你的應用包名# 解除安裝舊應用adb uninstall $app_package# 重新安裝被測試的apkadb install -r $project/你的app模組名/build/outputs/apk/生成的debug.apk# 執行monkey指令碼,將錯誤輸出到monkey_error.txt中adb shell monkey -p $app_package --ignore-crashes --ignore-timeouts --ignore-native-crashes --ignore-security-exceptions --pct-touch 40 --pct-motion 25 --pct-appswitch 10 --pct-rotation 5 -s 12358 -v -v -v --throttle 500 100000 2>$project/test_logs/monkey_error.txt 1>$project/test_logs/monkey_log.txt
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

上述指令碼(需要根據情況替換掉部分內容)的含義為執行 100000次 事件,每次事件相隔 500毫秒,忽略崩潰、忽略ANR,--pct-touch 40 --pct-motion 25 --pct-appswitch 10 --pct-rotation 5 為設定各種事件的百分比,Monkey的具體引數這裡不再贅述,大家可以檢視其他文章。

在執行這100000次事件的過程中,如果出現ANR、crash,那麼相關的日誌會輸出到 $project/test_logs/monkey_error.txt 路徑中,當測試結束之後我們可以判定monkey_error.txt檔案的大小,如果monkey_error.txt檔案中有內容那我們則認為本次測試失敗,然後通過郵件將 monkey_error.txt 作為附件傳送給相關人員,相關人員就可以通過 monkey_error.txt 以及測試裝置中的 /data/anr/traces.txt 檔案來定位、修復問題! 重要的是這些操作我們都可以讓Jenkins在夜間自動的為我們來完成,定期執行任務、分析報告與log、傳送郵件,例如我們的Jenkins任務會在每天夜裡 10點之後執行壓力測試,每次測試跑8個小時,那麼在第二天早上我們就可以得到測試報告,如果發現問題我們就可以在早上將問題解決掉,而不會拖到提交測試之後!!!

如果你的應用能夠經受8個小時壓力測試蹂躪之後沒有崩潰、沒有記憶體洩漏、沒有OOM,那麼在一定的程度上來說你的應用已經具備一定的穩定性。然後問題顯然沒有那麼簡單,在執行壓力測試的早期,你很可能在一個連續的時間段內都面臨測試失敗的問題。崩潰問題比較好查詢願意,那如果在壓力測試過程中如果出現了記憶體洩漏我們怎麼知道呢?我們有沒有辦法能夠自動化的發現問題?

我們的解決方案是通過定製 LeakCanary 來實現在自動化測試的過程中自動檢測記憶體洩漏,因為 LeakCanary 預設是在發現記憶體洩漏是在通知欄顯示,這樣不便於實現自動化。我們通過修改 LeakCanary 發現記憶體洩漏的策略來實現我們的目標,即發現記憶體洩漏之後將相關資訊寫入到一個具體的檔案,然後測試完成之後分析這個檔案,如果這個檔案裡面有內容,那麼認為產生了記憶體洩漏,最後將這個log檔案通過郵件傳送給相關人員。我們的修改如下:

public class LeakDumpService extends AbstractAnalysisResultService {    @Override    protected final void onHeapAnalyzed(HeapDump heapDump, AnalysisResult result) {        if ( !result.leakFound || result.excludedLeak ) {            return;        }        Log.e("", "### *** onHeapAnalyzed in onHeapAnalyzed , dump dir :  " + heapDump.heapDumpFile.getParentFile().getAbsolutePath());        String leakInfo = LeakCanary.leakInfo(this, heapDump, result, true);        CanaryLog.d(leakInfo);        // 將記憶體洩漏日誌        StorageUtils.saveResult(leakInfo);    }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

LeakCanary 檢測到記憶體洩漏之後就會執行 LeakDumpService 中的 onHeapAnalyzed 函式,在這個函式中我們將洩漏的資訊儲存到一個檔案中,每次執行產生的log會疊加寫入到同一個檔案,因此如果一次測試產生了多個洩漏我們就從一個檔案中得到。要使用LeakDumpService作為LeakCanary發現洩漏後的處理服務需要進行如下配置:

public final class LeakCanaryForTest {    private static String sAppPackageName = "";    private static RefWatcher sWatcher ;    public static void install(Application application) {        if (LeakCanary.isInAnalyzerProcess(application)) {            return;        }        sAppPackageName = application.getPackageName();        // 設定定製的 LeakDumpService , 將 leak 資訊輸出到指定的目錄        sWatcher = LeakCanary.refWatcher(application)                .listenerServiceClass(LeakDumpService.class)                .excludedRefs(AndroidExcludedRefs.createAppDefaults().build())                .buildAndInstall();        // disable DisplayLeakActivity        LeakCanaryInternals.setEnabled(application, DisplayLeakActivity.class, false);    }    /**     * 手動監控一個物件, 比如在 Fragment 的 onDestroy 函式中 呼叫 watch 監控Fragment是否被回收.     * @param target     */    public static void watch(Object target) {        if ( sWatcher != null ) {            sWatcher.watch(target);        }    }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

通過呼叫LeakCanaryForTest的install函式,我們就可以將LeakDumpService作為LeakCanary發現洩漏後的處理服務。這樣一來,我們就可以在執行壓力測試時通過 LeakCanary 檢測記憶體洩漏,並且將記憶體洩漏輸出到一個日誌檔案中,最後通過郵件得到這個日誌,然後根據日誌修復記憶體洩漏問題。因為壓力測試的事件是隨機性的,因此它能夠發現一些比較隱蔽的問題,這些測試路徑可能我們的QA同學不會測試到,因此Monkey 結合 LeakCanary 往往能夠得到意想不到的效果.

記憶體洩漏檢測效果如圖 3-2 所示:

圖3-2   

2017-03-27_leak.txt就是記憶體洩漏的日誌檔案, 部分日誌如下所示:

In  com.mynews:2.2.2:101.com.包名路徑.NewsDetailActivity  has  leaked:*  GC  ROOT  static  android.os.AsyncTask.SERIAL_EXECUTOR*  references  android.os.AsyncTask$SerialExecutor.mTasks*  references  java.util.ArrayDeque.elements*  references  array  java.lang.Object[].[0]*  references  android.os.AsyncTask$SerialExecutor$1.val$r  (anonymous  implementation  of  java.lang.Runnable)*  references  android.widget.TextView$3.this$0  (anonymous  implementation  of  java.lang.Runnable)*  references  android.support.v7.widget.AppCompatEditText.mContext*  references  android.support.v7.widget.TintContextWrapper.mBase*  references  android.view.ContextThemeWrapper.mBase*  leaks  com.包名路徑.NewsDetailActivity  instance
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

如果你一大早來到公司就收到了記憶體洩漏測試結果的報告,那麼恭喜你,你又即將解決了一個隱蔽的記憶體問題! 當然,沒有人願意在一大早開啟郵件就看到這類的測試報告。但這又何嘗不是一件好事,通過自動化的手段儘早的發現問題,解決問題,降低了成本、提升了應用質量。經過一段時間之後,我們相信應用內的記憶體洩漏問題會基本上被消滅掉!

四、開發與測試隔離

然而,我們並不是在開發的時候將 LeakCanary 引入到我們的工程中,因為它會拖慢我們的編譯速度,在開發測試過程中 LeakCanary 的記憶體檢測也會導致應用執行卡頓。比如我們只希望在執行壓力測試時引入 LeakCanary 進行記憶體檢測,那麼我們可以新建一個 module (這裡我們暫且叫做 leakfortest ), 該模組引用了 LeakCanary, 然後將 LeakCanaryForTest、LeakDumpService等類封裝到這個模組中,並且在壓力測試的時候引用它。這樣我們的應用模組build.gradle就需要做類似如下的修改:

android {    // 其他配置    productFlavors {        // 原包        prod {        }        // 用於壓力測試        monkey {        }    }}dependencies {    // 其他配置    // 用於在自動化測試中引入leakcanary監控記憶體洩露.    monkeyCompile project(':leakfortest')}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

然後我們我的應用程式碼中新增如下函式,程式碼如下:

public static void setupLeakCanary(Application application) {    if ( BuildConfig.FLAVOR.equals("monkey")  ) {        try {            Class canaryClz = Class.forName("com.simple.leakfortest.LeakCanaryForTest") ;            Method method = canaryClz.getDeclaredMethod("install", Application.class) ;            method.setAccessible(true);            method.invoke(null, application) ;        } catch (Exception e) {            Log.e("", "### leak canary error : " + e.getMessage()) ;            e.printStackTrace();        }    }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

然後我們在 Application 類中呼叫 setupLeakCanary 函式,在該函式中會判定如果這個應用是monkey flavor, 那麼就會整合 leakfortest 模組,並且在通過反射呼叫了LeakCanaryForTest類的install函式來整合我們定製過的 LeakCanary, 從而達到將記憶體洩漏的日誌輸出到特定檔案的效果. 為了實現這個效果,我們只需要將gradle任務中生成apk的命令改為 assembleMonkeyDebug, 然後將生成的apk安裝到裝置中,最後執行測試即可進行後續的流程。這樣一來,我們就將開發與自動化測試隔離開來了!

其他測試

通過上述的方案,我們就有了一套簡單、投入低、收益高的自動化測試方案,它們能夠快速的發現基礎模組的問題、記憶體洩漏問題,能夠保證應用的穩定性。但是這隻能保證應用邏輯在單個裝置的穩定性,不同的裝置可能會產生一些相容性的問題。因此,另一個重要的測試就是相容性測試,確保我們的應用在各種裝置上能夠正確的執行。如果條件許可,我們可以藉助市場上雲測試平臺執行一些monkey測試來驗證應用的相容性,從而避免相容性引發的問題。

如果說通過jenkins、monkey、單元測試能夠在一個點的角度保證應用的穩定性,那麼相容性測試就是從一個面的角度保證了應用的相容性。通過這兩個維度的測試,我們的應用肯定會越來越穩定,我們也能從中領悟更多軟體設計、測試的方法與思想。

然而,這一切只是開始,如果團隊有精力和時間,我們還可以在Jenkins中新增更多的方案進行測試。例如: 

  1. 通過 TinyDancer 、BlockCanary等效能檢測框架來查詢效能問題;
  2. 在測試過程中定期的輸出記憶體、CPU佔用,測試結束得到一個報表,最終可以與其他報告一塊來分析問題;
  3. 通過 Espresso、Robotium實現UI自動化測試。

通過不斷的完善自動化平臺,以機器替代部分的人工測試,我們的應用質量將會得到很大程度的保障。即使只有單元測試、壓力測試、LeakCanary記憶體檢測、雲平臺的相容性測試,我們的應用也能夠經受住創業公司快速迭代帶來的質量考驗。但並不是有更多的測試就會更好,有的時候也會適得其反,因此運用哪些測試方案、做到什麼程度都需要根據各自的情況進行決策。我們的目標是提高應用的質量,而不是增加測試的數量。

好吧,以上就是我這陣子的實踐與總結,也希望更多的人將自己的實踐、所思所得分享出來,讓我們在開發過程中少走彎路!

新書上市