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,就能正常訪問了。但總的來說很雞肋。
- 優點:實現上簡單,且比較容易理解。
- 缺點:存在性能問題;遠程化、插件化下的多ClassLoader存在覆蓋不到的問題;替換前就被加載的類及在其中被new出來的類和替換后加載的類不是同一個ClassLoader問題,apk運行時候就存在代碼安全隱患,雖然加白能解決但太過于麻煩。
2.3 findLoadedClass
在2.2中我們驗證了findLoadedClass其實是OK的,那么實際上我們也就能夠通過findLoadedClass來獲取到所有加載過的類信息。注意到:
該方法修飾符為protected,也即是正常情況下我們需要通過反射的方式來獲取到PathClassLoader,并繼續反射調用它的findLoadedClass方法,以獲取其加載狀態。
那么當我們的類很多的時候,多次調用反射去執行findLoadedClass方法必然會對性能帶來負面的影響。同樣的,也天然具有2.2節中無法檢測到獨立ClassLoader所加載的類情況,除非我們預先能知道整個apk運行期間有多少個自定義ClassLoader。存在覆蓋率上的問題。即:
- 優點:簡單,容易實現,且無代碼安全隱患
- 缺點:可能會引入較大的性能問題(執行耗時),獨立ClassLoader檢測不到的覆蓋率問題。
2.4 Hack訪問ClassTable
正如原文所訴,高德采用的是【復制ClassTable指針,通過標準API間接訪問類加載狀態的方案】,但更詳細的細節在文章中并沒有披露。網絡上有篇類似的處理:一種Android已加載類檢測方法
閱讀材料:
- bhook:https://github.com/bytedance/bhook/blob/main/doc/native_manual.zh-CN.md
- VirtualXposed:https://github.com/android-hacker/VirtualXposed/blob/122beb371519cb2d221ce06756361aaa30e2674f/VirtualApp/lib/src/main/jni/Foundation/fake_dlfcn.cpp#L4
- https://github.com/feicong/android-rom-book/tree/main/chapter-09
- 類加載虛擬機層
正如上面文章一種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高性能高穩定性代碼覆蓋率技術實踐