Android熱修復(二):以DexClassLoader類載入原理編寫demo實現類替換修復
上一篇文章簡易總結了熱修復實現的幾大原理,並詳細介紹了Android中的類載入機制及原始碼探索,Android的類載入機制涉及到ClassLoader、DexClassLoader 、PathClassLoader 、BaseDexClassLoader 、DexPathList、DexFile多個類之間方法互相呼叫,但是真正的核心實現類其實是DexPathList,它具體實現了findClass
方法,即所謂的“類載入”概念。
其實此方法中的實現邏輯給開發者暗示了熱修復的實現思路,此篇文章將一探究竟,剖析出如何根據DexClassLoader類載入機制來實現類替換修復,通過分析思路具體編碼實踐整個過程。
此篇文章將學習:
- DexClssLoader類載入核心
- 通過修改dexElement陣列實現類替換修復
- 整體編碼過程思路及原始碼展示( dx命令生成dex檔案)
一. 如何通過DexClssLoader類載入實現熱修復
1. 找到突破口——DexPathList
在經過上一篇對DexClassLoader相關原始碼分析後,可以發現類載入的主要邏輯處理都在DexPathList類中進行,類中有一個重要的成員變數——Element型別陣列,和兩個重要的方法——建構函式和findClass
:
- 主要在建構函式中處理對Element型別陣列賦值(遍歷指定路徑中的所有檔案,將dex檔案相關資訊賦值到陣列中);
- 在
findClass
方法中遍歷Element陣列,找到對應類名的dex檔案(Element型別的dexFile方法可轉化為DexFile型別),呼叫本身native方法獲取位元組碼檔案返回。
以上我又再次強調歸納一遍DexPathList類的邏輯處理,這幾乎代表著Android類載入機制的重點核心了,也與後續“熱修復處理”有關。
在多次總結中可見DexPathList類的成員變數Element型別陣列很關鍵,而且又是通過Element陣列中的成員載入得到class位元組碼檔案,因此可以說所有載入的類都在Element陣列中!
見上圖,此時客戶端中的Test類(Test.class)有bug,而伺服器已有修復好的Test.class,如若能夠將已修復的Test.class代替客戶端上的,是否就達到成功修復bug的功能?
如上圖,再仔細檢視DexPathList類中對Element陣列的註釋:dex或者資源(類路徑)的集合,更應該命名為pathElement,但是Facebook app使用反射來修改 ‘dexElement’。以上谷歌註釋相當於預設開發者可以這麼玩:修改dexElement。
2. 思路
在獲取谷歌助攻之後,實現熱修復功能萌生的第一個想法是:反射獲取到dexElement,直接修改其中有bug的類替換成已修復的類。
不過此方法進一步具體實現有些不太理想,因為dexElement是一個數組,需要一個一個遍歷找到對應的再做替換,過程實現是比較麻煩的。再仔細觀察DexPathList類的findClass
方法,發現內部迴圈的一個重點邏輯:在遍歷dexElement陣列時,若載入到的指定類class不為空時,直接return結束遍歷,將class位元組碼返回!
綜上所述,第二個想法由此萌生:根據findClass
方法裡的內部邏輯,遍歷查詢到指定類載入,class不為空就直接返回,那我們可以直接將已修復的類放到有bug類的前面,一旦載入到已修復好的類,那後面的bug類就不會有任何影響。
第二個想法的思路很簡單,相當於做了一個攔截。此時你可能還在糾結第一個想法,在dexElement陣列中刪去有bug的去替換成已修復的,這操作過程並沒有表面那麼容易,與第二個想法比起來,為何不採用更簡單的呢?
3.具體實現方法
實現熱修復功能思路:直接將已修復的dex放到dexElement陣列中有bug類的dex前面!
再結合Android系統思考詳細操作:Android類載入載入的是dex檔案,也就是解壓APK後的classes.dex,而一個dex檔案是包含了整個工程的class位元組碼檔案。在發現原工程中有某個類或涉及到的幾個類有錯誤,將這些已修復好的類打包到修復好的補丁包classes2.dex中,可以通過推送或其他方式由伺服器傳送到客戶端中,那麼接下來的任務就是將這兩個dex合併,其注意目的也就是將修復的class放置到原來出bug的class(其實這裡只需要將其放置到陣列中第一個位置,就可以一勞永逸!)
如上圖,這是github上Tinker技術的介紹圖,其原理與上述實現思路是不是非常類似?實則兩者實現原理相同,皆從DexClssLoader類載入原理入手,但是此篇文章並不詳細介紹Tinker使用及原理(下一篇介紹),而是瞭解以上原理自行編寫demo實現類替換修復。
二. 編碼過程
(以下過程採用Genymotion模擬器操作,AS的java目錄如下)
1. 測試場景搭建
(1)測試UI介面
如下圖,測試介面十分簡單,一個activity中2個button,點選後的處理邏輯如下:
- TEST按鈕:點選後會進行
10/0
這樣一個錯誤運算,點選之後程式必然出錯閃退,以此來模擬應用閃退的情況(實際編碼中不會出現此疏漏,在此僅為模擬)。 - FIX按鈕: 將已修復好的dex檔案(設定其名稱為classes2.dex)移置到應用程式目錄中,再開始進行熱修復類替換。
注意:此過程中的伺服器傳送給客戶端已修復的dex檔案(實際可通過推送或其他方式),此步驟筆者並未詳細實現,而自行簡化直接將已修復好的dex檔案拖入Genymotion模擬器中,重在突出熱修復邏輯處理過程。
(2)TEST按鈕方法實現
如上所述,點選TEST按鈕後將進行10/0
這個錯誤運算,即呼叫DexFixTest類的testFix
方法,程式肯定會出錯閃退,後續將修復此bug類,並生成已修復的dex用於熱修復。
DexFixTest類僅用於測試,程式碼如下:
public class DexFixTest {
public void testFix(Context context){
int dividend = 10;
//bug:除數不可為0
int divisor = 0;
Toast.makeText(context, "shit:"+dividend/divisor, Toast.LENGTH_SHORT).show();
}
}
(3)FIX按鈕方法實現
此按鈕點選會呼叫fixBug()
方法:
- 方法作用: 就是將已修復好的dex檔案(設定其名稱為classes2.dex)放置到應用程式目錄中,做好準備工作後呼叫 DexFixUtils 進行熱修復操作。
- 方法邏輯:是對dex檔案進行讀寫操作,主要易混點是dex檔案下載到的路徑和規定應用程式下的目錄路徑。
注意:此處可能有人會疑惑為何需要移置dex檔案位置?因為此熱修復方案採用的原理是DexClssLoader類載入,DexClassLoader有一個限制就是載入指定的dex檔案必須要在應用程式的目錄下面!因此先要移動dex檔案到指定位置,再實行熱修復。
獲取規定應用程式下的目錄路徑
Context.java
public File getDir(String name, int mode)
此API返回/data/data/youPackageName/app_name(”app_” 是返回時系統自己加上的)下的指定名稱的資料夾File物件,如果該資料夾不存在則用指定名稱建立一個新的資料夾。用此API在內部儲存中的對應app下建立name資料夾,用於存放移置後的已修復dex檔案,後續DexClassLoader會載入此處dex來進行熱修復。
獲取dex檔案下載到的路徑
Environment.getExternalStorageDirectory().getAbsolutePath()
此API返回sdcard路徑。在測試過程中是直接將已修復的dex檔案拖入模擬器/真機中,通過此API獲取到下載好的dex檔案,準備移置。
fixBug()
方法完整程式碼如下:
/*
* 把下載完成的已修復檔案classes2.dex(SD卡)移置到 應用目錄filePath
* */
private void fixBug() {
String dexName = "classes2.dex";
File fileDir = getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
//⭐️⭐️⭐️⭐️⭐️DexClassLoader指定的應用程式目錄
String filePath = fileDir.getPath()+File.separator+dexName;
File dexFile = new File(filePath);
if(dexFile.exists()){
dexFile.delete();
}
//移置dex檔案位置(從sd卡中移置到應用目錄)
InputStream is = null;
FileOutputStream os = null;
// ⭐️⭐️⭐️⭐️⭐️sd卡路徑應為"/storage/emulated/0",此處直接將檔案拖入Genymotion,因此路徑還需加上"/Download"
String sdPath = Environment.getExternalStorageDirectory().getAbsolutePath()+"/Download";
try {
is = new FileInputStream(sdPath+File.separator+dexName);
os = new FileOutputStream(filePath);
int len = 0;
byte[] buffer = new byte[1024];
while((len = is.read(buffer)) != -1){
os.write(buffer, 0, len);
}
//測試是否成功寫入
File fileTest = new File(filePath);
if(fileTest.exists()){
Toast.makeText(this, "dex重寫成功", Toast.LENGTH_SHORT).show();
}
//獲取到已修復的dex檔案,進行熱修復操作
DexFixUtils.loadFixedDex(this);
}catch (Exception e){
e.printStackTrace();
}
}
最後還是要強調一下sd卡路徑和應用程式目錄路徑,筆者在編寫程式碼時對此處有些混淆,因此在第一大點中增加了Android內外存的相關介紹。分別用Genymotion模擬器(Google Nexus 5 API 23)和真機(華為 API 23)測試的結果如下:
- 應用程式目錄路徑: 兩者都是一樣,為
/data/user/0/com.lemon.hotfix/app_odex
,odex是自定義的資料夾名,此資料夾用於存放移置後的已修復dex檔案。 - sd卡獲取路徑:
- Genymotion模擬器:
/storage/emulated/0
- 真機:
/storage/sdcard0
- Genymotion模擬器:
下圖舉例模擬器測試時debug到的路徑:
2. 熱修復邏輯實現DexFixUtils
DexFixUtils類的主要作用是:
/*
* @author lemonGuo
* 處理熱修復主要邏輯
* */
public class DexFixUtils {
private static HashSet<File> loadedDex = new HashSet<File>();
static{
loadedDex.clear();
}
......
}
(1)loadFixedDex 載入dex方法
/*
* 遍歷所有的dex檔案儲存到成員變數loadedDex中,用於後續合併
* */
public static void loadFixedDex(Context context) {
if(context == null){
return ;
}
File fileDir = context.getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
File[] listFiles = fileDir.listFiles();
for (File file:listFiles){
if(file.getName().startsWith("classes") && file.getName().endsWith("dex")){
loadedDex.add(file);
}
}
//合併之前到dex
doDexInject(context,fileDir);
}
(2)doDexInject 合併替換dex方法
目前的條件是已經獲取到dex集合,即原本的classes.dex和已修復好的classes.dex2,而接下來的任務就是合併這兩個dex檔案,再回顧以下兩個載入器作用:
- PathClassLoader:用來載入已安裝的應用程式dex;
- DexClassLoader:支援載入外部的APK、Jar或dex檔案;(限制:必須要在應用程式目錄下)
根據以上載入器的各自作用,再結合第一點中的思路講解,實現dex檔案合併稍稍有了頭緒,整體邏輯可分為3個步驟:
- 首先獲得載入應用程式dex檔案的PathClassLoader,即通過Context上下文
context.getClassLoader()
獲取的便是載入應用的PathClassLoader; - 然後獲得載入制定路徑下dex檔案的DexClassLoader,這裡的DexClassLoader需要自行建立,其構造方法中的四個引數分別是:指定要載入dex檔案的路徑dexPath、指定dex檔案需要被寫入的目錄,一般是應用程式內部路徑optimizedDirectory(不可以為null)、包含native庫的目錄列表librarySearchPath(可能為null)、父類載入器parent;
- 最後通過這兩個載入器去重寫 DexPathList類中的Element型別陣列dexElements,即直接將已修復的dex放到dexElement陣列中有bug類的dex前面。
上面3個步驟,重點在於獲取到PathClassLoader、DexClassLoader載入器後,第三步如何詳細實現第三步,即重寫dexElements陣列?
首先濾清思路既然要重寫dexElements陣列,首先要獲得載入程式PathClassLoader所載入的dexElements陣列,和載入已修復dex檔案DexClassLoader所載入的dexElements陣列,獲得這兩組陣列之後,再進行重寫。條件是已獲得PathClassLoader、DexClassLoader兩個載入器,接下來任務是如何獲取其對應的dexElements陣列?
檢視以下原始碼,可見我們已經洞悉dexElements陣列最終藏身之處,可是在AS中是無法直接查閱BaseDexClassLoader及相關原始碼,這將意味著首先需要通過反射獲取到BaseDexClassLoader類,再反射獲取類中的DexPathList類,即可獲取到dexElements陣列。
//原始碼
BaseDexClassLoader{
DexPathList pathList;
}
DexPathList{
Element[] dexElements;
}
注意此過程還沒有結束,獲取到兩個陣列後,首先要進行合併,即將已修復的dex放到dexElement陣列中有bug類的dex前面,然後將合併後的陣列去替換成程式載入器PathClassLoader所載入的陣列,因為DexClassLoader只是用來載入程式之外的已修復dex檔案,最終載入程式應用的還是PathClassLoader。如此一來即可大功告成!
以上doDexInject
方法邏輯完整實現原始碼如下:
/**
* 通過PathClassLoader、DexClassLoader合併dex檔案,實現類替換修復
* @param context 上下文環境
* @param filesDir dex所在的檔案目錄
*/
private static void doDexInject(Context context, File filesDir) {
//dex檔案需要被寫入的目錄
String optimizeDir = filesDir.getAbsolutePath()+File.separator+"opt_dex";
File fileOpt = new File(optimizeDir);
if(!fileOpt.exists()){
fileOpt.mkdirs();
}
//1.獲得載入應用程式dex的PathClassLoader
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
for (File dex : loadedDex) {
//2.獲得載入指定路徑下dex的DexClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(
dex.getAbsolutePath(),
fileOpt.getAbsolutePath(),
null,
pathClassLoader);
//3.合併dex
try {
Object dexObj = getPathList(dexClassLoader);
Object pathObj = getPathList(pathClassLoader);
Object fixDexElements = getDexElements(dexObj);
Object pathDexElements = getDexElements(pathObj);
//合併兩個陣列
Object newDexElements = combineArray(fixDexElements,pathDexElements);
//重新賦值給PathClassLoader 中的exElements陣列
Object pathList = getPathList(pathClassLoader);
setField(pathList,pathList.getClass(),"dexElements",newDexElements);
} catch (Exception e) {
e.printStackTrace();
}
}
}
(3)反射相關方法
此部分有關反射getField
的程式碼較為常規,另筆者多做了一層封裝,通過getPathList
、getDexElements
呼叫getField
反射方法獲取指定類,這樣整體程式碼風格看起來更優雅。
注意:此處兩個反射獲取BaseDexClassLoader類和dexElements陣列時提供的引數字串不同,獲取類需要以“包+類名”,例如dalvik.system.BaseDexClassLoader
;獲取成員變數只需“變數名”即可,例如dexElements
private static Object getPathList(Object baseDexClassLoader) throws Exception {
return getField(baseDexClassLoader,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
}
private static Object getDexElements(Object obj) throws Exception {
return getField(obj,obj.getClass(),"dexElements");
}
/**
* 通過反射獲得對應類
* @param obj Object類物件
* @param cl class物件
* @param field 獲得類的字串名稱
* @return
*/
private static Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
/**
* 通過反射修改值
* @param obj 待修改值
* @param cl class物件
* @param field 待修改值的字串名稱
* @param value 修改值
*/
private static void setField(Object obj,Class<?> cl, String field, Object value) throws Exception {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
localField.set(obj,value);
}
(4)數組合並方法
此方法的引數就是DexClassLoader、PathClassLoader所載入的這兩個dexElements陣列,主要目的就是將已修復的dex放到dexElement陣列中有bug類的dex前面。
正如第一大點所說,沒必要去找到那個bug類所在的陣列位置,而去剛剛好將已修復的放置它前面,有一個最簡單的方法:DexClassLoader所載入的dexElements陣列是已修復過的,而PathClassLoader所載入的dexElements陣列是存有bug的,因此直接建立一個新組,長度是兩者之和,先賦值已修復過的陣列中的值,然後再去賦值存在bug的陣列中的值,如此一來根據findClass
方法的return機制,遍歷找到指定類後直接return,陣列後面的值不再考慮,即可完美簡化解決數組合並問題。
方法邏輯圖如下:
該方法具體實現程式碼如下:
/**
* 兩個數組合並
* @param arrayLhs
* @param arrayRhs
* @return
*/
private static Object combineArray(Object arrayLhs, Object arrayRhs) {
Class<?> localClass = arrayLhs.getClass().getComponentType();
int i = Array.getLength(arrayLhs);
int j = i + Array.getLength(arrayRhs);
Object result = Array.newInstance(localClass, j);
for (int k = 0; k < j; ++k) {
if (k < i) {
Array.set(result, k, Array.get(arrayLhs, k));
} else {
Array.set(result, k, Array.get(arrayRhs, k - i));
}
}
return result;
}
三. 過程測試
1 . 程式閃退展示
首先程式執行後直接點選TEST按鈕,客戶端處理10/0
運算,必然出錯,出現程式閃退,效果GIF如下:
2 . 生成已修復的dex檔案
筆者本想通過AS來獲取這個已修復的dex檔案,可是錯誤類只有DexFixTest這一個類,因此只需要對其編譯成class檔案,再到dex檔案即可(此處暫時不考慮混淆),可是AS每次編譯是將整個專案工程一起編譯,那生成的dex檔案是包含所有類的,如此一來還需什麼替換,直接用此dex不就行了?
這樣並非達到熱修復本意,已修復dex檔案只需要包含對錯誤類的修復,因此筆者採用手動通過命令來生成。過程如下:
- 首先在AS中的DexFixTest類修改運算
10/0
為10/1
,點選編譯,在/Users/lemon/Desktop/gym/android_project/apps_gym/HotfixDemo/app/build/intermediates/classes/debug/com/lemon/hotfix/test/DexFixTest.class
可以找到該類的class檔案。 - 在藉助AS獲取到
DexFixTest.class
檔案後,在桌面隨意建一個資料夾放置該檔案,注意包名需對應資料夾名!(圖片如下),開啟終端輸入dx --dex --output=/Users/lemon/Desktop/test/classes2.dex /Users/lemon/Desktop/test/
,即可生成對應的dex檔案。 - 直接將檔案拖入到模擬器或真機即可
3 . 熱修復功能展示
最終演示效果圖如下,首先點選TEST按鈕,程式執行10/0
出錯閃退。再次進入程式點選FIX按鈕,進行類替換修復,將DexFixTest類中的運算替換為10/1
,螢幕吐司顯示dex檔案已被重寫,再點選TEST按鈕計算,此時計算的是10/1
,吐司顯示1,表示類已經被替換,熱修復成功。
注意:在編寫程式碼中一定要關閉AS 的Instant Run功能,它雖然可以加快構建和部署流程的速度,但其本身有太多限制。筆者掉入坑中好久,一直除錯有問題,最後才發現是這廝在作怪!
以上是根據第一篇解析了DexClassLoader類載入相關原始碼後,探索到可實現熱修復的思路,即修改dexElement,讓findClass
方法預先載入已修復的dex檔案而忽略後續的bug檔案,並憑藉此思路最終實現類替換修復功能。
整體實現思路並不複雜,不過其中涉及到一些底層知識,例如Android內外存、反射、dex檔案相關知識等等,筆者掉了不少坑,繼續努力。這兩篇主要學習DexClassLoader類載入相關原理並自行實現了一個小demo,對此理解已經不少,下一篇將學習記錄騰訊Tinker的使用及原理,共勉~
(原始碼正在整理中,後續放出)
若有錯誤,虛心指教~