從“0”到“1”手擼一個熱修復框架

前言

熱修復原理,這個一直是這幾年來很熱門的話題,在項目中使用的話,也基本要麼是阿里系或者騰訊系的開源框架。但是作爲一個光會使用的程序員是遠遠不夠的。這篇文章會從dex分包的原因,原理,熱修復的由來及原理爲思路,手動寫一個熱修復的框架,這樣感覺比光分析原理要更加深記憶。也是一片比較全面的文章。秉持着一blog一框架的原則,沒有分開,關於熱修復的所有知識點,都匯聚在這篇博客上,可能略長,希望大家能夠認真看完。

先看原理,再擼代碼

什麼是dex分包

先了解下什麼是dex分包,當我們把一個apk解壓後,我們會發現有一個classes.dex的文件,它包含了我們項目中所有的class文件。但是隨着業務越來越複雜,方法數也越來越多,當方法數超過一定範圍後,就會導致項目編譯失敗。

因爲一個dvm中存儲方法id用的是short類型,所以就導致dex中方法不能超過65535個

那麼如何解決這個問題尼?那就是dex分包方案。

2.1 分包的原理

就是將編譯好的class文件,拆分打包成2個dex,繞過dex方法的限制,運行時,再動態加載第2個dex文件。

這樣除了第1個dex文件外(正常apk中存在的唯一的dex文件),其他的所有dex文件都以資源的形式放到apk裏面,並在Application的onCreate回調中通過系統的ClassLoader加載它們。

值得注意的是,在注入之前就已經引用到的類,則必須放到第一個dex文件中,否則會提示找不到該文件。

接下來我們就來看看,如何將第2個dex文件注入到系統中。

classLoader

在Android中,我們編譯好的class文件,是需要加載到虛擬機纔會被執行的,而這個加載的過程就是通過ClassLoader來完成的。

3.1 ClassLoader體系

看上圖應該也能明白,我們第二個dex是以資源的形式存在的,所以我們要用到的classLoader是DexClassLoader。

DexClassPath:可以從一個jar包或者未安裝的apk中加載dex

看下DexClassLoader是怎麼加載class的,這段邏輯是在它的父類BaseDexClassLoader中,我們先看下這個類的源碼。

– BaseDexClassLoader.java

public class BaseDexClassLoader extends ClassLoader {

 // 需要加載的dex列表
 private final DexPathList pathList;

 @Override
 protected Class<?> findClass(String name) throws ClassNotFoundException {
 List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
 // 使用pathList對象查找name類
 Class c = pathList.findClass(name, suppressedExceptions);
 return c;
 }
}

這段代碼很簡單的,就是創建了個DexPathList對象,然後調用它的findClass方法,根據類名,尋找該類,那麼我們看下DexPathList對象,它在DexClassLoader中。

– DexClassLoader.java

*package*/ final class DexPathList {
 private static final String DEX_SUFFIX = ".dex";
 private static final String JAR_SUFFIX = ".jar";
 private static final String ZIP_SUFFIX = ".zip";
 private static final String APK_SUFFIX = ".apk";

 private final ClassLoader definingContext;

 // ->> 註釋1
 private final Element[] dexElements;

 public Class findClass(String name, List<Throwable> suppressed) {

 // ->> 註釋2
 for (Element element : dexElements) {
 DexFile dex = element.dexFile;

 if (dex != null) {
 Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
 if (clazz != null) {
 return clazz;
 }
 }
 }
 if (dexElementsSuppressedExceptions != null) {
 suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
 }
 return null;
 }
}
  • 註釋1:一個ClassLoader可以包含多個dex文件,每個dex文件是一個Element,多個dex文件排列成一個有序的數組就是dexElements
  • 註釋2:當找類的時候,會按順序遍歷dex文件,然後從當前遍歷的dex文件中找類,如果找到該類則返回,如果找不到從下一個dex文件繼續查找

那麼顯而易見,我們就可以通過反射,強行的將一個外部的dex文件添加到此dexElements中,這樣尋找起類來,就也可以從我們第2個dex中尋找了,這樣就算是將我們第2個dex加載進去了。(代碼在下面的實戰中會寫)

3.2 總結一下

1. 因爲dvm中存儲方法id用的是short類型,所以就導致dex中方法不能超過65535個,所以我們會將我們編譯好的class文件,拆分打包成2個dex,繞過dex方法的限制,運行時再加載第2個dex。

2. 通過源碼我們可知,一個ClassLoader可以包含多個dex文件,每個dex文件是一個Element,多個dex文件排列成了一個有序數組dexElements,在項目運行的過程中,我們所需用到的class,就是根據遍歷dexElements去尋找的,將我們只需要將需要加載的dex文件,通過反射加入到dexElements數組中,就可以完成加載了。

這下知道了dex分包的原因和原理了吧,那麼思考一個問題,如果在加載的過程中有2個一樣的class文件,該怎麼辦?

其實從上述的代碼我們可以知道,尋找一個class文件時,它會遍歷dexElements數組,先從第一個dex中去尋找,找到就返回,找不到才從下一個dex繼續找,那麼其實就可以理解成

如果有兩個重複的class,那麼dex1.class會覆蓋dex2.class

看到這個,聰明如我的你,有沒有想到什麼?

我們如果有個class裏面有bug,我們只需要提供一個一樣的class,並把它打包成dex,通過反射,放到dexElements最前端,那是不是就加載我們新的class,之前的有問題的class是不是就被覆蓋了?沒錯,這就是熱修復原理

熱修復原理

這塊知識,其實可以看下安卓App熱補丁動態修復技術介紹,當然懶惰如你懶得看的話,那麼就繼續看咱們的,接下來的內容,實際上也是參考上面這篇文章的。

通過上面的推論,我們知道了,如果兩個class相同,那麼在前面的dex中的class,會覆蓋後面dex中與它一樣的class。如下圖:

那麼熱補丁的原理就是,當修改好了一個類的bug後,將這個類打包成dex,比如叫patch.dex,再通過反射,將該dex放置在dexElements的最前面,那麼這個patch中我們修改的class就覆蓋了之前出現問題的class。如下圖

擼代碼

源碼很簡單,點擊按鈕蹦出一個toast MainActivity.java

@Override
public void onClick(View v) {

 switch (v.getId()){

 case R.id.btn:

 MyLogic myLogic = new MyLogic();
 Toast.makeText(MainActivity.this, myLogic.toMsg(), Toast.LENGTH_SHORT).show();
 break;
 }
}

public class MyLogic {

 public String toMsg(){

 return "老闆很摳門";
 }
}

只是隨口一說,爽歸爽,但是不能讓老闆知道,老闆用的時候,得給他手機打個補丁。只能讓別人看,不能讓老闆自己看到。

5.1 製作補丁

  1. 修改源碼:
    首先,我們先將代碼修正過來,將“老闆很摳門”改成“老闆人真好”,然後重新編譯項目。
  2. 找到MyLogic.class 文件:
    位置如下圖

  • 創建文件夾:
    路徑和包名一樣,然後將找到的class文件複製進去

  • 打jar包:
    在外層目錄下,我是在temp裏面創建的包路徑,所以先切換到temp目錄下 cd temp 進入外層目錄後,再執行打包命令:jar -cvf my.jar com

注:jar命令是在jdk的bin目錄裏面,不要忘記配置環境變量

  • 打dex包:
    執行命令
    dx --dex --output=my_dex.jar my.jar如下圖所示

好了,大功告成,將我們的my_dex.jar 放到sdcard上就行了,一般是放在服務器提供下載,這裏爲了簡單使用。

5.2 加載補丁

還記得上面我們說的邏輯嗎?(不記得看上面的4)

咳咳,雖然很囉嗦,但是吧,還得說 通過DexClassLoader加載我們的補丁(my_dex.jar),然後放到dexElements的前面,替換原有的錯誤。

思路

  1. 反射獲取BaseDexClassLoader對象,然後獲取它的成員變量pathList,pathList爲DexClassLoader的內部類,裏面有成員變量dexElements,獲取它。
  2. 創建DexClassLoader對象,加載我們的補丁文件(my_dex.jar), 並通過反射獲取它父類的pathList對象,依次獲取補丁包加載後生成的dexElements。
  3. 將2個dexElements通過反射合併,生成新的dexElements
  4. 將新生成的dexElements,通過反射,替換掉當前加載的dexElements。

5.3 擼代碼

public class MyApplication extends Application {

 @Override
 public void onCreate() {
 super.onCreate();

 // 獲取我們補丁的路徑
 String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath()+"/my_dex.jar";

 // 加載補丁
 try {
 inject(dexPath);
 } catch (Exception e) {
 e.printStackTrace();
 }
 }

 /**
 * 加載補丁
 * */
 private void inject(String dexPath) throws Exception{

 // ================= 1.獲取classes的dexElements ===================

 // 反射獲取 BaseDexClassLoader
 Class<?> mBaseDexClassLoader = Class.forName("dalvik.system.BaseDexClassLoader");

 // 反射獲取 BaseDexClassLoader 中的 pathList
 Field pathListField = mBaseDexClassLoader.getDeclaredField("pathList");
 pathListField.setAccessible(true);
 Object pathList = pathListField.get(getClassLoader());

 // 反射獲取 pathList 中的 dexElements
 Field dexElementsField = pathList.getClass().getDeclaredField("dexElements");
 dexElementsField.setAccessible(true);
 Object dexElements = dexElementsField.get(pathList); // pathList爲dexClassLoader中的內部類

 // ================= 2.獲取我們的補丁中的dexElements ===================

 String dexopt = getDir("dexopt", 0).getAbsolutePath();
 DexClassLoader mDexClassLoader = new DexClassLoader(dexPath, dexopt, dexopt, getClassLoader());

 // 反射獲取加載我們補丁後 dexClassLoader 中的 pathList
 Field myPathListField = mBaseDexClassLoader.getDeclaredField("pathList");
 myPathListField.setAccessible(true);
 Object myPathList = myPathListField.get(mDexClassLoader);

 // 反射獲取 加載我們補丁後,pathList 中的 dexElements
 Field myDexElementsField = myPathList.getClass().getDeclaredField("dexElements");
 myDexElementsField.setAccessible(true);
 Object myDexElements = myDexElementsField.get(myPathList);

 // ================= 3.合併數組 ===================
 Object newDexElements = mergeArray(myDexElements, dexElements);

 // ================= 4.將合併後的數組賦值給我們的app的classLoader ===================
 dexElementsField.set(pathList, newDexElements);
 }

 /**
 * 通過反射合併兩個數組
 */
 private Object mergeArray(Object firstArr, Object secondArr) {
 int firstLength = Array.getLength(firstArr);
 int secondLength = Array.getLength(secondArr);
 int length = firstLength + secondLength;

 Class<?> componentType = firstArr.getClass().getComponentType();
 Object newArr = Array.newInstance(componentType, length);
 for (int i = 0; i < length; i++) {
 if (i < firstLength) {
 Array.set(newArr, i, Array.get(firstArr, i));
 } else {
 Array.set(newArr, i, Array.get(secondArr, i - firstLength));
 }
 }
 return newArr;
 }
}

結果

在源碼不變的基礎上,加載補丁前和加載補丁後的對比

踩坑

可能是SDK比較新的緣故,所以並未發生網絡上提到的CLASS_ISPREVERIFIED問題,簡單說一下這個問題,在class替換加載的過程中,虛擬機會將dex優化成odex後纔拿去執行。在這個過程中會對所有class一個校驗。
假設A類在它的static方法,private方法,構造函數,override方法中直接引用到B類。如果A類和B類在同一個dex中,那麼A類就會被打上CLASS_ISPREVERIFIED標記,替換的話,會拋出異常

6.1 解決辦法

這個規則其實也可以理解成,只要在static方法,構造方法,private方法,override方法中直接引用了其他dex中的類,那麼這個類就不會被打上CLASS_ISPREVERIFIED標記。

那麼我們只需要讓所有類都引用其他dex中的某個類就可以了

比如說 在所有類的構造函數中插入這行代碼 System.out.println(AntilazyLoad.class); 這樣當安裝apk的時候,classes.dex內的類都會引用一個在不相同dex中的AntilazyLoad類,這樣就防止了類被打上CLASS_ISPREVERIFIED的標誌了,只要沒被打上這個標誌的類都可以進行打補丁操作。

源碼:

https://github.com/liuyangbajin/android_framework
上述邏輯主要是來源於QQ空間熱修復邏輯

Android學習PDF+架構視頻+面試文檔+源碼筆記


感謝大家能耐着性子看完囉裏囉嗦的文章

在這裏我也分享一份私貨,自己收錄整理的Android學習PDF+架構視頻+面試文檔+源碼筆記,還有高級架構技術進階腦圖、Android開發面試專題資料,高級進階架構資料幫助大家學習提升進階,也節省大家在網上搜索資料的時間來學習,也可以分享給身邊好友一起學習

如果你有需要的話,可以點贊+評論關注我,然後加我VX:15388039515 我發給你
(或關注微信公衆號“Android開發之家”回覆【資料】免費領取)

發佈了189 篇原創文章 · 獲贊 76 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章