一、程式碼修復
1、類載入方案
(1)Dex分包原理
單個Dex檔案裡面方法數不能超過65536個方法。
(1)原因:
因為android會把每一個類的方法id檢索起來,存在一個連結串列結構裡面。但是這個連結串列的長度是用一個short型別來儲存的, short佔兩個位元組(儲存-2的15次方到2的15次方-1,即-32768~32767),最大儲存的數量就是65536。
(2)解決方案:
- 精簡方法數量,刪除沒用到的類、方法、第三方庫。
- 使用ProGuard去掉一些未使用的程式碼
- 對部分模組採用本地外掛化的方式。
- 分割Dex
Dex分包方案主要做的是在打包時將應用程式碼分成多個Dex,將應用啟動時必須用到的類和這些類的直接引用類放到主Dex中,其他程式碼放到次Dex中。當應用啟動時先載入主Dex,等到應用啟動後再動態的載入次Dex。
(2)類載入修復方案
如果Key.Class檔案中存在異常,將該Class檔案修復後,將其打入Patch.dex的補丁包
(1) 方案一:
通過反射獲取到PathClassLoader中的DexPathList,然後再拿到 DexPathList中的Element陣列,將Patch.dex放在Element陣列dexElements的第一個元素,最後將陣列進行合併後並重新設定回去。在進行類載入的時候,由於ClassLoader的雙親委託機制,該類只被載入一次,也就是說Patch.dex中的Key.Class會被載入。
提供dex差量包patch.dex,將patch.dex與應用的classes.dex合併成一個完整的dex,完整dex載入後得到dexFile物件,作為引數構建一個Element物件,然後整體替換掉舊的dex-Elements陣列。(Tinker)
(3)類載入方案的限制
方案一:
- 由於類是無法進行解除安裝,所以類如果需要重新載入,則需要重啟App,所以類載入修復方案不是即時生效的。
- 在ART模式下,如果類修改了結構,就會出現記憶體錯亂的問題。為了解決這個問題,就必須把所有相關的呼叫類、父類子類等等全部載入到patch.dex中,導致補丁包大,耗時嚴重。
方案二:
- 下次啟動修復
- dex合併記憶體消耗可能導致OOM,最終dex合併失敗
2、底層替換方案
(1)基本方案
主要是在Native層替換原有方法,ArtMethod結構體中包含了Java方法的所有資訊,包括執行入口、訪問許可權、所屬類和程式碼執行地址等。替換ArtMethod結構體中的欄位或者替換整個ArtMethod結構體,就是底層替換方案。由於直接替換了方法,可以立即生效不需要重啟。
(2)優缺點
(1)缺點
- 不能夠增減原有類的方法和欄位,如果我們增加了方法數,那麼方法索引數也會增加,這樣訪問方法時會無法通過索引找到正確的方法。
- 平臺相容性問題,如果廠商對ArtMethod結構體進行了修改,替換機制就有問題。
(2)優點
- Bug修復的即時性
- 生成的PATCH體積小,效能影響低
二、資源修復
1、Instant Run
核心程式碼:runtime/MonkeyPatcher.java
#MonkeyPatcher
public static void monkeyPatchExistingResources(@Nullable Context context,
@Nullable String externalResourceFile,
@Nullable Collection<Activity> activities) {
......
try {
// Create a new AssetManager instance and point it to the resources installed under
// (1)通過反射建立了一個newAssetManager,呼叫addAssetPath新增了sdcard上的資源包
AssetManager newAssetManager = AssetManager.class.getConstructor().newInstance();
Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
mAddAssetPath.setAccessible(true);
if (((Integer) mAddAssetPath.invoke(newAssetManager, externalResourceFile)) == 0) {
throw new IllegalStateException("Could not create new AssetManager");
}
// Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
// in L, so we do it unconditionally.
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod("ensureStringBlocks");
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager);
if (activities != null) {
//(2)反射獲取Activity中AssetManager的引用,替換成新建立的newAssetManager
for (Activity activity : activities) {
Resources resources = activity.getResources();
try {
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
mResourcesImpl.setAccessible(true);
Object resourceImpl = mResourcesImpl.get(resources);
Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
implAssets.setAccessible(true);
implAssets.set(resourceImpl, newAssetManager);
}
Resources.Theme theme = activity.getTheme();
try {
try {
Field ma = Resources.Theme.class.getDeclaredField("mAssets");
ma.setAccessible(true);
ma.set(theme, newAssetManager);
} catch (NoSuchFieldException ignore) {
Field themeField = Resources.Theme.class.getDeclaredField("mThemeImpl");
themeField.setAccessible(true);
Object impl = themeField.get(theme);
Field ma = impl.getClass().getDeclaredField("mAssets");
ma.setAccessible(true);
ma.set(impl, newAssetManager);
}
......
}
//(3)遍歷Resource弱引用的集合,將AssetManager替換成newAssetManager
for (WeakReference<Resources> wr : references) {
Resources resources = wr.get();
if (resources != null) {
// Set the AssetManager of the Resources instance to our brand new one
try {
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
mResourcesImpl.setAccessible(true);
Object resourceImpl = mResourcesImpl.get(resources);
Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
implAssets.setAccessible(true);
implAssets.set(resourceImpl, newAssetManager);
}
resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}
}
複製程式碼
- 反射構建新的AssetManager,並反射呼叫addAssertPath載入sdcard中的新資源包,這樣就得到一個含有所有新資源的AssetManager
- 將原來引用到AssetManager的地方,通過反射把引用處替換為新的AssetManager
2、資源包替換(Sophix)
預設由Android SDK編譯出來的apk,其資源包的package id為0x7f。framework-res.jar的資源package id為0x01
- 構造一個package id為0x66的資源包(非0x7f和0x01),只包含已經改變的資源項。
- 由於不與已經載入的Ox7f衝突,所以可以通過原有的AssetManager的addAssetPath載入這個包。
三、SO庫修復
本質是對native方法的修復和替換
1、so庫載入
(1)通過以下方法載入so庫
#System
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);
}
引數為so庫名稱,位於apk的lib目錄下
public static void load(String filename) {
Runtime.getRuntime().load0(VMStack.getStackClass1(), filename);
}
載入外部自定義so庫檔案,引數為so庫在磁碟中的完整路徑
複製程式碼
private static native String nativeLoad(String filename, ClassLoader loader, String librarySearchPath);
複製程式碼
最終都是呼叫了native方法nativeLoad,引數fileName為so在磁碟中的完整路徑名
(2)遍歷nativeLibraryDirectories目錄
#DexPathList
public String findLibrary(String libraryName) {
String fileName = System.mapLibraryName(libraryName);
for (File directory : nativeLibraryDirectories) {
File file = new File(directory, fileName);
if (file.exists() && file.isFile() && file.canRead()) {
return file.getPath();
}
}
return null;
}
複製程式碼
類似於類載入的findClass方法,在陣列中每一個元素對應一個so庫,最終返回了so的路徑。如果將so補丁新增到陣列的最前面,在呼叫方法載入so庫時,會先將補丁so的路徑返回。
2、SO修復方案
(1)介面替換
提供方法替代System.loadLibrary方法
- 如果存在補丁so,則載入補丁so庫,不去載入apk安裝目錄下的so庫
- 如果不存在補丁so,呼叫System.loadLibrary去載入安裝apk目錄下的so庫
(2)反射注入
因為載入so庫會遍歷nativeLibraryDirectories
- 通過反射將補丁so庫的路徑插入到nativeLibraryDirectories陣列的最前面
- 遍歷nativeLibraryDirectories時,就會將補丁so庫進行返回並載入,從而達到修復目的
四、熱修復框架分析
- 底層替換方案:阿里的AndFix、HotFix
- 類載入方案:QQ空間補丁技術、微信的Tinker方案、餓了麼的Amigo
- 二者結合:Sophix
參考資料:
- 主流熱修復方案分析
- Android熱修復技術,你會怎麼選?
- 《Android進階解密》
- 《深入探索Android熱修復技術原理》