高德Android高性能高穩定性代碼覆蓋率技術實踐

前言

代碼覆蓋率(Code coverage)是軟件測試中的一種度量方式,用於反映代碼被測試的比例和程度。

在軟件迭代過程中,除了應該關注測試過程中的代碼覆蓋率,用戶使用過程中的代碼覆蓋率也是一個非常有價值的指標,同樣不可忽視。因爲伴隨着業務擴展和功能更新,產生了大量過時和廢棄的代碼,這些代碼或者很少甚至完全不再使用,或者“年久失修”,缺少維護,不僅對應用包體積有影響,還可能帶來穩定性風險。此時,能夠採集生產環境的代碼覆蓋率,瞭解線上代碼的使用情況,爲下線無用代碼提供依據,就十分重要了。

目標

我們的目標很明確:根據雲端配置,採集線上每個類的觸達和使用頻次,上傳到雲端,在平臺進行處理,並提供查詢和報表展示能力

如上圖所示,我們期望代碼覆蓋率數據能在平臺上進行查詢和直觀的展示,在需要時可以直接查看,爲下線舊代碼、資源調度和分配等提供決策依據,最終爲用戶提供更小的App安裝包,更好的功能使用體驗。

通過雲控中心,我們可以控制是否啓用覆蓋率採集,也可以根據覆蓋率(類使用頻次)動態調整App中金剛位、線程等資源的調度分配策略。其中覆蓋率採集方案是最爲重要的一環,業界也有很多成熟的方案,但都有各自適合的場景,而我們的訴求是在儘量不影響用戶使用和App運行的前提下,採集類粒度的代碼使用覆蓋率。使用的採集方案應該少Hack,實現簡單,兼顧穩定性和性能,同時也不會侵入打包流程,帶來包體積影響等,在經過深入探索後,我們自研出了一套完美滿足這些要求的全新方案。

方案對比

下表爲常見方案與自研方案的各項指標對比,綠色表示更優。

從表格中可以看出:

Jacoco方案

類似的還有Emma、Cobertura等,他們都通過插樁實現,可以支持所有版本所有粒度的採集,但是插樁帶來了一定的包體積和性能影響,不適合線上大範圍使用。

Hook PathClassLoader方案

實現簡單,無源碼侵入,且支持所有Android版本,但Hook PathClassLoader不僅帶來了性能影響,甚至可能波及App穩定性。

Hack訪問ClassTable方案

能夠按需採集,對App性能幾乎沒有影響,但Hack可能帶來兼容性問題,且實現較複雜。

自研方案

  • 性能優異,支持按需採集,無損App性能

  • 實現簡單,未使用任何“黑科技”,穩定性和兼容性極好

  • 支持跨進程和插件採集

對比得知自研方案能更好的滿足我們採集線上代碼覆蓋率的訴求,因爲它不僅有着很好的穩定性,而且有着優異的性能,幾乎不會對用戶產生任何影響。那麼它是如何做到高性能和高穩定性的呢?請看下文介紹。

方案介紹

原理

要採集類粒度的代碼覆蓋率,其實就是要知道在App運行過程中,加載和使用了哪些類。在Java應用中,這可以通過調用ClassLoader的findLoadedClass方法直接查詢得到,而在Android App中卻沒那麼簡單。原因是Android系統做了這樣一個優化:

爲了提升啓動性能,對於App自定義的類,即PathClassLoader加載的類,如果直接調用findLoadedClass進行查詢,即使這個類沒有加載,也會執行加載操作。

這不是我們期望的。

雖然我們沒辦法直接調用FindLoadedClass方法查詢類的加載狀態,但是經過深入研究和分析,我們發現ClassLoader最終是通過查詢它的ClassTable字段得到類加載狀態的,如果我們也能訪問ClassTable,問題不就迎刃而解了嗎?沿着這個思路,我們創新性地提出了複製ClassTable指針,通過標準API間接訪問類加載狀態的方案。

該方案巧妙地實現了對ClassTable的無Hack訪問;同時完美繞開了我們不需要的類加載優化,寥寥數行代碼就實現了類加載情況的獲取,巧妙且簡潔,同時它還具備以下優勢:

  • 採集速度是普通方案的5倍以上,性能優異
  • 使用標準API訪問ClassTable,兼容性與穩定性極佳
  • 僅使用一次反射,無任何“黑科技”,簡單穩定
  • 不影響類加載及App運行
  • 完美支持多進程和插件的採集

不過有一點需要注意:

ClassTable字段是從Android N開始引入的,所以該方法只適用於Android N及以上。出於必要性和ROI考慮,我們也未對Android N以下版本進行適配。

採集流程

基於上述的方案,我們設計了完整的代碼覆蓋率採集功能,關鍵流程如下:

可以看到整個端側的採集流程是串行的,非常便於流程控制和數據整合。下面說明一下設計思路:

  • 採集時將App分爲兩部分,一部分是主進程和子進程使用的宿主類數據,另一部分是插件類數據。

  • 基於查詢方式採集,主進程、子進程、插件分別提供查詢類加載狀態的接口。

  • 流程基於串行方式,由主進程控制,依次調用相應的接口採集主進程、子進程和插件的數據。

  • 每個版本只採集和上報未加載過的類數據,首次採集時,以類全集爲輸入;後續的每次採集,以上一版本未加載的類爲輸入,採集次數越多,需要查詢的類越少。

  • 主進程和子進程依次查詢,查詢都以上一次查詢後剩餘的未加載類爲輸入,因此越靠後的子進程所需查詢的數量越少,同一個插件在不同進程的實例的查詢也與此類似。

如下圖所示:

  • 採集結束時,會生成一份宿主類數據和N份插件類數據(假如有N個插件)。這些數據會分別與之前的採集結果做Diff,將增量數據上傳服務。

  • 服務平臺進行存儲、解Mapping、模塊關聯等處理,最後以報表形式聚合展示。

值得注意的是:

  • 主進程與子進程使用的類都屬於宿主,採集結果應該合併爲一份數據;同理,一個插件無論在多少個進程加載,最後也只應生成一份該插件的數據。

  • 採集時我們將數據分爲兩部分,這樣可以提高採集效率,也方便後續解混淆;在平臺展示時,合併展示更有意義。

版本管理

Android App的代碼大都會經過混淆處理,混淆後的類名會因版本而異,這就需要根據App版本來管理覆蓋率數據。

按版本管理數據後,每個版本會清除上一版本的數據,避免數據錯亂;一個特定的類,在當前版本已經使用過之後,會記錄下來,後續此版本的採集不再重複查詢它的使用情況。

每個版本首次採集時,需要以App的類名全集作爲輸入,每一次採集會產生一個未使用類的集合,作爲下一次採集的輸入。這樣,一個版本中每次採集需要關注的類數量會逐步減少,可避免無意義的查詢,提升採集性能。

類名數據獲取

類名數據可以通過兩種方式獲取:

1.從安裝包獲取

安裝包內的類名數據可以從PathClassLoader中獲取,插件則可以從對應的BaseDexClassLoader中獲取,使用如下方法即可:

public static List<String> getClassesFromClassLoader(BaseDexClassLoader classLoader) throws ClassNotFoundException, IllegalAccessException {
    //類名數據位於BaseDexClassLoader.pathList.dexElements.dexFile中,可以通過反射獲取

    //先獲取pathList字段
    Field pathListF = ReflectUtils.getField("pathList", BaseDexClassLoader.class);
    pathListF.setAccessible(true);
    Object pathList = pathListF.get(classLoader);

    //獲取pathList中的dexElements字段
    Field dexElementsF = ReflectUtils.getField("dexElements", Class.forName("dalvik.system.DexPathList"));
    dexElementsF.setAccessible(true);
    Object[] array = (Object[]) dexElementsF.get(pathList);

    //獲取dexElements中的dexFile字段
    Field dexFileF = ReflectUtils.getField("dexFile", Class.forName("dalvik.system.DexPathList$Element"));
    dexFileF.setAccessible(true);
    ArrayList<String> classes = new ArrayList<>(256);
    for (int i = 0; i < array.length; i++) {
        //獲取dexFile
        DexFile dexFile = (DexFile) dexFileF.get(array[i]);
        //遍歷DexFile獲取類名數據
        Enumeration<String> enumeration = dexFile.entries();
        while (enumeration.hasMoreElements()) {
            classes.add(enumeration.nextElement());
        }
    }
    return classes;
}

這種方式簡單直接,不過會一次性將DexFile中的所有類名加載到內存中,而根據我們的測試,每一萬個類大約佔0.8mb內存,對於動輒數萬個類的大型App來說,會是一個不小的內存開銷。所以還可以考慮第二種方式。

2.雲化下載

從構建平臺獲取類名數據,上傳到雲化平臺,App在需要的時候下載使用。

至於選用哪種方式,直接根據類數量來選取就好。類數量特別多時,如大型App場景,建議使用雲化方式;普通App或插件,直接從安裝包類獲取即可。

子進程採集

主進程未加載的類,我們會交給子進程再次查詢。這就需要子進程提供支持跨進程調用的查詢接口,我們選擇了簡單可靠,且容易複用的AIDL方案來實現。

具體做法是:

通過AIDL定義查詢接口,並定義對應的Action,在Service的onBind方法中根據Action返回查詢接口的Binder實現類用於遠程調用。

同時考慮到跨進程的成本較高,如果對每個類都調用一次查詢接口,無疑是難以接受的。於是我們想到了文件+批量查詢的方式:利用文件作爲數據載體,將已加載的類和未加載的類都寫入到文件中,在接口間傳遞文件路徑。文件操作還可以採用BufferedReader和BufferedWriter以提升性能。

調用過程如圖:

這樣做的好處也顯而易見:

  • 採集一個進程僅需一次跨進程調用,成本極低

  • 避免數據序列化的內存開銷

  • 繞開大數據無法直接跨進程傳遞的問題

  • 採集流程更簡單,可按需採集需要的進程

  • 方便數據過濾,避免重複查詢已加載類,提升採集性能

插件採集

對於宿主類,查詢PathClassLoader對應的ClassTable即可。

而插件一般通過BaseDexClassLoader或其派生類進行加載,需要查詢相應ClassLoader的ClassTable。

對於在子進程中使用的插件,只是多了跨進程接口調用,將已加載類和剩餘類返回給主進程進行處理的操作。

採集步驟如下:

  • 查詢子進程類時,會同時查詢該進程中運行的插件類,將數據寫入按插件名劃分的文件。

  • 對主進程插件的採集是整個流程的最後一個環節,此時會檢測每個插件對應的數據文件(子進程生成),並進行合併處理,最後將數據文件刪除。

  • 最後再處理剩餘的插件數據文件,這部分文件屬於只在子進程運行的插件。

到此,就得到了所有插件的類加載數據。

解Mapping

查看代碼覆蓋率數據時,我們期望看到原始的類名,所以解Mapping是必經之路。

解Mapping操作可以在端上進行,也可以在服務側進行,出於安全性考慮,我們選擇了服務側。

Mapping文件由打包過程生成,每個安裝包對應一份。我們的做法是在構建平臺打正式包的時候通過腳本生成混淆類與明文類的映射文件,服務端在需要的時候通過App版本信息獲取對應的映射文件,反解出原始類名,並與模塊進行關聯。

最終展示到平臺的就是解完Mapping,並與模塊、插件完成關聯的代碼覆蓋率數據。

數據存儲及增量計算

採集的數據需要存儲起來,爲了方便計算增量數據,我們選擇了數據庫作爲存儲方案,因爲它天生具備去重及排序功能,而且性能也不錯。具體的做法是:

  • 創建一張數據表,只需包含一個名爲class的列就行,該列聲明爲主鍵,不接受空值和重複。

  • 每次採集前,獲取其中的行數,採集過程中,將已加載的類名數據更新到表中,讓數據庫自動完成去重。採集完成後,再次獲取數據行數,與採集前的行數相減得出的offset就是增量部分,我們只需要將這部分數據上傳到服務。

性能和穩定性

經過我們的反覆測試和調優,對5w+類的採集平均耗時約0.5s/次,採集期間內存增長在500kb左右,CPU無明顯上漲。

同時也經過高德地圖線上多個版本驗證,未發現相關崩潰及ANR。

其他

繞開黑灰名單

Android P以後,官方將ClassTable成員變量加入了黑灰名單,在使用反射訪問之前,需繞開SDK限制。我們採用的是元反射+設置豁免的方式,具體的實現可以參考GitHub上的開源項目FreeReflection,想要了解更多可自行Google查詢。

採集時機和頻率

雖然採集過程短暫無感,但爲了最小的影響App的運行,我們將採集工作放在子線程中,並選擇在App退後臺一段時間後開始執行。

同時由於我們只需要知道代碼使用的比例和大致情況,每次冷啓後只採集一次即可。

多位用戶多次冷啓後的數據,已經足以反映真實的代碼使用情況了。如果需要每個類的使用頻次數據,在服務端聚合統計也能得到。

寫在最後

代碼覆蓋率作爲一種度量方式,不僅能爲我們下線舊代碼提供依據,同時還能反映某個功能的使用熱度,可以爲資源分配、調度決策等提供依據,是軟件開發中一項不可或缺的重要工具。

我們這套全新的方案,簡潔而不簡單,巧妙地實現了無Hack採集,在保證高穩定性和不侵入源碼的前提下,優雅地實現了生產環境代碼覆蓋率的高性能採集,已經過高德地圖多版本驗證,是一套成熟、穩定且高效的方案。在此分享出來,希望能爲有同樣訴求的同學提供一些借鑑和思路。

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