1. 程式人生 > >Android 熱修復原理篇及幾大方案比較

Android 熱修復原理篇及幾大方案比較

熱修復說白了就是”即時無感打補丁”,比如你們公司上線一個app,使用者反應有重大bug,需要緊急修復。2015年以來,Android開發領域裡對熱修復技術的討論和分享越來越多,同時也出現了一些不同的解決方案.如果按照通常做法,那就是程式猿加班搞定bug,然後測試,重新打包併發布。這樣帶來的問題就是成本高,效率低。於是,熱修復就應運而生.一般通過事先設定的介面從網上下載無Bug的程式碼來替換有Bug的程式碼。這樣就省事多了,用 戶體驗也好。目前熱修復儘管有很多坑,做了好多工作,可能吃力不討好,各種適配可能還是沒修復線上的有些Bug。不過呢,對於一個產品有熱修復畢竟是件好事。尤其是對於一個有眾多使用者的app(,一個bug不只是影響到幾個幾十個使用者,一些創業公司的APP,崩潰或者bug可能直接導致使用者解除安裝和永不使用,所以,就衝它有不用發版也可以解決我們線上的bug,我們的app也要適當考慮加入熱修復。

我們知道Android系統也是仿照java搞了一個虛擬機器,不過它不叫JVM,它叫Dalvik/ART VM他們還是有很大區別的(這是不是我們的重點, 點開是個拓展閱讀)。我們只需要知道,Dalvik/ART VM 虛擬機器載入類和資源也是要用到ClassLoader,不過Jvm通過ClassLoader載入的class位元組碼,而Dalvik/ART VM通過ClassLoader載入則是dex。

Android的類載入器分為兩種,PathClassLoader和DexClassLoader,兩者都繼承自BaseDexClassLoader

PathClassLoader程式碼位於libcore\dalvik\src\main\

Java\dalvik\system\PathClassLoader.java 
DexClassLoader程式碼位於libcore\dalvik\src\main\java\dalvik\system\DexClassLoader.java 
BaseDexClassLoader程式碼位於libcore\dalvik\src\main\java\dalvik\system\BaseDexClassLoader.java

  • PathClassLoader
  • 用來載入系統類和應用類

  • DexClassLoader

    用來載入jar、apk、dex檔案.載入jar、apk也是最終抽取裡面的Dex檔案進行載入.

    這裡寫圖片描述

2.熱修復機制

熱修復就是利用dexElements的順序來做文章,當一個補丁的patch.dex放到了dexElements的第一位,那麼當載入一個bug類時,發現在patch.dex中,則直接載入這個類,原來的bug類可能就被覆蓋了

看下PathClassLoader程式碼

public class PathClassLoader extends BaseDexClassLoader {

    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
} 

DexClassLoader程式碼

public class DexClassLoader extends BaseDexClassLoader {

    public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}

兩個ClassLoader就兩三行程式碼,只是呼叫了父類的建構函式.

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 {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

在BaseDexClassLoader 建構函式中建立一個DexPathList類的例項,這個DexPathList的建構函式會建立一個dexElements 陣列

public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) {
        ... 
        this.definingContext = definingContext;
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        //建立一個數組
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);
        ... 
    }

然後BaseDexClassLoader 重寫了findClass方法,呼叫了pathList.findClass,跳到DexPathList類中.

/* package */final class DexPathList {
    ...
    public Class findClass(String name, List<Throwable> suppressed) {
            //遍歷該陣列
        for (Element element : dexElements) {
            //初始化DexFile
            DexFile dex = element.dexFile;

            if (dex != null) {
                //呼叫DexFile類的loadClassBinaryName方法返回Class例項
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }       
        return null;
    }
    ...
} 

會遍歷這個陣列,然後初始化DexFile,如果DexFile不為空那麼呼叫DexFile類的loadClassBinaryName方法返回Class例項. 
歸納上面的話就是:ClassLoader會遍歷這個陣列,然後載入這個陣列中的dex檔案. 
而ClassLoader在載入到正確的類之後,就不會再去載入有Bug的那個類了,我們把這個正確的類放在Dex檔案中,讓這個Dex檔案排在dexElements陣列前面即可.

CLASS_ISPREVERIFIED問題

根據QQ空間談到的在虛擬機器啟動的時候,在verify選項被開啟的時候,如果static方法、private方法、建構函式等,其中的直接引用(第一層關係)到的類都在同一個dex檔案中,那麼該類就會被打上CLASS_ISPREVERIFIED標誌,且一旦類被打上CLASS_ISPREVERIFIED標誌其他dex就不能再去替換這個類。所以一定要想辦法去阻止類被打上CLASS_ISPREVERIFIED標誌。

為了阻止類被打上CLASS_ISPREVERIFIED標誌,QQ空間開發團隊提出了一個方法是先將一個預備好的hack.dex加入到dexElements的第一項,讓後面的dex的所有類都引用hack.dex其中的一個類,這樣原來的class1.dex、class2.dex、class3.dex中的所有類都引用了hack.dex的類,所以其中的都不會打上CLASS_ISPREVERIFIED標誌。

比如Qzon團隊的 安卓App熱補丁動態修復技術介紹  (這個一定要看!!! 他是熱修復元老級文章,也是重點抄襲物件)

動態載入class檔案,然後呼叫反射完成修復的原理:

Java程式在執行的時候,JVM通過類載入機制(ClassLoader)把class檔案載入到記憶體中,只有class檔案被載入記憶體,才能被其他class引用,使程式正確執行起來.

Java中的ClassLoader有三種.

1. Bootstrap ClassLoader 

由C++寫的,由JVM啟動.

啟動類載入器,負責載入java基礎類,對應的檔案是%JRE_HOME/lib/ 目錄下的rt.jar、resources.jar、charsets.jar和class等

2.Extension ClassLoader

Java類,繼承自URLClassLoader

擴充套件類載入器,對應的檔案是 %JRE_HOME/lib/ext 目錄下的jar和class等

3.App ClassLoader

Java類,繼承自URLClassLoader

系統類載入器,對應的檔案是應用程式classpath目錄下的所有jar和class等

這裡要注意一點:只有被同一個類載入器例項載入並且檔名相同的class檔案才被認為是同一個class.

下面來一個小例子:

因為系統的ClassLoader只會載入指定目錄下的class檔案,如果你想載入自己的class檔案,那麼就可以自定義一個ClassLoader.\

如何自定義ClassLoader

新建一個類繼承自java.lang.ClassLoader,重寫它的findClass方法。--將class位元組碼陣列轉換為Class類的例項---呼叫loadClass方法即可

我先建一個叫Log的類,很簡單,只有一句列印

  1. public class Log {  
  2.    public static void main(String[] args) {  
  3.         System.out.println("呼叫成功");  
  4.    }  
  5. }    

  6. 把這個java檔案放到D盤根目錄,然後開啟cmd,用javac命令把java檔案轉化為class檔案

  1. 然後我新建一個MyClassLoader繼承自ClassLoader
  2. public class MyClassLoader extends ClassLoader {  
  3.    @Override  
  4.    protected Class<?> findClass(String name) throws ClassNotFoundException {  
  5.        Class log = null;  
  6.        // 獲取該class檔案位元組碼陣列  
  7.        byte[] classData = getData();  
  8.        if (classData != null) {  
  9.            // 將class的位元組碼陣列轉換成Class類的例項  
  10.            log = defineClass(name, classData, 0, classData.length);  
  11.        }  
  12.        return log;  
  13.    }  
  14.    private byte[] getData() {  
  15.        //指定路徑  
  16.        String path = "D:/Log.class";  
  17.        File file = new File(path);  
  18.        FileInputStream in = null;  
  19.        ByteArrayOutputStream out = null;  
  20.        try {  
  21.            in = new FileInputStream(file);  
  22.            out = new ByteArrayOutputStream();  
  23.            byte[] buffer = new byte[1024];  
  24.            int size = 0;  
  25.            while ((size = in.read(buffer)) != -1) {  
  26.                out.write(buffer, 0, size);  
  27.            }  
  28.        } catch (IOException e) {  
  29.            e.printStackTrace();  
  30.        } finally {  
  31.            try {  
  32.                in.close();  
  33.            } catch (IOException e) {  
  34.                e.printStackTrace();  
  35.            }  
  36.        }  
  37.        return out.toByteArray();  
  38.    }  
  39. }  
  40. //最後測試一下,輸出載入這個Log的class檔案的載入器,並且利用反射呼叫它的mian方法.
  41. public class Test {  
  42.    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException {  
  43.        MyClassLoader myClassLoader = new MyClassLoader();  
  44.        //查詢Log這個class檔案  
  45.        myClassLoader.findClass("Log");  
  46.        //載入Log這個class檔案  
  47.        Class<?> Log = myClassLoader.loadClass("Log");    
  48.        System.out.println("類載入器是:"+Log.getClassLoader());    
  49.        //利用反射獲取main方法  
  50.        Method method=Log.getDeclaredMethod("main", String[].class) ;    
  51.        Object object=Log.newInstance();  
  52.        String [] arg={"ad"};  
  53.        method.invoke(object, (Object)arg);  
  54.    }  
  55. }  

業界內比較著名的有阿里巴巴的AndFix,HotFix(內測)Dexposed,Qzone的超級補丁和tencent的Tinker(將開源)以及大眾點評的Nuwa,騰訊Bugly,RocooFix

Dex的熱修復總結

Dex的熱修復目前來看基本上有四種方案:

  • 阿里系的從native層入手,見AndFix
  • 微信的方案,見微信Android熱補丁實踐演進之路,dexDiff和dexPatch,方法很牛逼,需要全量插入,但是這個全量插入的dex中需要刪除一些過早載入的類,不然同樣會報class is pre verified異常,還有一個缺點就是合成佔記憶體和內建儲存空間。微信讀書的方式和微信類似,見Android Patch 方案與持續交付,不過微信讀書是miniloader方式,啟動時容易ANR,在我錘子手機上變現出來特別明顯,長時間的卡圖示現象。

此外,微信的方案是多classloader,這種方式可以解決用multidex方式在部分機型上不生效patch的問題,同時還帶來一個好處,這種多classloader的方式使用的是instant run的程式碼,如果存在native library的修復,也會帶來極大的方便。

Native Library熱修復總結

而native libraray的修復,目前來說,基本上有兩種方案。。

  • 類似multidex的dex方式,插入目錄到陣列最前面,具體文章見Android熱更新之so庫的熱更新,需要處理系統的相容性問題,系統分隔線是Android 6.0
  • 第二種方式需要依賴多classloader,在構造BaseDexClassLoader的時候,獲取原classloader的native library,通過環境變數分隔符(冒號),將patch的native library與原目錄進行連線,patch目錄在前,這樣同樣可以達到修復的目的,缺點是需要依賴dex的熱修復,優點是應用native library時不需要處理相容性問題,當然從patch中釋放出來的時候也需要處理相容性問題。

上述方案從原理上可以簡單劃分為3類:

原理 方案
Native hook方案 AndFix
QQ空間提出的Classloader替換類的方案 Nuwa, HotFix, RocooFix
Instant Run的冷插拔原理的Dex替換 Tinker
優缺點分析
測試模組 AndFix Classloader方案 Tinker
類替換 no yes yes
資源替換 no no yes
是否需要重啟 no yes yes
相容穩定性 不穩定 最好 穩定

下面,我們就分別介紹QQ空間超級熱補丁技術和微信Tinker以及阿里百川的HotFix技術。

一、Qzone超級補丁技術

超級補丁技術基於DEX分包方案,使用了多DEX載入的原理,大致的過程就是:把BUG方法修復以後,放到一個單獨的DEX裡,插入到dexElements陣列的最前面,讓虛擬機器去載入修復完後的方法。

 

當patch.dex中包含Test.class時就會優先載入,在後續的DEX中遇到Test.class的話就會直接返回而不去載入,這樣就達到了修復的目的。

但是有一個問題是,當兩個呼叫關係的類不在同一個DEX時,就會產生異常報錯。我們知道,在APK安裝時,虛擬機器需要將classes.dex優化成odex檔案,然後才會執行。在這個過程中,會進行類的verify操作,如果呼叫關係的類都在同一個DEX中的話就會被打上`CLASS_ISPREVERIFIED`的標誌,然後才會寫入odex檔案。

所以,為了可以正常地進行打補丁修復,必須避免類被打上`CLASS_ISPREVERIFIED`標誌,具體的做法就是單獨放一個類在另外DEX中,讓其他類呼叫。

我們來逆向手機QQ空間APK看一下具體的實現:

先進入程式入口`QZoneRealApplication`,在`attachBaseContext`中進行了兩步操作:修復`CLASS_ISPREVERIFIED`標誌導致的unexpected DEX problem異常、載入修復的DEX。

 

 1. 修復Unexpected DEX Problem異常

先看程式碼,

 

可以看到,這裡是要載入一個libs目錄下的dalvikhack.jar。在專案的assets/libs找到該檔案,解壓得到’classes.dex’檔案,逆向開啟該DEX檔案,

 

通過不同的DEX載入進來,然後在每一個類的構造方法中引用其他DEX中的唯一類AnitLazyLoad,避免類被打上CLASS_ISPREVERIFIED標誌。

 

在無修復的情況下,將DO_VERIFY_CLASSES設定為false,以提高效能。只有在需要修復的時候,才設定為true。

 

至於如何載入進來,與下面第二個步驟基本相同。

2. 載入修復的DEX

從loadPatchDex()方法進入,經過幾次跳轉,到達核心的程式碼段,`SystemClassLoaderInjector.c()`。由於進行了混淆和多次方法的跳轉,於是將核心程式碼段做了如下整理:

 

修復的步驟為:

1. 可以看出是通過獲取到當前應用的Classloader,即為BaseDexClassloader

2. 通過反射獲取到他的DexPathList屬性物件pathList

3. 通過反射呼叫pathList的dexElements方法把patch.dex轉化為Element[]

4. 兩個Element[]進行合併,把patch.dex放到最前面去

5. 載入Element[],達到修復目的

整體的流程圖如下:

 

從流程圖來看,可以很明顯的找到這種方式的特點:

優勢:

  1. 沒有合成整包(和微信Tinker比起來),產物比較小,比較靈活
  2. 可以實現類替換,相容性高。(某些三星手機不起作用)

不足:

1. 不支援即時生效,必須通過重啟才能生效。

2. 為了實現修復這個過程,必須在應用中加入兩個dex!dalvikhack.dex中只有一個類,對效能影響不大,但是對於patch.dex來說,修復的類到了一定數量,就需要花不少的時間載入。對手淘這種航母級應用來說,啟動耗時增加2s以上是不能夠接受的事。

3. 在ART模式下,如果類修改了結構,就會出現記憶體錯亂的問題。為了解決這個問題,就必須