android Qzone的App熱補丁熱修復技術

轉自:https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a;

          https://www.cnblogs.com/purpleraintear/p/6046390.html;

1.背景

當一個App發佈以後,忽然發現了一個嚴重bug須要進行緊急修復,這時候公司各方就會忙得焦頭爛額:從新打包App、測試、向各個應用市場和渠道換包、提示用戶升級、用戶下載、覆蓋安裝。有時候僅僅是爲了修改了一行代碼,也要付出巨大的成本進行換包和從新發布。
這時候就提出一個問題:有沒有辦法以補丁的方式動態修復緊急Bug,再也不須要從新發布App,再也不須要用戶從新下載,覆蓋安裝?
雖然Android系統並無提供這個技術,可是很幸運的告訴你們,答案是:能夠,咱們QQ空間提出了熱補丁動態修復技術來解決以上這些問題。html

2.實際案例

空間Android獨立版5.2發佈後,收到用戶反饋,結合版沒法跳轉到獨立版的訪客界面,天天都較大的反饋。在之前只能緊急換包,從新發布。成本很是高,也影響用戶的口碑。最終決定使用熱補丁動態修復技術,向用戶下發Patch,在用戶無感知的狀況下,修復了外網問題,取得很是好的效果。java

3.解決方案

該方案基於的是android dex分包方案的,關於dex分包方案,網上有幾篇解釋了,因此這裏就再也不贅述,具體能夠看這裏
簡單的歸納一下,就是把多個dex文件塞入到app的classloader之中,可是android dex拆包方案中的類是沒有重複的,若是classes.dex和classes1.dex中有重複的類,當用到這個重複的類的時候,系統會選擇哪一個類進行加載呢?
讓咱們來看看類加載的代碼:

一個ClassLoader能夠包含多個dex文件,每一個dex文件是一個Element,多個dex文件排列成一個有序的數組dexElements,當找類的時候,會按順序遍歷dex文件,而後從當前遍歷的dex文件中找類,若是找類則返回,若是找不到從下一個dex文件繼續查找。
理論上,若是在不一樣的dex中有相同的類存在,那麼會優先選擇排在前面的dex文件的類,以下圖:

在此基礎上,咱們構想了熱補丁的方案,把有問題的類打包到一個dex(patch.dex)中去,而後把這個dex插入到Elements的最前面,以下圖:

好,該方案基於第二個拆分dex的方案,方案實現若是懂拆分dex的原理的話,你們應該很快就會實現該方案,若是沒有拆分dex的項目的話,能夠參考一下谷歌的multidex方案實現。而後在插入數組的時候,把補丁包插入到最前面去。
好,看似問題很簡單,輕鬆的搞定了,讓咱們來試驗一下,修改某個類,而後打包成dex,插入到classloader,當加載類的時候出現了(本例中是QzoneActivityManager要被替換):

爲何會出現以上問題呢?
從log的意思上來說,ModuleManager引用了QzoneActivityManager,可是發現這這兩個類所在的dex不在一塊兒,其中:android

  1. ModuleManager在classes.dex中
  2. QzoneActivityManager在patch.dex中
    結果發生了錯誤。
    這裏有個問題,拆分dex的不少類都不是在同一個dex內的,怎麼沒有問題?

讓咱們搜索一下拋出錯誤的代碼所在,嘿咻嘿咻,找到了一下代碼:

從代碼上來看,若是兩個相關聯的類在不一樣的dex中就會報錯,可是拆分dex沒有報錯這是爲何,原來這個校驗的前提是:
api

若是引用者(也就是ModuleManager)這個類被打上了CLASS_ISPREVERIFIED標誌,那麼就會進行dex的校驗。那麼這個標誌是何時被打上去的?讓咱們在繼續搜索一下代碼,嘿咻嘿咻~,在DexPrepare.cpp找到了一下代碼:

這段代碼是dex轉化成odex(dexopt)的代碼中的一段,咱們知道當一個apk在安裝的時候,apk中的classes.dex會被虛擬機(dexopt)優化成odex文件,而後纔會拿去執行。
虛擬機在啓動的時候,會有許多的啓動參數,其中一項就是verify選項,當verify選項被打開的時候,上面doVerify變量爲true,那麼就會執行dvmVerifyClass進行類的校驗,若是dvmVerifyClass校驗類成功,那麼這個類會被打上CLASS_ISPREVERIFIED的標誌,那麼具體的校驗過程是什麼樣子的呢?
此代碼在DexVerify.cpp中,以下:
數組

  1. 驗證clazz->directMethods方法,directMethods包含了如下方法:
    • static方法
    • private方法
    • 構造函數
  2. clazz->virtualMethods
    • 虛函數=override方法?

歸納一下就是若是以上方法中直接引用到的類(第一層級關係,不會進行遞歸搜索)和clazz都在同一個dex中的話,那麼這個類就會被打上CLASS_ISPREVERIFIED:

因此爲了實現補丁方案,因此必須從這些方法中入手,防止類被打上CLASS_ISPREVERIFIED標誌。
最終空間的方案是往全部類的構造函數裏面插入了一段代碼,代碼以下:
if (ClassVerifier.PREVENT_VERIFY) { System.out.println(AntilazyLoad.class); }緩存

其中AntilazyLoad類會被打包成單獨的hack.dex,這樣當安裝apk的時候,classes.dex內的類都會引用一個在不相同dex中的AntilazyLoad類,這樣就防止了類被打上CLASS_ISPREVERIFIED的標誌了,只要沒被打上這個標誌的類均可以進行打補丁操做。
而後在應用啓動的時候加載進來.AntilazyLoad類所在的dex包必須被先加載進來,否則AntilazyLoad類會被標記爲不存在,即便後續加載了hack.dex包,那麼他也是不存在的,這樣屏幕就會出現茫茫多的類AntilazyLoad找不到的log。
因此Application做爲應用的入口不能插入這段代碼。(由於載入hack.dex的代碼是在Application中onCreate中執行的,若是在Application的構造函數裏面插入了這段代碼,那麼就是在hack.dex加載以前就使用該類,該類一次找不到,會被永遠的打上找不到的標誌)
其中:

之因此選擇構造函數是由於他不增長方法數,一個類即便沒有顯式的構造函數,也會有一個隱式的默認構造函數。
空間使用的是在字節碼插入代碼,而不是源代碼插入,使用的是javaassist庫來進行字節碼插入的。
隱患:
虛擬機在安裝期間爲類打上CLASS_ISPREVERIFIED標誌是爲了提升性能的,咱們強制防止類被打上標誌是否會影響性能?這裏咱們會作一下更加詳細的性能測試.可是在大項目中拆分dex的問題已經比較嚴重,不少類都沒有被打上這個標誌。
如何打包補丁包:安全

    1. 空間在正式版本發佈的時候,會生成一份緩存文件,裏面記錄了全部class文件的md5,還有一份mapping混淆文件。
    2. 在後續的版本中使用-applymapping選項,應用正式版本的mapping文件,而後計算編譯完成後的class文件的md5和正式版本進行比較,把不相同的class文件打包成補丁包。
      備註:該方案如今也應用到咱們的編譯過程中,編譯不須要從新打包dex,只須要把修改過的類的class文件打包成patch dex,而後放到sdcard下,那麼就會讓改變的代碼生效。

2、QQ空間熱修復原理深刻解析

1、背景

 

App的上線發佈是咱們程序猿開心的事情,證實着一段時間來成果的進步和展示。可是隨着App的上線手機App市場,接下來的更新維護工做便成了」屢見不鮮「。尤爲是在創業公司,隨着業務等不穩定性因素,前期App的更新工做更爲頻繁,可能兩天一小改,三天一大改的狀況常常發生。微信

那麼應對版本更新的同時,須要咱們不斷將新版本上線,並下發到用戶,此時兩個典型的問題發生了:cookie

(1)發版的週期過長app

(2)用戶的App版本更新進度緩慢

因此,在傳統App的開發模式下,須要一種手段來改變當前存在的問題。若是存在一種方案能夠在不發版的前提下也能夠修復線上App的Bug,那麼以上兩個問題就都得以解決。此時一系列的第三方庫撲面而來,阿里的AndFix、騰訊的Qzone修復、以及近期開源的微信Tinker應運而生。

關於各類熱更新庫的使用,網上已經有不少的博文來介紹。本篇博客着重和你們分享一下關於QQ空間熱更新的原理解析。

 

2、熱修復原理

 

關於原理的分析,大體分爲以下模塊:

(1)熱修復機制的產生

(2)Android類加載機制

 

一、熱修復機制的產生

 

隨着App業務不斷疊加,以及第三方庫的多種依賴,相信不少人某天運行程序忽然出現以下異常:

 1 java.lang.IllegalArgumentException: method ID not in [0, 0xffff]: 65536  
 2 at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:501)  
 3 at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:282)  
 4 at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:490)  
 5 at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:167)  
 6 at com.android.dx.merge.DexMerger.merge(DexMerger.java:188)  
 7 at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:439)  
 8 at com.android.dx.command.dexer.Main.runMonoDex(Main.java:287)  
 9 at com.android.dx.command.dexer.Main.run(Main.java:230)  
10 at com.android.dx.command.dexer.Main.main(Main.java:199)  
11 at com.android.dx.command.Main.main(Main.java:103)  

 

從異常信息中,咱們不難發現:method ID not in 65536。而且大體能夠看出是在dex層跑出的異常。什麼意思呢?

咱們編寫的Java業務代碼爲.java類型文件,當咱們編譯運行一個完整的App項目時,系統會執行以下流程:

 

.Java  --> .class  --> dex  -->  (odex ) --> Apk

 

當class文件被打包成一個dex文件時,因爲dex文件的限制,方法的ID爲short型,因此一個dex文件存放的方法數最多爲65536。超過了該數,系統就會拋出上面所述的異常。爲了解決這個問題,Google爲咱們提供了multidex解決方案,即把dex文件分爲主、副兩類。系統只加載第一個dex文件(主dex),剩餘的dex文件(副dex)在Application中的onCreate方法中,以資源的方式被加載到系統的ClassLoader。能夠理解爲:一個APK能夠包含多個dex文件。這樣就解決了65536問題的同時。也爲熱修復提供了實現方案:將修復後的dex文件下發到客戶端(App),客戶端在啓動後就會加載最新的dex文件。

關於如何實現加載最新dex文件,咱們還須要瞭解下Android Davilk虛擬機的類加載流程。

 

二、Android類加載機制

 

Android虛擬機對於類的加載機制爲:同一個類只會加載一次。因此要實現熱修復的前提就是:讓下發到客戶端的補丁包類要比以前存在bug的類優先加載。相似於一種「替換」的解決。如何實現優先加載呢?咱們先來了解下Davilk虛擬機的類加載方式。

Java虛擬機JVM的類加載是:ClassLoader。一樣Android系統提供了兩種類加載方式:

(1)DexClassLoader

(2)PathClassLoader

 

首先從源碼中深刻:

 

libcore/dalvik/src/main/java/dalvik/system/

(1)DexClassLoader源碼: 

  • package dalvik.system;
    19import java.io.File;
     
    36public class DexClassLoader extends BaseDexClassLoader {
    37    /**
    38     * Creates a {@code DexClassLoader} that finds interpreted and native
    39     * code.  Interpreted classes are found in a set of DEX files contained
    40     * in Jar or APK files.
    41     *
    42     * <p>The path lists are separated using the character specified by the
    43     * {@code path.separator} system property, which defaults to {@code :}.
    44     *
    45     * @param dexPath the list of jar/apk files containing classes and
    46     *     resources, delimited by {@code File.pathSeparator}, which
    47     *     defaults to {@code ":"} on Android
    48     * @param optimizedDirectory directory where optimized dex files
    49     *     should be written; must not be {@code null}
    50     * @param libraryPath the list of directories containing native
    51     *     libraries, delimited by {@code File.pathSeparator}; may be
    52     *     {@code null}
    53     * @param parent the parent class loader
    54     */
    55    public DexClassLoader(String dexPath, String optimizedDirectory,
    56            String libraryPath, ClassLoader parent) {
    57        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    58    }
    59}

     

(2)PathClassLoader:  

25 public class PathClassLoader extends BaseDexClassLoader {
26    /**
27     * Creates a {@code PathClassLoader} that operates on a given list of files
28     * and directories. This method is equivalent to calling
29     * {@link #PathClassLoader(String, String, ClassLoader)} with a
30     * {@code null} value for the second argument (see description there).
31     *
32     * @param dexPath the list of jar/apk files containing classes and
33     * resources, delimited by {@code File.pathSeparator}, which
34     * defaults to {@code ":"} on Android
35     * @param parent the parent class loader
36     */
37    public PathClassLoader(String dexPath, ClassLoader parent) {
38        super(dexPath, null, null, parent);
39    }
40
41    /**
42     * Creates a {@code PathClassLoader} that operates on two given
43     * lists of files and directories. The entries of the first list
44     * should be one of the following:
45     *
46     * <ul>
47     * <li>JAR/ZIP/APK files, possibly containing a "classes.dex" file as
48     * well as arbitrary resources.
49     * <li>Raw ".dex" files (not inside a zip file).
50     * </ul>
51     *
52     * The entries of the second list should be directories containing
53     * native library files.
54     *
55     * @param dexPath the list of jar/apk files containing classes and
56     * resources, delimited by {@code File.pathSeparator}, which
57     * defaults to {@code ":"} on Android
58     * @param libraryPath the list of directories containing native
59     * libraries, delimited by {@code File.pathSeparator}; may be
60     * {@code null}
61     * @param parent the parent class loader
62     */
63    public PathClassLoader(String dexPath, String libraryPath,
64            ClassLoader parent) {
65        super(dexPath, null, libraryPath, parent);
66    }
67}

從源碼能夠看出,DexClassLoader和PathClassLoaderr繼承自BaseDexClassLoader。

(1)PathClassLoader能夠操做本地文件系統的文件列表或目錄中的classes。PathClassLoader負責加載系統類和主Dex中的類。

(2)DexClassLoader是一個能夠從包含classes.dex實體的.jar或.apk文件中加載classes的類加載器。DexClassLoader負責加載其餘dex文件(副dex)中的類。

既然是類加載器,必然存在類加載方法,繼續查看源碼,能夠發現BaseDexClassLoader提供了findClass方法用於加載類:

(1)BaseDexClassLoader源碼:

17 package dalvik.system;
18
19 import java.io.File;
20 import java.net.URL;
21 import java.util.ArrayList;
22 import java.util.Enumeration;
23 import java.util.List;
 
29 public class BaseDexClassLoader extends ClassLoader {
30    private final DexPathList pathList;
 
45    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
46            String libraryPath, ClassLoader parent) {
47        super(parent);
48        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
49    }
50
51    @Override
52    protected Class<?> findClass(String name) throws ClassNotFoundException {
53        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
54        Class c = pathList.findClass(name, suppressedExceptions);
55        if (c == null) {
56            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
57            for (Throwable t : suppressedExceptions) {
58                cnfe.addSuppressed(t);
59            }
60            throw cnfe;
61        }
62        return c;
63    }

 

在findClass方法中,又調用了DexPathList對象的findClass方法,DexPathList源碼以下:

  

17 package dalvik.system;
18
19 import java.io.File;
20 import java.io.IOException;
21 import java.net.MalformedURLException;
22 import java.net.URL;
23 import java.util.ArrayList;
24 import java.util.Arrays;
25 import java.util.Collections;
26 import java.util.Enumeration;
27 import java.util.List;
28 import java.util.zip.ZipFile;
29 import libcore.io.ErrnoException;
30 import libcore.io.IoUtils;
31 import libcore.io.Libcore;
32 import libcore.io.StructStat;
33 import static libcore.io.OsConstants.*;
 
48/*package*/ final class DexPathList {
49    private static final String DEX_SUFFIX = ".dex";
50    private static final String JAR_SUFFIX = ".jar";
51    private static final String ZIP_SUFFIX = ".zip";
52    private static final String APK_SUFFIX = ".apk";
53
54    /** class definition context */
55    private final ClassLoader definingContext;
56
57    /**
58     * List of dex/resource (class path) elements.
59     * Should be called pathElements, but the Facebook app uses reflection
60     * to modify 'dexElements' (http://b/7726934).
61     */
62    private final Element[] dexElements;
63
64    /** List of native library directories. */
65    private final File[] nativeLibraryDirectories;
305    /**
306     * Finds the named class in one of the dex files pointed at by
307     * this instance. This will find the one in the earliest listed
308     * path element. If the class is found but has not yet been
309     * defined, then this method will define it in the defining
310     * context that this instance was constructed with.
311     *
312     * @param name of class to find
313     * @param suppressed exceptions encountered whilst finding the class
314     * @return the named class or {@code null} if the class is not
315     * found in any of the dex files
316     */
317    public Class findClass(String name, List<Throwable> suppressed) {
318        for (Element element : dexElements) {
319            DexFile dex = element.dexFile;
320
321            if (dex != null) {
322                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
323                if (clazz != null) {
324                    return clazz;
325                }
326            }
327        }
328        if (dexElementsSuppressedExceptions != null) {
329            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
330        }
331        return null;
332    }

 

  1.   
能夠看到,在findClass方法中,遍歷Element元素數組,從每個dex文件中查找目標類,在找到後即返回並中止遍歷。
Element爲DexPathList的靜態內部類,而後取出Element對象中的DexFile,調用DexFile的loadClassBinaryName方法,繼續來看DexFile源碼:
  
package dalvik.system;

19 import java.io.File;
20 import java.io.FileNotFoundException;
21 import java.io.IOException;
22 import java.util.ArrayList;
23 import java.util.Enumeration;
24 import java.util.List;
25 import libcore.io.ErrnoException;
26 import libcore.io.Libcore;
27 import libcore.io.StructStat;
28
29/**
30 * Manipulates DEX files. The class is similar in principle to
31 * {@link java.util.zip.ZipFile}. It is used primarily by class loaders.
32 * <p>
33 * Note we don't directly open and read the DEX file here. They're memory-mapped
34 * read-only by the VM.
35 */
36 public final class DexFile {
37    private int mCookie;
38    private final String mFileName;
39    private final CloseGuard guard = CloseGuard.get();
207    /**
208     * See {@link #loadClass(String, ClassLoader)}.
209     *
210     * This takes a "binary" class name to better match ClassLoader semantics.
211     *
212     * @hide
213     */
214    public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
215        return defineClass(name, loader, mCookie, suppressed);
216    }
218    private static Class defineClass(String name, ClassLoader loader, int cookie,
219                                     List<Throwable> suppressed) {
220        Class result = null;
221        try {
222            result = defineClassNative(name, loader, cookie);
223        } catch (NoClassDefFoundError e) {
224            if (suppressed != null) {
225                suppressed.add(e);
226            }
227        } catch (ClassNotFoundException e) {
228            if (suppressed != null) {
229                suppressed.add(e);
230            }
231        }
232        return result;

DexFile類中,loadClassBinaryName方法中調用了defineClass方法,該方法直接經過defineClassNative執行Android原生層代碼...到此爲止,整個加載流程就走完了。

因此,要實現熱修復的就必需要在DexPathList中遍歷Element元素時,讓補丁dex在Element數組中的爲止優先於原有已存在的dex。這樣,當系統遍歷dexElement時,就能夠加載最新補丁dex,實現dex的 「替換」。
引用 安卓App熱補丁動態修復技術介紹歷文章中的描述:
  

  

遍歷dexElement:優先補丁dex + 替換bug dex  = 熱修復 
以上就是QQ空間熱修復的核心解決方案。爲了進一步深刻熱修復原理,接下來咱們以代碼爲例,具體看看是如何實現的。

 

3、核心實現


(1)編寫demo代碼,工程名稱爲QQHotUpdate
1 /**
2  * Created by Song on 2017/5/15.
3  */
4 public class Cal {
5     public float calculate() {
6         // 很明顯,會有算數異常拋出
7         return 1 / 0;
8     }
9 }

  

 1 public class MainActivity extends AppCompatActivity {
 2  
 3     private Cal cl;
 4     @Override
 5     protected void onCreate(Bundle savedInstanceState) {
 6         super.onCreate(savedInstanceState);
 7         setContentView(R.layout.activity_main);
 8         cl = new Cal();
 9     }
10  
11     /**
12      * 點擊按鈕測試
13      * @param view
14      */
15     public void cal(View view) {
16         cl.calculate();
17     }

 

上面咱們定義了測試用例代碼。很明顯,在調用Cal的calculate方法時系統會出現異常,運行程序以下: 

  

(2)打補丁包,顧名思義,就是修補問題後的包。第一步須要先修改程序,並從新rebuild。
 
1 /**
2  * Created by Song on 2017/5/15.
3  */
4 public class Cal {
5     public float calculate() {
6         // 修改後的,不存在任何問題
7         return 1 / 1;
8     }
9 }

補丁包實際上是一個dex文件。dex文件的造成過程爲:.class --> jar --> dex。因此,先要將class文件打包爲jar。

從新編譯後的class文件在app / build / intermediates / classes / debug / 包名 / ...
將目錄copy到桌面,刪除沒必要要的文件,留下Cal.class便可。而後執行以下命令:
jar -cvf pat.jar com上述命令將com目錄下的文件打包爲pat.jar文件。
接下來須要將jar文件打包爲dex文件,咱們使用SDK24.0版本下的dx.bat,進入該目錄,在dos下執行:
dx --dex --output=patch_dex.jar C:/Users/Song/Desktop/pat.jar最終打包出的dex文件爲patch_dex.jar。

(3)加載補丁

打開Android Device Monitor,將補丁放入SD卡根目錄:
  
選擇右上角第二個按鈕,將patch_dex.jar導入到模擬器。
建立Application,在Application中加載補丁文件:
 1 /**
 2  * Created by Song on 2017/5/15.
 3  */
 4 public class MainApplication extends Application {
 5  
 6     @Override
 7     public void onCreate() {
 8         super.onCreate();
 9         // 獲取補丁 執行注入
10         
11         String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath().concat("/patch_dex.jar");
12         File file = new File(dexPath);
13         if (file.exists()) {
14             Log.e("-----","開始.................");
15             inject(dexPath);
16         }
17     }
18  
19     /**
20      * 要注入的dex的路徑
21      */
22     private void inject(String path) {
23         try {
24             // 獲取classes的dexElements
25             Class<?> cl = Class.forName("dalvik.system.BaseDexClassLoader");
26             Object pathList = getField(cl, "pathList", getClassLoader());
27             Object baseElements = getField(pathList.getClass(), "dexElements", pathList);
28             // 獲取patch_dex的dexElements(須要先加載dex)
29             String dexopt = getDir("dexopt", 0).getAbsolutePath();
30             DexClassLoader dexClassLoader = new DexClassLoader(path, dexopt, dexopt, getClassLoader());
31             Object obj = getField(cl, "pathList", dexClassLoader);
32             Object dexElements = getField(obj.getClass(), "dexElements", obj);
33             // 合併兩個Elements
34             Object combineElements = combineArray(dexElements, baseElements);
35             // 將合併後的Element數組從新賦值給app的classLoader
36             setField(pathList.getClass(), "dexElements", pathList, combineElements);
37         } catch (ClassNotFoundException e) {
38             e.printStackTrace();
39         } catch (IllegalAccessException e) {
40             e.printStackTrace();
41         } catch (NoSuchFieldException e) {
42             e.printStackTrace();
43         }
44     }
45  
46     /**
47      * 經過反射獲取對象的屬性值
48      */
49     private Object getField(Class<?> cl, String fieldName, Object object) throws NoSuchFieldException, IllegalAccessException {
50         Field field = cl.getDeclaredField(fieldName);
51         field.setAccessible(true);
52         return field.get(object);
53     }
54  
55     /**
56      * 經過反射設置對象的屬性值
57      */
58     private void setField(Class<?> cl, String fieldName, Object object, Object value) throws NoSuchFieldException, IllegalAccessException {
59         Field field = cl.getDeclaredField(fieldName);
60         field.setAccessible(true);
61         field.set(object, value);
62     }
63  
64     /**
65      * 經過反射合併兩個數組
66      */
67     private Object combineArray(Object firstArr, Object secondArr) {
68         int firstLength = Array.getLength(firstArr);
69         int secondLength = Array.getLength(secondArr);
70         int length = firstLength + secondLength;
71  
72         Class<?> componentType = firstArr.getClass().getComponentType();
73         Object newArr = Array.newInstance(componentType, length);
74         for (int i = 0; i < length; i++) {
75             if (i < firstLength) {
76                 Array.set(newArr, i, Array.get(firstArr, i));
77             } else {
78                 Array.set(newArr, i, Array.get(secondArr, i - firstLength));
79             }
80         }
81         return newArr;
82     }
83 }

分析:

(1)在補丁包存在的狀況下,向App的BaseDexClassLoader的dexElements下注入補丁。
(2)經過自己BaseDexClassLoader,利用反射獲取classes的dexElements。
(3)加載補丁包的dexElements。
(4)合併兩個補丁包,生成新的dexElements。
(5)將新的dexElements從新設置到App下的BaseDexClassLoader。

重點在合併代碼上,能夠發現,在合併的過程當中,咱們將新的補丁文件設置到了最前面。從上面原理部分咱們知道,相同文件只會加載一次!當虛擬機優先加載了最前面的補丁包後,遇到相同文件就不會再重複加載。這就達到了修復的做用。
ok,執行代碼,等待驚喜.... 麻蛋,又出現異常:
 Class ref in pre-verified class resolved to unexpected implementation

百度後發現原來是由於類校驗產生的問題:
    (1)在apk安裝的時候系統會將dex文件優化成odex文件,在優化的過程當中會涉及一個預校驗的過程。
    (2)若是一個類的static方法,private方法,override方法以及構造函數中引用了其餘類,並且這些類都屬於同一個dex文件,此時該類就會被打上CLASS_ISPREVERIFIED。
    (3)若是在運行時被打上CLASS_ISPREVERIFIED的類引用了其餘dex的類,就會報錯。
    (4)因此MainActivity的onCreate()方法中引用另外一個dex的類就會出現上文中的問題。
    (5)正常的分包方案會保證相關類被打入同一個dex文件。
    (6)想要使得patch能夠被正常加載,就必須保證類不會被打上CLASS_ISPREVERIFIED標記。而要實現這個目的就必需要在分完包後的class中植入對其餘dex文件中類的引用。

    要在已經編譯完成後的類中植入對其餘類的引用,就須要操做字節碼,慣用的方案是插樁。常見的工具備javaassist,asm等。其實QQ空間熱修復也是利用的插樁的方式來實現了在apk文件安裝的時候不被打上CLASS_ISPREVERIFIED標記。完成熱修復工做。關於插樁此處就再也不贅述了,這裏推薦給你們一篇教程: Android熱修復技術-插樁分析

 3、更詳細介紹:

介紹

Qzone 超級補丁技術基於dex分包方案,使用了多dex加載(multidex)的原理,大體的過程就是:把BUG方法修復之後,放到一個單獨的dex文件,而後插入到dexElements數組的最前面,讓虛擬機去加載修復完後的方法。

該方案的靈感來源? 
沒錯就是類加載機制,相信大部分同窗都對它有所瞭解吧。

ClassLoader 類加載機制

Android應用程序本質上使用的是java開發,使用標準的java編譯器編譯出Class文件,和普通的java開發不一樣的地方是把class文件再從新打包成dex類型的文件,這種從新打包會對Class文件內部的各類函數表、變量表等進行優化,最終產生了odex文件。odex文件是一種通過android打包工具優化後的Class文件,所以加載這樣特殊的Class文件就須要特殊的類裝載器,因此android中提供了DexClassLoader類。

類圖: 
clipboard_mh1534131797938.jpg

Android使用的是Dalvik虛擬機裝載class文件,因此classloader不一樣於java默認類庫rt.jar包中java.lang.ClassLoader, 能夠看到android中的classloader作了些修改,可是原理仍是差很少的。 
學過java的同窗都知道, 類加載器是採用雙親委派機制來進行類加載的。

雙親委託模式是什麼?

某個特定的類加載器在接到加載類的請求時,首先將加載任務委託給父類加載器,依次遞歸,若是父類加載器能夠完成類加載任務,就成功返回;只有父類加載器沒法完成此加載任務時,才本身去加載。

爲何要用雙親委託模式?

  1. 能夠避免重複加載,當父加載器已經加載了該類的時候,就沒有必要子ClassLoader再加載一次。
  2. 安全性考慮,防止核心API庫被隨意篡改。咱們試想一下,若是不使用這種委託模式,那咱們就能夠隨時使用自定義的String來動態替代java核心api中的定義類型,這樣會存在很是大的安全隱患。

雙親委派機制 從ClassLoader.java 源代碼能夠清晰的看出來: 
ClassLoader.java 
classloader.png
流程大概以下: 
1.判斷類是否已經加載過; 
2.父類加載器優先加載; 
3.parent爲null,則調用BootstrapClassLoader進行加載 ; 
4.若是class依舊沒有找到,則調用當前類加載器的findClass方法進行加載;

BaseDexClassLoader.java 
baseDexClassLoader.png

DexPathList.java 
dexpathllist.png

DexFile.java(\dalvik\dx\src\com\android\dx\dex\file\DexFile.java) 
dexfile.png

defineClassNative(android4.4版本,區分ART 和Dalvik兩種狀況) 
1.ART 環境 [art\runtime\native\dalvik_system_DexFile.cc] 
defineclassnative.png

2.Dalvik 環境 [\dalvik\vm\native\dalvik_system_DexFile.cpp ] 
dvmdefine.png

(注:dvmDefineClass函數則是類加載機制中最爲核心的邏輯,因爲和本文深刻探索的方向關聯性不強,就不做深究了。源碼在 dalvik2/vm/oo/Class.cpp中,有興趣可自行研究。)

原理分析

從以上類加載機制的源碼中咱們能夠分析出,當找類的時候,會按順序遍歷dex文件,而後從當前遍歷的dex文件中找類,若是找類則返回,若是找不到繼續從下一個dex文件查找。理論上,若是在不一樣的dex中有相同的類存在,那麼會優先選擇排在前面的dex文件的類,Qzone方案的靈感就是從上述的DexPathList類中的for循環體而來。 
qzone1.png

在此基礎上,Qzone 團隊構想了熱補丁的方案,把有問題的類打包到一個dex(patch.dex)中去,而後把這個dex插入到Elements的最前面,以下圖: 
qzone2.png

若是懂拆分dex的原理的話,你們應該很快就會實現該方案。若是沒有拆分dex的項目的話,能夠參考一下谷歌的multidex方案實現,而後在插入數組的時候,把補丁包插入到最前面去。

當patch.dex中包含Main.class時就會優先加載,在後續的DEX中遇到Main.class的話就會直接返回而不去加載,這樣就達到了修復的目的。看似問題很簡單,輕鬆的搞定了,Qzone一開始按照以上思路進行了實踐,但在實際操做中,出現了 unexpected DEX 的異常。這個問題是由於在Dalvik環境中,類被打上CLASS_ISPREVERIFIED的標誌,主動拋出異常報錯。

爲何系統要給類打上CLASS_ISPREVERIFIED的標誌?

咱們知道,在APK安裝時,虛擬機須要將classes.dex優化成odex文件,而後纔會執行。在這個過程當中,會進行類的verify操做,爲了提升運行性能,若是調用關係的類都在同一個DEX中的話,就會被打上CLASS_ISPREVERIFIED的標誌,而後寫入odex文件,代表它沒有被其餘Dex的類引用。

規避 Dalvik 下 「unexpected DEX」 的異常。 
以下是 dalvik 的一段源碼,當補丁安裝後,首次使用到補丁裏的類時會調用到這裏, 源代碼以下:

[dalvik/vm/oo/Resolve.cpp] 
resolve.png

從代碼邏輯咱們能夠看出,須要同時知足代碼中標出來的三個條件,纔會出現異常,這三個條件的含義以下: 
threeCase.png

所以,想要避免補丁類加載時發生 「unexpected DEX 」 的異常,則須要從以上三個地方來入手。

Qzone 的超級補丁方案採用的是經過繞過這裏的第二個判斷來避免報錯的。若是一個類被打上了CLASS_ISPREVERIFIED標誌,那麼就會進行dex的校驗。而要避免報錯,首先得弄清楚它是什麼條件下才會被打上。繼續搜索源代碼,發如今DexPrepare.cpp找到了以下代碼:

[dalvik\vm\analysis\DexPrepare.cpp] 
dexprepare.png

這段代碼是dex轉化成odex(dexopt)的代碼中的一段,咱們知道當一個apk在安裝的時候,apk中的classes.dex會被虛擬機(dexopt)優化成odex文件,而後纔會拿去執行。 
虛擬機在啓動的時候,會有許多的啓動參數,其中一項就是verify選項,當verify選項被打開的時候,上面doVerify變量爲true,那麼就會執行dvmVerifyClass進行類的校驗,若是dvmVerifyClass函數校驗類成功,那麼這個類會被打上CLASS_ISPREVERIFIED的標誌。

DexClassDef 結構體代碼: 
struct DexClassDef { 
u4 classIdx; //類的類型, DexTypeId中的索引下標 
u4 accessFlags; //類的訪問標誌 
u4 superclassIdx; //父類類型, DexTypeId中的索引下標 
u4 interfacesOff; //接口偏移, 指向DexTypeList的結構 
u4 sourceFileIdx; //源文件名, DexStringId中的索引下標 
u4 annotationsOff; //註解偏移, 指向DexAnnotationsDirectoryItem的結構 
u4 classDataOff; //類數據偏移, 指向DexClassData的結構 
u4 staticValuesOff; //類靜態數據偏移, 指向DexEncodedArray的結構 
};

而具體的校驗過程,即dvmVerifyClass函數是什麼樣子的呢?咱們繼續往下探索。 
代碼在DexVerify.cpp中,以下: 
[dalvik\vm\analysis\DexVerify.cpp] 
dvmverifyclass.png

該方法作了三件事情: 
1. 是否已被校驗過? 
2. 驗證clazz->directMethods方法,directMethods包含了如下方法: 
● static方法 
● private方法 
● 構造方法 
3. clazz->virtualMethods。虛函數=override方法?

歸納一下就是,只要在static方法,private方法,構造方法,override方法中直接引用了其餘dex中的類,那麼這個類就不會被打上CLASS_ISPREVERIFIED標記。也就是說若是以上方法中直接引用到的類(第一層級關係,不會進行遞歸搜索)和clazz都在同一個dex中的話,那麼這個類就會被打上CLASS_ISPREVERIFIED。

搞清了前因後果,因此就能夠從這些地方入手。最終Qzone的方案是往全部補丁類的構造函數裏面插入了一段代碼,來引用另一個dex的類,防止類被打上CLASS_ISPREVERIFIED標誌。代碼以下:

if (ClassVerifier.PREVENT_VERIFY) {
    System.out.println(AntilazyLoad.class);
}

  

Qzone方案的大體實現流程以下:

打補丁包: 
1.在正式版本發佈的時候,會生成一份緩存文件,裏面記錄了全部class文件的md5,還有一份mapping混淆文件。 
2. 在後續的版本中使用-applymapping選項,應用正式版本的mapping文件,而後計算編譯完成後的class文件的md5和正式版本進行比較,把不相同的class文件打包成補丁包。

Hook及加載patch操做: 
1. 打包過程當中,會往全部補丁類的構造函數裏面插一段代碼。 
2. 其中AntilazyLoad類會被打包成單獨的hack.dex,當安裝apk的時候,patch.dex內的類都會引用一個在不相同dex中的AntilazyLoad類,這樣就防止了類被打上CLASS_ISPREVERIFIED的標誌,只要沒被打上這個標誌的類均可以進行打補丁操做。 
3. 先加載進來AntilazyLoad類,否則AntilazyLoad類會被標記爲不存在,即便後續加載了hack.dex包也於事無補。 
4. 獲取到當前應用的Classloader,即爲BaseDexClassloader。 
5. 經過反射獲取到它的DexPathList屬性對象pathList。 
6. 經過反射調用pathList的dexElements方法把補丁包patch.dex轉化爲Element[]。 
7. 兩個Element[]進行合併,把patch.dex放到最前面去。 
8. 加載Element[],達到修復目的。

該方案之因此選擇構造函數進行插入代碼,是由於它不增長方法數,一個類即便沒有顯式的構造函數,也會有一個隱式的默認構造函數。 
細節:Qzone使用的是在字節碼插入代碼,而不是源代碼插入,使用的是java assist庫來進行字節碼插入的。

互動問題:思考一下,除了經過防止補丁類被打上CLASS_ISPREVERIFIED標誌,咱們還能夠想到有哪些方式來解決Dalvik下的 「unexpected DEX」 異常問題?

Qzone方案總結

優點: 
1.沒有合成整包(和微信Tinker比起來),輸出產物比較小,比較靈活。 
2.能夠實現類替換,兼容性較高。(某些三星手機不起做用)

不足: 
1.不支持即時生效,必須經過重啓才能生效。 
2.爲了實現修復這個過程,必須在應用中加入兩個dex, dalvikhack.dex中只有一個類,對性能影響不大,可是對於patch.dex來講,修復的類到了必定數量,就須要花很多的時間加載。對大型應用來講,啓動耗時增長2s以上是很難接受的事。 
3.在ART模式下,若是類修改告終構,就會出現內存錯亂的問題。爲了解決這個問題,就必須把全部相關的調用類、父類子類等等所有加載到patch.dex中,這會致使ART下的補丁包異常的大,進一步增長應用啓動加載的時候,耗時更加嚴重。

其中第二點不足,即性能沒法提高的緣由: 
插樁的解決方案會影響到運行時性能的緣由在於:app 內的全部補丁類都預埋引用一個獨立 dex 的空類,致使安裝 dexopt 階段的 preverify 失敗,運行時將再次 verify+optimize。 
另外即便後期發佈版本實際上無需發佈補丁,咱們也須要預埋插樁的邏輯,這自己也是不合理的一點,因此確實有必要去探索新的方向,既保留補丁的能力,同時去掉插樁帶來的負面影響。

第三點不足,即ART模式下補丁包異常大的緣由: 
ART(Android Runtime)是Android在4.4版本中引入的新虛擬機環境,在5.0版本正式取代了Dalvik VM。ART環境下,App安裝時其包含的Dex文件將被dex2oat預編譯成目標平臺的機器碼,從而提升了App的運行效率。在這個預編譯過程當中,dex2oat對目標代碼的優化過程與Dalvik VM下的dexopt有較大區別,尤爲是在5.0版本之後ART環境下新增的方法內聯優化,因爲方法內聯改變了本來的方法分佈和調用流程。

方法內聯之因此會致使優先加載補丁Dex的方案出現問題,本質上是由於補丁Dex只覆蓋了舊Dex裏的一部分類,一旦被覆蓋的類的方法被內聯到了調用者裏,則加載類的過程仍是正常的,即從補丁Dex里加載了新版本的類。但因爲內聯,執行流程並未跳轉到新的方法裏,因而全部關於新版本的類的方法、成員、字符串的查找用的就都是舊方法裏的索引了。所以,在ART模式下,若是類修改告終構,就會出現內存錯亂的問題。爲了解決這個問題,就必須把全部相關的調用類、父類子類等等所有加載到patch.dex中,這會致使ART下的補丁包異常的大,進一步增長應用啓動加載的時候,耗時更加嚴重。

參考文章: 
《安卓App熱補丁動態修復技術介紹》 - QQ空間開發團隊 
《QFix探索之路》 - 手Q熱補丁輕量級方案

相關文章
相關標籤/搜索