Android 代碼熱度統計(概述)

1. 前言

代碼熱度統計,在測試中一般也叫做代碼覆蓋率。一般得到代碼覆蓋率后就能了解整體樣本在線上的代碼使用情況,為無用代碼下線提供依據。

做了一下調研,在Android中一般比較常用的是:JaCoCO覆蓋率統計工具,它采用構建時插樁,APP運行采集覆蓋數據,并可本地可視化展示的一套完整鏈路。使用可參考:Android 代碼覆蓋率統計

但大量插樁必然會帶來性能、包大小上的劣勢,相關更詳細的使用和分析可以參考高德的這篇文章:Android 端代碼染色原理及技術實踐

在高德的另一篇文章:高德Android高性能高穩定性代碼覆蓋率技術實踐 中也提到了其實代碼熱度統計有多種方式,如下圖:
在這里插入圖片描述
但,正如文章中所訴,Jacoco、Hook PathClassLoader方案雖然兼容性極強,但均會影響性能和包大小,故不適合上線到生產環境中。而通過ClassLoader的findLoadedClass方案:

在Android中對于App自定義的類,即PathClassLoader加載的類,如果直接調用findLoadedClass進行查詢,即使這個類沒有加載,也會執行加載操作。

很明顯,不合適。故上述適合生產環境的方案只有一種,即:Hack訪問ClassTable方案。

2. 方案介紹

Jacoco更加適用于測試同學功能驗證,對比查看驗證功能邏輯對應的代碼覆蓋情況,以確保不漏測。

相關教程網絡上很多,比如:搜索到一篇相關的文章:滴滴開源 Super-jacoco:java 代碼覆蓋率收集平臺文檔 可以了解下,它增強了本地測試驗證中的增量代碼覆蓋程度統計。

2.1 插樁的另一種方案

前文介紹了,Jacoco的插樁方式采集粒度很細,帶來的apk包大小增量和性能的增量是較大的。而注意到,高德介紹的后三種的采集粒度都是class,那么對應的其實我們可以只在每個class的init方法中插樁,這樣無論是apk包大小增量還是性能的負面影響都會低很多。我們自己實現也挺簡單,可以參考字節的byteX:coverage-plugin。

這種方案同樣不能覆蓋到插件化、遠程化這些動態加載的Class,且每個類的init或者cinit方案去插樁埋點,本身會有包大小、運行性能的損耗,比較雞肋。

2.2 Hook PathClassLoader方案

在插件化、遠程化過程中,我們一般需要自定義一個PathClassLoader來替換APP一啟動創建的ClassLoader,這樣我們就能攔截在application的attachBaseContext之后的findClass或者loadClass行為,故而就能知道當前啟動訪問了哪些類。

實現方案比較簡單,可以參考Qigsaw的SplitDelegateClassloader塞入的過程。或者可以參考這篇文章:Android旁門左道之動態替換應用程序。關鍵邏輯即為:通過context獲取到LoadedApk mPackageInfo,在LoadedApk里面定義的ClassLoader mClassLoader即為待替換的目標。如果替換失敗了,可再替換ContextImpl中的ClassLoader mClassLoader;作為兜底。

至于為什么需要這么替換,需要了解APP的啟動,可以閱讀ActivityThread開始追代碼和debug調試看看,后面再詳細展開。

至于實現:

// 自定義類加載器
public class MFClassLoader extends PathClassLoader {public final static String TAG = ConstantValues.TAG;private static BaseDexClassLoader originClassLoader;public MFClassLoader(ClassLoader parent) {super("", parent);originClassLoader = (BaseDexClassLoader) parent;}@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {Log.e(TAG, "====> findClass: " + name);// U can upload info to server. then analysis all datas.try {return originClassLoader.loadClass(name);} catch (ClassNotFoundException error) {error.printStackTrace();throw error;}}@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {return findClass(name);}
}
// 替換classLoader
public class MFApplication extends Application {public final static String TAG = ConstantValues.TAG;@Overrideprotected void attachBaseContext(Context base) {super.attachBaseContext(base);attachBaseContextCallBack(base);}private void attachBaseContextCallBack(Context base) {boolean b = replaceClassLoader(base, new MFClassLoader(MFApplication.class.getClassLoader()));Log.e(TAG, "====> attachBaseContext --> [replace classloader " + b + "]");}private boolean replaceClassLoader(Context baseContext, ClassLoader reflectClassLoader) {try {Object packageInfo = HiddenApiReflection.findField(baseContext, "mPackageInfo").get(baseContext);if (packageInfo != null) {HiddenApiReflection.findField(packageInfo, "mClassLoader").set(packageInfo, reflectClassLoader);}Log.e(TAG, "===> replaceClassLoader by packageInfo.");return true;} catch (Throwable e) {e.printStackTrace();}try {HiddenApiReflection.findField(baseContext, "mClassLoader").set(baseContext, reflectClassLoader);Log.e(TAG, "===> replaceClassLoader by Context.");return true;} catch (Throwable e) {e.printStackTrace();}return false;}
}

注意到上述代碼中:

public MFClassLoader(ClassLoader parent) {// public PathClassLoader(String dexPath, ClassLoader parent) super("", parent);originClassLoader = (BaseDexClassLoader) parent;
}

對應的dexPath傳入的是一個空值,也即是實際上類查找的時候所使用的ClassLoader還是originClassLoader去加載Class,而每個類對應的Class對象的classLoader屬性中記錄了當前加載的類加載器對象,也就是實際上還是會記錄的是originClassLoader。那么后續我們在任意一個類中,通過this.getClass().getClassLoader獲取到的ClassLoader對象還是原來的originClassLoader,自然在該對象中new xxx()對象,還是使用的originClassLoader,也就是后續的類查找,其實我們自定義的MFClassLoader其實感知不到。那么如何解決?

這里其實很簡單,那就是讓當前我們定義的MFClassLoader去查找真正的類。也即是需要在初始化的時候傳入dexPath和librarySearchPath,這兩個內容可以很輕松獲取到,比如:

// dexPath 無遠程化、插件化情況,一般就只有base.apk
// 如:/data/app/com.mengfou.honeynote-X_rWVreU1BlVpRYlCS-5Jw==/base.apk
context.getPackageCodePath()
// librarySearchPath 同理,一般也為base apk的lib目錄
// 如:/data/app/com.mengfou.honeynote-X_rWVreU1BlVpRYlCS-5Jw==/lib/arm64
private String getPathFromReflect(ClassLoader originalClassLoader) {try {Field pathListField = HiddenApiReflection.findField(originalClassLoader, "pathList");pathListField.setAccessible(true);Object pathList = pathListField.get(originalClassLoader);Field nativeLibraryDirectoriesField = HiddenApiReflection.findField(pathList, "nativeLibraryDirectories");nativeLibraryDirectoriesField.setAccessible(true);List<File> nativeLibraryDirectories = (List<File>) nativeLibraryDirectoriesField.get(pathList);if(nativeLibraryDirectories != null) {Log.e(TAG, "===> MFClassLoader nativeLibraryDirectories: " + nativeLibraryDirectories.get(0) );return nativeLibraryDirectories.get(0).getAbsolutePath();}} catch (NoSuchFieldException | IllegalAccessException e) {e.printStackTrace();}return "";
}

那么對應的自定義類加載器就改寫為:

public class MFClassLoader extends PathClassLoader {public final static String TAG = ConstantValues.TAG;private static BaseDexClassLoader originClassLoader;// 第一種實現 public MFClassLoader(ClassLoader originalClassLoader) {super("", originalClassLoader);originClassLoader = (BaseDexClassLoader) originalClassLoader;}// 第二種實現public MFClassLoader(String dexPath, String libraryPath, ClassLoader originalClassLoader) {super(dexPath, libraryPath,  originalClassLoader.getParent());originClassLoader = (BaseDexClassLoader) originalClassLoader;}@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {Class<?> aClass;try {aClass = super.findClass(name);} catch (ClassNotFoundException e) {aClass = originClassLoader.loadClass(name);}Log.e(TAG, beautifulPrint(name, aClass.getClassLoader().getClass().getCanonicalName()));return aClass;}private String beautifulPrint(String name, String canonicalName) {int length = name.length();StringBuilder stringBuilder = new StringBuilder("===> findClass: ");stringBuilder.append(name);while(length < 80) {stringBuilder.append(" ");length++;}stringBuilder.append(canonicalName);return stringBuilder.toString();}@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {return findClass(name);}
}

這樣修改后,幾乎所有的類我們都能感知到,比如:
在這里插入圖片描述
測試某個類中查找未加載的類:
在這里插入圖片描述
我們的自定義ClassLoader也攔截到了。

值得注意的是,前面參考的博客中指出“為了提升啟動性能,對于App自定義的類,即PathClassLoader加載的類,如果直接調用findLoadedClass進行查詢,即使這個類沒有加載,也會執行加載操作。

這里對其進行了驗證,上述結論大概是錯誤的。且Android官方文檔也說明了該API:
在這里插入圖片描述

寫了個案例驗證:
在這里插入圖片描述
觀察源碼:

class PathClassLoader extends BaseDexClassLoader
class BaseDexClassLoader extends ClassLoader

而 findLoadedClass(name);方法的調用只出現在ClassLoader類中。可通過cs.android.com來查閱。而在ClassLoader類的loadClass方法中我們可以看見這樣的一個調用:
在這里插入圖片描述
進入該方法:
在這里插入圖片描述

走到了VMClassLoader的一個native方法。也即是art/runtime/native/java_lang_VMClassLoader.cc。至少源碼反應在Java代碼層未做主動load。而至于native方法中是否有在findLoadedClass方法,去加載,待考究,后面再看。

回到主題【Hook PathClassLoader方案】,在一定程度上確實可行,但一般大型apk中都有動態dex/apk,會自定義ClassLoader,這部分會檢測不到。另外,因為我們是在Application的attach方法中進行的替換ClassLoader,那么其實在替換之前就加載的類查找也是使用原有的ClassLoader,也即是還會丟失部分數據。比如:
在這里插入圖片描述
這里我們構建對象的ClassLoader就是原來的PathClassLoader。因為MFApplication是該classLoader加載的。

而且這樣會存在代碼安全隱患,因為也就是在APP啟動后至少是在Application和其余代碼中間就存在兩個ClassLoader,因為兩個ClassLoader在第二種實現中是獨立的,也就是分別在兩個ClassLoader中獲取到的對象,其實數據毫無關系,比如我們在Application中存儲了一下this,然后期望在后面某個由自定義ClassLoader加載的實例化類去訪問存儲的Application,但其實正常情況情況下訪問不到,比如:
在這里插入圖片描述
調用后會報錯,NPE。而如果用第一種實現就無該問題,因為本質上都使用的originalClassLoader,但我們自定義的ClassLoader這個時候就無用了,因為幾乎不能攔截和記錄到findClass的過程。

那么如果需要用第二種實現,我們就需要對工程進行改造,確保在自定義Application中沒有訪問非替換ClassLoader的類,顯然有點強人所難,因為實際開發中,我們確實會使用自定義Application的各個回調接口來定義加載某些類,比如初始化框架、啟動器等。

略微一想其實也能解決,就是處理比較麻煩。比如這里保存的Application的類,若后面有自定義ClassLoader加載的類中訪問Application中new出來的對象的類,我們可以加個白名單:
在這里插入圖片描述
如上圖所示,讓自定義Application和ContextManager用originalClassLoader,就能正常訪問了。但總的來說很雞肋。

  1. 優點:實現上簡單,且比較容易理解。
  2. 缺點:存在性能問題;遠程化、插件化下的多ClassLoader存在覆蓋不到的問題;替換前就被加載的類及在其中被new出來的類和替換后加載的類不是同一個ClassLoader問題,apk運行時候就存在代碼安全隱患,雖然加白能解決但太過于麻煩。

2.3 findLoadedClass

在2.2中我們驗證了findLoadedClass其實是OK的,那么實際上我們也就能夠通過findLoadedClass來獲取到所有加載過的類信息。注意到:
在這里插入圖片描述
該方法修飾符為protected,也即是正常情況下我們需要通過反射的方式來獲取到PathClassLoader,并繼續反射調用它的findLoadedClass方法,以獲取其加載狀態。

那么當我們的類很多的時候,多次調用反射去執行findLoadedClass方法必然會對性能帶來負面的影響。同樣的,也天然具有2.2節中無法檢測到獨立ClassLoader所加載的類情況,除非我們預先能知道整個apk運行期間有多少個自定義ClassLoader。存在覆蓋率上的問題。即:

  1. 優點:簡單,容易實現,且無代碼安全隱患
  2. 缺點:可能會引入較大的性能問題(執行耗時),獨立ClassLoader檢測不到的覆蓋率問題。

2.4 Hack訪問ClassTable

正如原文所訴,高德采用的是【復制ClassTable指針,通過標準API間接訪問類加載狀態的方案】,但更詳細的細節在文章中并沒有披露。網絡上有篇類似的處理:一種Android已加載類檢測方法

閱讀材料:

  1. bhook:https://github.com/bytedance/bhook/blob/main/doc/native_manual.zh-CN.md
  2. VirtualXposed:https://github.com/android-hacker/VirtualXposed/blob/122beb371519cb2d221ce06756361aaa30e2674f/VirtualApp/lib/src/main/jni/Foundation/fake_dlfcn.cpp#L4
  3. https://github.com/feicong/android-rom-book/tree/main/chapter-09
  4. 類加載虛擬機層

正如上面文章一種Android已加載類檢測方法所訴:
在這里插入圖片描述
Hack訪問ClassTable方式本質上還是傳入每個類去查找這個類是否被loaded,同樣的classTable在每個ClassLoader中都不一樣,所以也需要找到所有的classloader,但有個好處就是沒有替換全局PathClassLoader那樣,需要考慮和處理由于存在兩個PathClassLoader所引入的代碼安全性隱患。但實際上,如果某個動態加載的apk/dex,使用的是獨立自定義的ClassLoader來加載,那么其實還是會丟失數據。

這么來說,其實這里【Hack訪問ClassTable】方案和【Hook PathClassLoader】方案的優勢就是:① 無需處理由于存在兩個PathClassLoader所引入的代碼安全性隱患;② 在native層調用lookup方法來查找,可能性能會略優于2.3節的方案,但還是需要遍歷所有的Class name去做匹配(但無需頻繁反射調用findLoadedClass這種Java層代碼)。
缺點:獨立ClassLoader檢測不到的覆蓋率問題。

2.5 參考博客https://juejin.cn/post/7282606413842612283

3. 相關鏈接

  • Android 常見熱修復方案及原理
  • 另一種繞過 Android P以上非公開API限制的辦法
  • Android高性能高穩定性代碼覆蓋率技術實踐

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/914511.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/914511.shtml
英文地址,請注明出處:http://en.pswp.cn/news/914511.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

RAG優化

RAG搭建本地AI知識庫&#xff0c;在使用過程中遇到的三大痛點&#xff0c;以及相應的進階方案。1. RAG知識庫的三大痛點-- 內容理解不足&#xff1a;AI難以全面理解導入資料的內容&#xff0c;比如在向量編碼時候&#xff0c;生硬的截斷等導致分析結果不理想。eg: 知識庫分割器…

Ubuntu 24.04 啟用 root 圖形登錄

關鍵詞&#xff1a;Ubuntu 24.04、root 登錄、GDM、SSH、nano、配置文件一、前言 Ubuntu 默認禁用 root 賬戶 的圖形與 SSH 登錄&#xff0c;這是為了安全。但在某些場景&#xff08;如測試、救援、自動化腳本&#xff09;你可能需要 直接用 root 登錄 GNOME 桌面。本文以 Ubun…

Jekyll + Chirpy + GitHub Pages 搭建博客

Chirpy 是適用于技術寫作的簡約、響應迅速且功能豐富的 Jekyll 主題&#xff0c;文檔地址&#xff1a;https://chirpy.cotes.page/ &#xff0c;Github 地址&#xff1a;jekyll-theme-chirpy 。 1.開始 打開 chirpy-starter 倉庫&#xff0c;點擊按鈕 Use this template -->…

學習 Flutter (一)

學習 Flutter (一) 1. 引言 什么是 Flutter&#xff1f; Flutter 是 Google 開發的一套開源 UI 框架&#xff0c;主要用于構建高性能、高保真、跨平臺的應用程序。使用一套 Dart 編寫的代碼&#xff0c;開發者可以同時構建適用于&#xff1a; Android iOS Web Windows、mac…

Spring Boot 實現圖片防盜鏈:Referer 校驗與 Token 簽名校驗完整指南

Spring Boot 實現圖片防盜鏈教程&#xff08;Referer 校驗 Token 簽名校驗&#xff09;本文將詳細講解兩種防盜鏈實現方案&#xff0c;并提供完整代碼示例。方案一&#xff1a;Referer 校驗通過檢查 HTTP 請求頭中的 Referer 字段判斷來源是否合法。實現步驟創建 Referer 攔截…

從 JSON 到 Python 對象:一次通透的序列化與反序列化之旅

目錄 一、為什么要談 JSON 二、最快速上手&#xff1a;兩把鑰匙 dumps 與 loads 三、深入 dumps&#xff1a;參數是魔法棒 四、深入 loads&#xff1a;把風險擋在門外 五、文件級序列化&#xff1a;dump 與 load 六、處理中文與編碼陷阱 七、異常場景與調試技巧 八、實…

Leetcode 3315. 構造最小位運算數組 II

1.題目基本信息 1.1.題目描述 給你一個長度為 n 的質數數組 nums 。你的任務是返回一個長度為 n 的數組 ans &#xff0c;對于每個下標 i &#xff0c;以下 條件 均成立&#xff1a; ans[i] OR (ans[i] 1) nums[i] 除此以外&#xff0c;你需要 最小化 結果數組里每一個 a…

黑搜小知識 | DNS域名解析過程是什么樣的?

什么是DNS&#xff1f;DNS( Domain Name System)是“域名系統”的英文縮寫&#xff0c;是一種組織成域層次結構的計算機和網絡服務命名系統&#xff0c;它用于TCP/IP網絡&#xff0c;它所提供的服務是用來將主機名和域名轉換為IP地址的工作。舉例來說&#xff0c;如果你要訪問域…

MyBatis 使用教程及插件開發

作者&#xff1a;小凱 沉淀、分享、成長&#xff0c;讓自己和他人都能有所收獲&#xff01; 本文的宗旨在于通過簡單干凈實踐的方式教會讀者&#xff0c;使用 SpringBoot 配置 MyBatis 并完成對插入、批量插入、修改、查詢以及注解事務和編程事務的使用&#xff0c;通過擴展插件…

Maui勸退:用windows直接真機調試iOS,無須和Mac配對

初級代碼游戲的專欄介紹與文章目錄-CSDN博客 我的github&#xff1a;codetoys&#xff0c;所有代碼都將會位于ctfc庫中。已經放入庫中我會指出在庫中的位置。 這些代碼大部分以Linux為目標但部分代碼是純C的&#xff0c;可以在任何平臺上使用。 源碼指引&#xff1a;github源…

【極客日常】后端任務動態注入執行策略的一種技術實現

近期做項目時遇到一個場景&#xff0c;是需要在后端任務執行時動態注入策略。具體而言&#xff0c;筆者負責的后端服務&#xff0c;可以理解是會在線上服務發布時&#xff0c;對服務風險做實時掃描&#xff0c;那么這個掃描就需要根據當前線上服務發布上下文&#xff0c;匹配對…

8. JVM類裝載的執行過程

1. JVM介紹和運行流程-CSDN博客 2. 什么是程序計數器-CSDN博客 3. java 堆和 JVM 內存結構-CSDN博客 4. 虛擬機棧-CSDN博客 5. JVM 的方法區-CSDN博客 6. JVM直接內存-CSDN博客 7. JVM類加載器與雙親委派模型-CSDN博客 8. JVM類裝載的執行過程-CSDN博客 9. JVM垃圾回收…

Linux操作系統之信號:信號的產生

前言&#xff1a;上篇文章我們大致講解了信號的有關概念&#xff0c;為大家引入了信號的知識點。但光知道那些是遠遠不夠的。本篇文章&#xff0c;我將會為大家自己的講解一下信號的產生的五種方式&#xff0c;希望對大家有所幫助。一、鍵盤&#xff08;硬件&#xff09;產生信…

pdf拆分

文章目錄 背景目標實現下載 背景 好不容易下載的1000頁行業報告&#xff0c;領導非要按章節拆分成20份&#xff01;學術論文合集需要按作者拆分投稿&#xff0c;手動分頁到懷疑人生…客戶發來加密合同&#xff0c;要求每5頁生成獨立文檔&#xff0c;格式還不能亂&#xff01; …

vue3使用mermaid生成圖表,并可編輯

效果圖實際代碼<template><div class"mermaid-container" style"z-index: 99999" ref"wrapperRef"><!-- 控制欄 --><div class"control-bar"><div class"control-bar-flex control-bar-tab-wrap"…

tcp/quic 的滑動窗口

一、滑動窗口 rwnd&#xff1a; 接收端窗口&#xff0c;接收方在每次發送ACK確認報文時&#xff0c;會包含一個 rwnd (Receive Window Size) 字段&#xff0c;指明自己當前剩余的接收緩沖區大小&#xff08;即可用窗口&#xff09;&#xff0c;這里是否是socket的接收緩沖區&am…

JVM監控及診斷工具-命令行篇

18.1. 概述 性能診斷是軟件工程師在日常工作中需要經常面對和解決的問題&#xff0c;在用戶體驗至上的今天&#xff0c;解決好應用的性能問題能帶來非常大的收益。 Java 作為最流行的編程語言之一&#xff0c;其應用性能診斷一直受到業界廣泛關注。可能造成 Java 應用出現性能…

Jenkins 版本升級與插件問題深度復盤:從 2.443 到 2.504.3 及功能恢復全解析

前言&#xff1a;問題溯源與升級必要性 在 Jenkins 持續集成體系中&#xff0c;插件生態是其強大功能的核心驅動力。然而&#xff0c;某次例行維護中&#xff0c;團隊對 Jenkins 2.443 環境的插件進行批量升級后&#xff0c;意外觸發連鎖反應 &#xff1a; SSH Server 插件功能…

Ribbon實戰

一、前置知識 1.1 負載均衡定義 負載均衡指的是將網絡請求通過不同的算法分配到不同的服務器上的技術&#xff0c;從而提升系統的性能。 1.2 負載均衡工具 負載均衡工具可以分分為客戶端負載均衡工具和服務端負載均衡工具&#xff0c;它們的區別如下。 表1-1 負載均衡工具…

cs285學習筆記(一):課程總覽

根據 Fall 2023 學期的官方課程日程&#xff0c;這里是 CS?285 全課程的 Lecture 大綱及內容摘要&#xff0c;詳細對應周次和主題&#xff0c;方便你快速定位每節課要點、相關作業與視頻資源 &#x1f3af; 官方課程地址 YouTobe 視頻地址 blibli視頻(帶中文字幕) &#x…