58同城Android端-最小外掛化框架實戰和原理分析

01

背景

移動網際網路進入存量時代,隨著人口紅利減退,充分盤活、經營現有流量便成為了各行各業全新的機遇與挑戰。各大公司都在內捲髮力,對 App 包大小、啟動速度、效能做持續最佳化。

App 包體積和使用者轉換率成負相關,包體積越小、使用者下載時長越短,使用者轉換率越高。而隨著國內使用者的增量見頂,越來越多的應用選擇出海,開發對應的海外版,Google Play 應用市場目前強制要求超過 100MB 的應用只能使用 AAB 擴充套件檔案方式上傳,Google Play 會為我們的應用託管 AAB 擴充套件檔案,進行自定義分發和動態交付。

(可以看到,排名靠前的 App 包大小基本是都 >100M,很多 App 都上架了極速版)

58同城Android端-最小外掛化框架實戰和原理分析

58同城 App 對包大小這塊也非常關注,每次釋出版本之前都會對包大小進行分析與監控,下圖為 Android 32位包大小變化:

58同城Android端-最小外掛化框架實戰和原理分析

58同城Android端-最小外掛化框架實戰和原理分析

近期我們在對包大小進行新一輪的梳理,過程中發現:人臉認證庫內建了 4 套框架,當自研框架認證異常時將切換到其他框架,這種策略下內建 4 套框架對包大小造成了較大的負重。經過分析與調研,達成了共識方案:內建騰訊認證,把自研認證和阿里認證動態化,預計收益可達 4M,後期方案落地後逐步推進騰訊認證的動態化,預計可再減少 1。66 M。

包大小減少的常用手段非常多,主要分類還是技術手段和業務手段:

當前選擇動態化作為技術選項,是因為我們在技術手段上對包大小做的努力已基本見頂,同時從認證模組的背景考慮,低頻、低耦合正適合於外掛化場景。動態化又分成了正規軍 Android App Bundle 和國內的游擊隊外掛化,至於58同城在外掛化、AAB 上的探索和實現上的技術選型,後面的章節中會進行講解。最終效果:

外掛化便是本文章的重點,外掛化一般用來做兩件事:減少基礎包大小和動態更新。外掛化是移動端模組化、外掛化、元件化三劍客之一,歷史也非常久了,網上公開的免費外掛化文章很多都是純概念型或純方案型,本文章將會從 0-1 講解外掛化的知識,配合實戰經驗,讓你有所收穫。

工欲善其事,必先利其器,我們先來了解外掛化中涉及到的相關知識點。

02

外掛化需要了解的知識

在正式瞭解外掛化之前,需要了解外掛化會涉及到的相關概念,整個文章是一個循序漸進的過程。這樣在後面講到外掛化需要解決的問題、現有外掛化框架的對比、外掛化的實現時可以做到知其然而知其所以然。

2。1 類載入過程和類載入器

2。1。1 Java

首先,我們來了解下類載入的過程和類載入器,以 java 檔案為例,類載入幹過程如下:

58同城Android端-最小外掛化框架實戰和原理分析

載入:這部分涉及到類載入器,將 class 檔案載入到記憶體,建立對應的 Class 物件

連線:包括驗證、準備、解析三部分。驗證階段會檢驗被載入的類是否有正確的內部結構,並和其他類協調一致;準備階段則負責為類的靜態屬性分配記憶體,並設定預設初始值;解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。

初始化:JVM 負責對類進行初始化,主要是對靜態屬性/靜態塊進行初始化。

那麼一個類什麼時候會被觸發載入過程呢?除去系統類、擴充套件類外,我們程式的類什麼時候執行,主要包括以下幾種情況:

建立類的例項,如 new XXX();

呼叫某個類的靜態方法;

訪問某個類或介面的靜態屬性,或為該靜態屬性賦值;

透過反射方式來建立某個類或介面對應的 java。lang。Class 物件,如使用Class。forName(“XXX”)

初始化某個類的子類。初始化子類時,所有的父類都會被初始化。

那麼講完了 Java 類的載入過程,我們再來看下它的類載入器:

58同城Android端-最小外掛化框架實戰和原理分析

類載入器載入類遵循雙親委派模式,這是基於安全和效率方面的考慮,實現委派模式是透過 ClassLoader 構造器中的 parent 來做的,我們看一下 ClassLoader 抽象類:

public abstract class ClassLoader { protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 檢測是否已裝載過該 Class Class<?> c = findLoadedClass(name); // 未裝載過 if (c == null) { try { // 是否有父 ClassLoader,有的話使用父 ClassLoader 嘗試載入 if (parent != null) { c = parent。loadClass(name, false); } else { // 沒有父 ClassLoader,使用 BootstrapClassLoader 嘗試載入 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found } if (c == null) { // 上述都沒找到,使用自身 ClassLoader 載入 c = findClass(name); } } return c; }}

2。1。2 Android

Android 區別於 Java 的兩個核心點:

基於 Dex 檔案格式,而非 class 檔案格式,當然 Dex 裡包含 class

虛擬機器為 Davilk/ART,而非 JVM

其實 dex 和 Class 本質上都是一樣的,都是二進位制流檔案格式,dex 檔案是從 class 檔案演變而來的:class 檔案存在冗餘資訊,dex 檔案則去掉了冗餘,並且整合了整個工程的類資訊,在 Android 中做外掛化和熱修復都離不開 dex。

class VS dex:

記憶體佔用大,不適合移動端:dex 做了各種最佳化和去冗餘

堆疊的載入模式,載入速度慢

檔案 IO 操作多,類查詢慢:Android 虛擬機器直接 IO load dex,再進行類載入

Android 的類載入器包括:

ClassLoader 是一個抽象類,其中定義了 ClassLoader 的主要功能。

SecureClassLoader 拓展了 ClassLoader 類加入了許可權方面的功能,加強了 ClassLoader 的安全性。

URLClassLoader 它繼承自 SecureClassLoader,用來透過 URl 路徑從jar 檔案和資料夾中載入類和資源。

BootClassLoader 是 ClassLoader 的內部類,用於載入一些系統 Framework 層級需要的類。

BaseDexClassLoader 繼承自 ClassLoader,是抽象類 ClassLoader的具體實現類,PathClassLoader 和 DexClassLoader 都繼承它。

PathClassLoader 載入系統類和應用程式的類,如果是載入非系統應用程式類,則會載入 data/app/ 目錄下的 dex 檔案以及包含 dex 的 apk 檔案或 jar 檔案(已安裝)

DexClassLoader 可以載入自定義的 dex 檔案以及包含 dex 的 apk 檔案或jar檔案,也支援從 SD 卡進行載入

InMemoryDexClassLoader 是 Android8。0 新增的類載入器,繼承自BaseDexClassLoader,用於載入記憶體中的 dex 檔案。

這裡需要注意一點,我們的應用程式的預設 ClassLoader 為 PathClassLoader,而 PathClassLoader 的父 ClassLoader 為 BootClassLoader,這也是為什麼 bugly 上一些 ClassNotFound 堆疊頂部為 BootClassLoader:

ClassLoader。java private static ClassLoader createSystemClassLoader() { String classPath = System。getProperty(“java。class。path”, “。”); String librarySearchPath = System。getProperty(“java。library。path”, “”); return new PathClassLoader(classPath, librarySearchPath, BootClassLoader。getInstance()); }

在 Android ClassLoader 中,Dex 最終會被執行解析成 DexElements,我們檢視 Android 原始碼 BaseDexClassLoader,可以看到:

package dalvik。system;public class BaseDexClassLoader extends ClassLoader { private final DexPathList pathList; public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) { super(parent); this。pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory); } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { Class c = pathList。findClass(name, suppressedExceptions); if (c == null) { throw err; } return c; } @Override protected URL findResource(String name) { return pathList。findResource(name); } @Override protected Enumeration findResources(String name) { return pathList。findResources(name); } @Override public String findLibrary(String name) { return pathList。findLibrary(name); }}

透過 dexPath(dex路徑)、libraryPath(so路徑)、optimizedDirectory(oat最佳化儲存目錄) 構建了 DexPathList,而 classloader 的 findClass、findLibrary、findResources 都委託給了它去查詢,也是 Android ClassLoader 使用 Dex 載入的實現部分(區別於 Java ClassLoader),最終 loadClass 會呼叫到 DexFile 的:

private static native Class defineClassNative(String name, ClassLoader loader, int cookie)throws ClassNotFoundException, NoClassDefFoundError;

2。2 ClassLoader 的 findClass、findLibrary、findResource

上面我們瞭解了 ClassLoader 相關的知識,那麼在 Android 外掛化中,我們還需要了解相關的幾個常用方法。

2。2。1 findClass

根據類完整名稱去查詢 Class 物件,如 findClass(“com。xx。Test”),同時需要注意,在 ClassLoader 中關於 class 載入的有以下幾個方法:

在列印 App 預設的 PathClassLoader 物件時,可以看到當前的類查詢路徑,路徑一般以 。apk、。jar 結尾,ClassLoader 變化在這些路徑進行類查詢,在外掛化中,合併外掛的 dex 路徑也會出現在當中,如果是獨立的 ClassLoader,則裡面只有外掛本身的路徑:

> DexPathList[[zip file “/data/app/com。wuba-v5PkwKJhGUzCf2aDtJEQAQ==/base。apk”, zip file “/data/data/com。wuba/library/house/hsgmainplugin-all-0。1。0/hsgmainplugin-release。jar”]

2.2.2 findLibrary

查詢、載入 so 庫使用,我們在列印 App 預設的 PathClassLoader 物件時,可以看到當前可查詢的 so 的路徑,ClassLoader 便會從這些路徑進行 so 查詢,在外掛化中,合併外掛的 so 路徑也會出現在當中,如果是獨立的 ClassLoader,則裡面只有外掛 so 本身的路徑:

> nativeLibraryDirectories=[/data/app/com。wuba-v5PkwKJhGUzCf2aDtJEQAQ==/lib/arm, /data/app/com。wuba-v5PkwKJhGUzCf2aDtJEQAQ==/base。apk!/lib/armeabi-v7a, /system/lib]]

關於 ClassLoader 中 so 查詢有以下1個需要關注的方法:

String findLibrary(String libname)

findLibrary 是根據 so 名稱去查詢它所在的完整路徑,當代碼觸發 System。loadLibrary(libName) 時觸發,findLibrary 實現

public String findLibrary(String libraryName) { String fileName = System。mapLibraryName(libraryName); for (File directory : nativeLibraryDirectories) { String path = new File(directory, fileName)。getPath(); if (IoUtils。canOpenReadOnly(path)) { return path; } } return null; }

Framework 一般不會讓使用者直接透過 dlopen 去載入動態連結庫,而是封裝了以下兩種方式:

public final class System { // 方式一:透過 so 檔案路徑載入 public static void load(String filename) { Runtime。getRuntime()。load0(VMStack。getStackClass1(), filename); } // 方式二:透過 so 庫名載入 public static void loadLibrary(String libname) { Runtime。getRuntime()。loadLibrary0(VMStack。getCallingClassLoader(), libname); } }

System。loadLibrary() 最終透過 dlopen() 來實現:

Sysytem#loadLibrary ——> Sysytem#load ——> Runtime#nativeLoad Java + | Native dvmLoadNativeCode ——> dlopen -> 開啟一個 so 檔案,建立一個 handle

2。2。3 findResource

URL findResource(String name)

這個方法用於查詢資源,如 findResource(“file:D:\workspaces\”),在 Android 中基本無用,Android 有屬於自己的一套資源管理方案。

2。3 DexClassLoader 的 oat 配置

如果需要使用多 ClassLoader 時,需要自己構造 DexClassLoader:

class PluginDexClassLoader extends BaseDexClassLoader { private PluginDexClassLoader(List dexPaths, File optimizedDirectory, String librarySearchPath, ClassLoader parent) throws Throwable { super((dexPaths == null) ? “” : TextUtils。join(File。pathSeparator, dexPaths), optimizedDirectory, librarySearchPath, parent); }}

需要傳入 dex 路徑集合、so 庫目錄、dex 最佳化目錄、以及父 ClassLoader。 DexClassLoader 提供了 optimizedDirectory,而 PathClassLoader 則沒有(系統會自動生成以後快取目錄,即 /data/dalvik-cache,不同廠商不一樣),optimizedDirectory 是用來存放 odex 檔案的地方,所以可以利用 DexClassLoader實現動態載入。

這邊簡單介紹下幾個概念:

**dex:**java 程式編譯成 class 後,dx 工具將所有 class 檔案合成 dex 檔案。

odex (Android5。0 之前): Optimized DEX,即最佳化過的 dex。 Android5。0 之前 APP 在安裝時會進行驗證和最佳化,為了校驗程式碼合法性及最佳化程式碼執行速度,驗證和最佳化後,會產生 odex 檔案,執行 apk 的時候,直接載入 ODEX,避免重複驗證和最佳化,加快了 apk 的響應時間。

oat (Android5。0 之後): oat 是 ART 虛擬機器執行的檔案,是 ELF 格式二進位制檔案,包含 DEX 和編譯的本地機器指令,oat 檔案包含 dex 檔案,因此比 odex 檔案佔用空間更大。Android5。0 dex2oat 預設會把 classes。dex 翻譯成本地機器指令,生成 ELF 格

式的 oat 檔案。不過 android 5。0 之後 oat 檔案還是以 。odex 字尾結尾,但是已經不是 android5。0 之前的檔案格式,而是 ELF 格式封裝的本地機器碼。

vdex: Android8。0 以後加入,包含 apk 的未壓縮 dex 程式碼,另外還有一些旨在加快驗證速度的元資料。

2。4 LoadedApk

在 Android 中,我們透過 context。getClassLoader() 即可獲取到程式預設的類載入器,當然這個載入器在沒有任何處理的時候為 PathClassLoader,那麼如果我們想對其進行替換/擴充套件該如何處理呢?首先我們需要找到它具體的持有者 ContextImpl。java:

package android。app;class ContextImpl extends Context { final @NonNull LoadedApk mPackageInfo;}

再看看 LoadedApk, LoadedApk 物件是 apk 檔案在記憶體中的表示,在啟動我們的應用程序後,經過 system_server 的層層呼叫,最終會建立 LoadedApk,可以看到下面程式碼,它持有了很多重要的資訊,如主執行緒、包資訊、Resources、ClassLoader、Application 等:

package android。app;public final class LoadedApk { private final ActivityThread mActivityThread; final String mPackageName; private ApplicationInfo mApplicationInfo; private String mAppDir; private String mResDir; private String mDataDir; private String mLibDir; private File mDataDirFile; private final ClassLoader mBaseClassLoader; Resources mResources; private ClassLoader mClassLoader; private Application mApplication; private String[] mSplitNames; private String[] mSplitAppDirs; private String[] mSplitResDirs; private String[] mSplitClassLoaderNames;

在外掛化中,對 ClassLoader、Resources 的處理都可以透過它,它在一個程序內是全域性唯一的。VirtualApp 處理資源採用的就是替換 LoadedApk 的 Resource 物件。而替換預設的 ClassLoader 也可以透過反射替換掉 LoadedApk 中的 mClassLoader,這個 api 相對來說很穩定,各 Android 版本沒有做變更。

2。5 AssetManager、Resources

外掛化中,除了 class、libs 相關的載入,另一個重點就是資源,在 Android 中與資源載入相關的兩個類便是 AssetManager、Resources。 Resources 用來獲取 res 目錄下的各種與裝置相關的資源,而 AssetManager 則用來獲取 assets 目錄下的資源。

AssetManager 屬於 Resources 的一個屬性:

package android。content。res;public class Resources { private ResourcesImpl mResourcesImpl; public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) { this(null); mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments()); } public final AssetManager getAssets() { return mResourcesImpl。getAssets(); }}

可以看到構造 Resources 物件時,需要傳入 AssetManager 物件,我們再來看看 AssetManager:

package android。content。res;public final class AssetManager implements AutoCloseable { // AssetManager 構造器使用 @UnsupportedAppUsage 註解 @UnsupportedAppUsage public AssetManager() { } // AssetManager addAssetPath 使用 @UnsupportedAppUsage 註解 @UnsupportedAppUsage public int addAssetPath(String path) { return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/); } // 可獲取已安裝的資源路徑,使用 @UnsupportedAppUsage 註解 @UnsupportedAppUsage public @NonNull ApkAssets[] getApkAssets() { synchronized (this) { if (mOpen) { return mApkAssets; } } return sEmptyApkAssets; }}

AssetManager 不允許 App 程式碼直接對其進行構造,所以在外掛化過程中,如果要使用獨立資源模式構建外掛 AssetManager 需要用到反射,同時 AssetManager 新增資源查詢路徑的方法 addAssetPath 也不允許 App 程式碼直接訪問,外掛化中新增外掛資源路徑需要對其進行反射。AssetManager 的資源路徑一般包含以下幾類:

而透過 AssetManager 的 getApkAssets 的 getAssetPath 方法可以獲取到該 AssetManager 的資源路徑(陣列),不過這些方法對於 App 層都無法直接呼叫,需要使用反射。

Android 應用中,Application、Activity、Service 都可以獲取到 AssetManager 和 Resources 物件:

public class App extends Application { @Override public Resources getResources() { return super。getResources(); } @Override public AssetManager getAssets() { return super。getAssets(); }}public class TestActivity extends Activity { @Override public Resources getResources() { return super。getResources(); } @Override public AssetManager getAssets() { return super。getAssets(); }}public class TestService extends Service { @Override public Resources getResources() { return super。getResources(); } @Override public AssetManager getAssets() { return super。getAssets(); }}

其實它們都指向當前 Context 中的 LoadedApk 的 Resources,可以說是當前應用唯一的,在外掛化中,我們就可以針對上述這些資源相關的類和方法進行處理。

03

外掛化需要解決的核心問題

瞭解了外掛化需要掌握的知識點後,我們再來了解一下外掛化需要解決的核心問題。

可以看到上圖宿主與外掛的結構圖,外掛化需要解決的核心問題也是從這幾塊入手。

3。1 外掛化的安全性和穩定性

為什麼 Android 包括 iOS,以及當前流行的跨端框架 Flutter 都不允許對已安裝的應用做外掛動態更新,主要是基於對安全性和效能的顧慮。如繞過應用市場檢測,給使用者 App 進行木馬外掛等風險性的動態更新,同時對於執行效能會有影響,缺少各種對宿主包的最佳化。當然 Android & iOS 允許 JS 的動態更新,畢竟 JS 檔案可明文檢視,而 Google Play 近兩年也推出了官方的“外掛化”能力 - Android App Bundle。

拋開這些,我們看看國內的外掛化,在安全性和穩定性上存在問題的原因主要如下:

安全性:

外掛 Apk 的安全性問題,如被劫持篡改 所以外掛化框架一般都需要對外掛 Apk 做簽名和摘要驗證

穩定性:

主要涉及到私有 api 的反射,不同 Android 版本、 不同廠商對 class、so、resources 涉及到的隱私 api 都會存在差異,特別是需要將外掛合併到宿主執行環境的情況

同時一些外掛化框架為了繞過四大組

件未安裝的校驗,做了很多的 hook 和反射,穩定性和效能都需要做大量的適配工作

3。2 class 和 so 載入

對於 class 和 so 的載入,有兩種模式:合併式和獨立式。

合併式

優點:

宿主與外掛可直接互相訪問

缺點:

穩定差,需要做大量適配

宿主與外掛相同庫如果出現不相容,會出現對應 class 載入異常

獨立式

優點:

幾乎無反射,穩定性強,只需要 hook 一處用於擴充套件預設的 PathClassLoader

不用處理宿主和外掛相同庫版本不相容問題, 宿主和外掛 ClassLoader 分離

缺點:

相互訪問比較麻煩,主要在於宿主和外掛之間的訪問,不過都可以透過攔截各自

ClassLoader 的 findClass、findLibrary 來處理

由此可見,如果是獨立的模組,不使用宿主包的能力,其實用獨立外掛很合適,但如果涉及到大量宿主能力的呼叫(不推薦,這樣外掛 Apk 過於依賴宿主的相關庫的向下相容性),需要對 ClassLoader 做更多的處理。

3。2。1 合併式

顧名思義,就是將外掛 dex 路徑合併到宿主的 dex 路徑中,so 路徑合併宿主的 so 路徑中,到主要透過 classloader 實現。

dex 路徑合併:

(1) Android 6。0 及其以上

反射 classloader 的 pathList,擴充套件其 dexElements,擴充套件時反射 makeDexElements(List, File, List)

(2) Android 4。4。2 - 6。0

反射 classloader 的 pathList,擴充套件其 dexElements,擴充套件時反射 makeDexElements(ArrayList, File, ArrayList)

(3) Android 4。0 - 4。4。2

反射 classloader 的 pathList,擴充套件其 dexElements,擴充套件時反射 makeDexElements(ArrayList, File)

可以看到,classloader。pathList。dexElements 是穩定的私有 api,主要區別在於擴充套件 DexElements 用到的 makeDexElements 方法簽名不同,不過目前大多數 App 的最低執行版本已經升到了 Android 5。0

對於合併式,會出現類版本相容性問題:

可以看下圖,外掛和宿主都引用了同一個庫,但是當宿主升級此庫後,由於它內部未做向下相容,刪除了某些類或者修改了對外方法,如果此時外掛不進行同步更新打包,那麼執行將會出現問題。

又或者宿主和外掛打包分離,都引用了同一個庫,但這個庫版本不相容時,也會出現這個問題

如何解決?

方法1:參考 AAB 打包規則,外掛參與宿主打包過程,將相同依賴庫打包到宿主,同時可判斷外掛是否需要做對應的更新

方法2:分開打包,但需要制定相同依賴庫升級的規則,不過這種方式會使外掛包體積變大,存在冗餘

so 路徑合併:

(1) Android 7。1 及其以上

反射 classloader 的 pathList,擴充套件其 nativeLibraryDirectories,需要使用 pathList 的 systemNativeLibraryDirectories、makePathElements、nativeLibraryPathElements 等幾個函式去擴充套件

(2) Android 6。0 - 7。1

與 7。1 及其以上一致,區別在於 makePathElements 的方法簽名不同

(3) Android 4。0 - 6。0

反射 classloader 的 pathList,擴充套件其 nativeLibraryDirectories,直接構建新的 ArrayList 替換 nativeLibraryDirectories 即可

與 dex 路徑合併一致,在不同版本之前存在一些差別。

3。2。2 獨立式

獨立式就是外掛中的 class 和 so 使用獨立的 ClassLoader 載入:

public class PluginDexClassLoader extends BaseDexClassLoader { public PluginDexClassLoader( List dexPaths, File optimizedDirectory, String librarySearchPath, ClassLoader parent) { super((dexPaths == null) ? “” : TextUtils。join(File。pathSeparator, dexPaths), optimizedDirectory, librarySearchPath, parent); } }

可以看到,獨立 ClassLoader 無反射,一般,我們會擴充套件程式預設的 ClassLoader(一處反射:替換掉 loadedApk 中的 classloader 物件),在預設 ClassLoader findClass、findLibrary 異常時,再使用獨立的 ClassLoader 去載入。

3。3 資源載入和資源 id 衝突

上面講完了外掛 Apk 中 class 和 so 的載入,我們再來看下資源如何載入處理。同樣,資源載入也分為合併式和獨立式:

合併式

優點:

宿主與外掛可直接互相訪問資源

缺點:

穩定差,需要做大量適配

需要解決資源 id 衝突問題

需要解決引用的相同第三庫資源變更問題

獨立式

優點:

反射少,僅需反射 AssetManager 建立、新增路徑的方法

無需關心資源 id 衝突問題

無需關心第三方庫資源變更問題

缺點:

資源相互訪問比較麻煩

獨立式意味著如果需要引用第三方庫的資源,要將第三方庫單獨打包到外掛中,而宿主如果也引用了此第三方庫,勢必會造成外掛包體積增大,存在冗餘

由此可見,如果是獨立的模組,不使用宿主包的資源,其實用獨立式很合適,但如果需要訪問宿主資源,則需要考慮合併式,否則要做大量的處理,畢竟你無法輕鬆地控制那些第三方庫。

3。3。1 合併式

合併式是將外掛的路徑合併到預設的 Resouces 中:

Android 5。0 及其以上:

獲取到當前的 Resources 物件

獲取該 Resources 的 AssetManager,反射呼叫其 addAssetPath() 新增外掛路徑

Android 5。0 以下:

獲取到舊的 Resources 物件和當前的 Context 物件

構建新的 Resource 物件,將舊的 Resources 已有的資源路徑、外掛路徑都合併進去

替換掉舊的 Resources (這一步有大量的適配),主要是判斷當前的 Context 型別:

(1) Context 為 ContextThemeWrapper,做對應替換處理

(2) Context 的 baseContext 為 android。app。ContextImpl 型別,做對應替換處理

(3) 個別 rom 的定製,處理 Context 的 baseContext 的 mResources 和 mTheme

除此之外,合併式還需要解決外掛資源 id 和宿主資源 id 衝突問題,主要是 Apk 中的 resources。arsc 索引衝突,這個非常容易出現:

58同城Android端-最小外掛化框架實戰和原理分析

合併式資源 id 衝突問題:

我們知道可以透過 R。id。xxx/R。string 來非常方便的訪問應用程式的資源,在編譯的時候,Android 編譯工具 aapt 會掃描你所定義的所有資源,然後給它們指定不同的資源 ID。

資源 ID 是一個16進位制的數字,格式是 PPTTNNNN,如 0x7f010001:

PP 代表資源所屬的包 (packageID),對於應用程式的資源來說,PP 的取值是 0×77

TT 代表資源的型別(typeID)

NNNN 代表這個型別下面的資源的名稱

一旦資源被編譯成二進位制檔案的時候, aapt 會生成 R。java 檔案和 resources。arsc 檔案,R。java 用於程式碼的編譯,而 resources。arsc 則包含了全部的資源名稱、資源 ID 和資源的內容 (對於單獨檔案型別的資源,這個內容代表的是這個檔案在其 。apk 檔案中的路徑資訊),這樣就把執行環境中的資源 id 和具體的資源對應起來了。

外掛 Apk 和宿主 Apk 的資源 id 很容易發生重複,造成資源合併衝突,那麼針對於這個問題,目前的外掛化框架有以下幾種解決方案:

3。3。2 獨立式

上述講了合併式資源的方案和問題,而資源處理還有另一種方案,便是獨立式,獨立式資源不會有資源 id 衝突的問題,但是宿主和外掛之間的資源訪問比較麻煩,適用於業務比較獨立的外掛,外掛只使用外掛自身的資源,方法很簡單:

// 構建新的 AssetManagerAssetManager assetManager = AssetManager。class。newInstance();// 新增外掛資源路徑Method addAssetPathMethod = HiddenApiReflection。findMethod(AssetManager。class, “addAssetPath”, String。class);addAssetPathMethod。invoke(assetManager, pluginApk。getAbsolutePath());// 構建新的 ResoucesResources newResources = new Resources(assetManager, preResources。getDisplayMetrics(), preResources。getConfiguration());

讓外掛使用這個新的 Resources 有兩種方式:

3。4 四大元件

Android 的四大元件:Activity、Receiver、Service、ContentProvider 需要在 AndroidManifest。xml 中進行註冊。Android 的四大元件其實有挺多的共通之處,比如它們都接受 ActivityManagerService(AMS) 的管理,它們的請求流程也是基本相通的。目前網上有很多關於四大元件啟動流程和原理的分析,篇幅很長,我們這邊就直接講述外掛化如何支援四大元件,透過系統服務的註冊校驗。

系統安裝好宿主 Apk 後,會解析 Apk 中的 AndroidManifest。xml,生成組大元件的資訊,如果外掛的四大元件未在宿主 AndroidManifest。xml 中註冊,會出現啟動元件崩潰問題。

方案總體可以分成以下3類:

3。4。1 動態替換方案

以 Activity 為例,主要是對 Android 底層程式碼進行 Hook,使在 App 啟動 Activity 中進行欺騙 ActivityManagerService,以達到載入外掛中元件的目的。

58同城Android端-最小外掛化框架實戰和原理分析

3.4.2 靜態代理方案

靜態代理方案相對來說,會比較好理解一點。因為它不需要去 Hook 任何程式碼,主要在宿主中建立一個代理的 Activity,叫 ProxyActivity, ProxyActivity 內部有一個對外掛 Activity 的引用,讓 ProxyActivity的任何生命週期函式都呼叫外掛中的 Activity 中同名的函式。這是 dynamic-load-apk 外掛化框架所創。

以上方案適用於 Activity、Receiver、Service、ContentProvider,但 ContentProvider 需要注意一點:

3。4。3 提前預埋外掛所用的四大元件

還有最後一種方案,是我認為最簡單的,即提前預埋好四大元件,如預埋多個 Activity(區分不同啟動模式)、Service 等,外掛對這些預埋的元件進行實現。其實外掛所用的元件一般比較固定,我們要做的是做好不同外掛使用的元件管理。這種方案可以避免掉上述四大元件的各種問題,無需多餘的 hook、反射處理。如 AAB 機制就是會提前講宿主和 dynamic feature 的 AndroidManifest 檔案進行合併,放置在宿主包中,不允許外掛對這些四大元件配置進行動態更新。

這種方案需要注意一點就是 ContentProvider:

應用程式在建立 Application 的過程中,會執行 handleBindApplication(), 將 AndroidManifest 中 ContentProvider 進行安裝,所以 ContentProvider 的初始化時機是非常早的。這時如果外掛 Apk 沒有安裝,則會導致這些 ContentProvider 找不到實現類,出現崩潰。我們可以用一個空的 ContentProvider 騙過 App 啟動校驗,外掛安裝完成後再對真實的 ContentProvider 進行初始化

3。5 現有外掛化框架技術方案對比

講完了外掛化需要解決的幾個核心問題,那麼我們最後來看下目前市面上的外掛化框架對這些問題分別是如何選型處理的:

總結:

這些外掛化框架都很優秀,是行業的先驅,功能也很完備,但是實際落地過程中有一些問題,如:

不再維護,部分框架還停留在 15年,gradle 外掛、打包適配等比較陳舊

功能龐雜,包含多種載入模式,以及大量衍生功能地適配處理,導致穩定性、接入成本劇增,後期維護成本高

打包功能侵入宿主 App,適配成本高

只適合於特定的業務場景,如 AAB,需要每次基於基礎包重新構建釋出

使用了一些隱私 API,有政府整改風險

04

58App最小外掛化實現

在背景所有說的業務中,我們使用外掛化作為技術方案的原因是為了減少包大小。不需要完整外掛化框架這麼多功能,如新增元件能力、多種載入模式的切換,以及一些其他的邊緣能力,我們只需要最核心的外掛安裝、載入能力。其實 Shadow、dynamic-load-apk 就是如此,適用於獨立的業務模組,反射少,獨立ClassLoader 和 Resources,四大元件使用靜態代理模式進行生命週期分發,但即使這些框架,仍然具有程式碼複雜、接入成本高的問題,或者專案太老,很多環境和程式碼未做適配。如果有一個具備完全可執行的、接入成本低的、穩定高的外掛化框架,其實更利於落地推廣。

58同城 Android 端之前已使用基於 AAB 的動態化框架進行了落地:

廠商包:包大小控制在 50M 以內

市場包:基於版本級別的線上 AB 測

剪包:招聘和房產剪包,用於外鏈投放

58同城Android端-最小外掛化框架實戰和原理分析

此框架之前在 58App 上線動態更新十餘次,單次更新使用者最高 800w,那為什麼信安人臉認證動態化不繼續使用此框架呢?主要有以下兩個原因:

58同城Android端-最小外掛化框架實戰和原理分析

以上就是關於信安人臉動態化的技術選型,對於外掛化的幾個痛點,解決方案如下:

58同城Android端-最小外掛化框架實戰和原理分析

接下來,我們來看下 58App 最小外掛化框架的設計和實現。

4。1 框架設計

可以看到結構非常簡單:

編譯期:

外掛打包上傳能力

外掛資源處理能力

執行期:

外掛管理:包含外掛的版本、路徑、apk、libs 的管理

外掛安裝:外掛下載、校驗,外掛的 apk 複製、libs 抽取儲存,安裝標記等

外掛載入:dex & so 載入,資源載入

4。2 外掛打包

主要處理外掛資源和外掛打包上傳,無需侵入宿主打包流程,執行:

。。/gradle uploadPluginRelease

成功後,在 build/outputs/apk/debug(release) 下會生成:

——build/outputs/apk/debug(release)——- plugin-upload-infos。json (上傳的外掛版本、md5、url)——- plugin_manifest。xml (清單檔案,需要將內容複製合併到宿主/接入 SDK 的清單檔案)——- **arm64-v8a。apk——- **armeabi-v7a。apk——- **universal。apk

plugin-upload-infos。json 內容如下,eg:

{ “version”: “1。0”, “infos”: [ { “abi”: “armeabi-v7a”, “url”: “https://wos2。58cdn。com。cn/FgHcBazYFgLi/cutpackage/PluginApp-armeabi-v7a-debug-1653323406181。apk”, “md5”: “0804443b61a079262ff760f33f76c077” }, { “abi”: “arm64-v8a”, “url”: “https://wos2。58cdn。com。cn/FgHcBazYFgLi/cutpackage/PluginApp-arm64-v8a-debug-1653323409379。apk”, “md5”: “34767a82a47a240c1bbd363ea6e615ea” } ]}

資源處理這塊,對外掛 Activity、Service 資源獲取方法做編譯織入 PluginResourcesManager。getResources(“pluginName”):

58同城Android端-最小外掛化框架實戰和原理分析

PluginResourcesManager 如下:

public final class PluginResourcesManager { private static final Map resourcesMap = new HashMap<>(); private static final Map assetManagerMap = new HashMap<>(); public static Resources getResources(String pluginName) { if (resourcesMap。containsKey(pluginName)) { return resourcesMap。get(pluginName); } try { AssetManager assetManager = AssetManager。class。newInstance(); Method addAssetPathMethod = HiddenApiReflection。findMethod(AssetManager。class, “addAssetPath”, String。class); ApkPlugin apkPlugin = new ApkPlugin(pluginName, “”, “”); File pluginApk = PluginPathManager。getInstance()。getPluginApk(apkPlugin); addAssetPathMethod。invoke(assetManager, pluginApk。getAbsolutePath()); Resources preResources = WBPluginLoader。getContext()。getResources(); Resources newResources = new Resources(assetManager, preResources。getDisplayMetrics(), preResources。getConfiguration()); resourcesMap。put(pluginName, newResources); assetManagerMap。put(pluginName, assetManager); return newResources; } catch (Throwable e) { return WBPluginLoader。getContext()。getResources(); } } public static AssetManager getAssetManager(String pluginName) { if (assetManagerMap。containsKey(pluginName)) { return assetManagerMap。get(pluginName); } Resources resources = getResources(pluginName); return resources。getAssets(); }}

4。3 外掛管理

data/data/${packageName}- app_wbplugins—— pluginName1 - code_cache (程式碼快取) - nativeLib (so 目錄) - arm64-v8a/armeabi-v7a - oat (oat 最佳化目錄) - base。apk (外掛 apk) - mark。json (安裝標記,包含版本資訊)—— pluginName2 。。。

4。4 外掛安裝

外掛安裝這一塊,流程如下:

安裝標記如下,eg:

{“name”:“TestPlugin”,“version”:“1。0”,“abi”:“armeabi-v7a”}

4.5 外掛載入

載入 dex & so

外掛 ClassLoader:

final class PluginDexClassLoader extends BaseDexClassLoader { private PluginDexClassLoader(List dexPaths, File optimizedDirectory, String librarySearchPath, ClassLoader parent) throws Throwable { super((dexPaths == null) ? “” : TextUtils。join(File。pathSeparator, dexPaths), optimizedDirectory, librarySearchPath, parent); UnKnownFileTypeDexLoader。loadDex(this, dexPaths, optimizedDirectory); } static PluginDexClassLoader create(List dexPaths, File optimizedDirectory, File librarySearchFile) throws Throwable { PluginDexClassLoader cl = new PluginDexClassLoader( dexPaths, optimizedDirectory, librarySearchFile == null ? null : librarySearchFile。getAbsolutePath(), PluginDexClassLoader。class。getClassLoader() ); return cl; } }

重寫 App 預設的 PathClassLoader 的雙親委派模式:

第一步,獲取 App 執行的預設 ClassLoader

擴充套件預設 ClassLoader 的 class、library 載入,優先使用原始預設的 ClassLoader 載入,載入失敗則使用外掛 ClassLoader 載入

設定此新的 ClassLoader 為 App 執行的預設 ClassLoader (替換 context。mPackageInfo 的 ClassLoader,時機需要在 Application 的 attachBaseContext())

public final class PluginDelegateClassloader extends PathClassLoader { private static BaseDexClassLoader originClassLoader; PluginDelegateClassloader(ClassLoader parent) { super(“”, parent); originClassLoader = (BaseDexClassLoader) parent; } private static void reflectPackageInfoClassloader(Context baseContext, ClassLoader reflectClassLoader) throws Exception { Object packageInfo = HiddenApiReflection。findField(baseContext, “mPackageInfo”)。get(baseContext); if (packageInfo != null) { HiddenApiReflection。findField(packageInfo, “mClassLoader”)。set(packageInfo, reflectClassLoader); } } public static void inject(ClassLoader originalClassloader, Context baseContext) throws Exception { Context ctx = baseContext; while (ctx instanceof ContextWrapper) { ctx = ((ContextWrapper) ctx)。getBaseContext(); } PluginDelegateClassloader classloader = new PluginDelegateClassloader(originalClassloader); reflectPackageInfoClassloader(ctx, classloader); } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { try { return originClassLoader。loadClass(name); } catch (ClassNotFoundException error) { Set splitDexClassLoaders = PluginClassLoaders。getInstance()。getClassLoaders(); for (PluginDexClassLoader loader : splitDexClassLoaders) { Class<?> clazz = loader。loadClassItself(name); if (clazz != null) { return clazz; } } throw error; } } @Override public Class<?> loadClass(String name) throws ClassNotFoundException { return findClass(name); } @Override public String findLibrary(String name) { String libName = originClassLoader。findLibrary(name); if (libName == null) { Set splitDexClassLoaders = PluginClassLoaders。getInstance()。getClassLoaders(); for (PluginDexClassLoader classLoader : splitDexClassLoaders) { libName = classLoader。findLibraryItself(name); if (libName != null) { break; } } } return libName; }}

資源載入

資源載入請見 外掛打包 的 PluginResourcesManager

4。6 遇到的問題

1。 啟動阿里認證報 Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 28105 (SGBackgroud)

這個問題前後排查 1周多,阿里認證使用了安全套件,其本身 so 為 apk 外掛格式:

反編譯相關程式碼其使用的也是獨立 ClassLoader 載入,最後經過二分、檢視 apk 等方式,查詢到原因,編譯出的 demo Apk 的 META-INFO 中沒有簽名信息,阿里安全套件對簽名檔案有檢測。最後透過在 demo app 中顯示指定簽名檔案解決。

2。 獨立資源 appcompat 庫問題

androidx。appcompat:appcompat

阿里認證庫已適配 AndroidX,動態包由於使用的是獨立的 ClassLoader 和 Resources,如果外掛包依賴 appcompat 會出現以下問題:

58同城Android端-最小外掛化框架實戰和原理分析

最終,選取了外掛包不依賴 appcompat,統一交由宿主進行依賴,目前大多數 App 均已適配 AndroidX,這種方案無需維護外掛 appcompat 和宿主 appcompat 的版本,同時也能對外掛本身進行瘦身。

05

總結

外掛化的原理其實不難,核心點就幾個。各種外掛化框架對於這些核心痛點也已經有了成熟的解決方案,目前外掛化能在 58App 落地也是站在先驅的肩膀上,找到了最合適的方案進行微創新與落地。

作者:

況眾文

58技術

出處:https://mp。weixin。qq。com/s/zKpjaHPMjKYjqBd4co4Itg