Android每週一輪子:Nvwa(熱修復)

Jensen95發表於2018-04-19

前言

(廢話) 最近發現了一個問題,一些平時部落格寫的很多的程式設計師,反倒在日常的工作中,卻是業務寫的很一般,只會擺理論的人,甚至還跑出來教別人如何找工作,如何做架構,其實自己都沒搞明白。但是受眾的分層導致了輸出者的分層,教出清北學生的老師並不一定來自比這更好的學校,因此,對於部落格的輸出,一個是作為對自己學習的一個記錄,非常仔細的梳理可以非常方便的讓我們在需要的時候拿起來,再就是即使這個知識現在不用,無法深入下去,可能會遺忘的比較快,其細節可能會忘記,但是核心思想我們還是有印象的,再就是站在讀者的角度上來看,由於讀者的差異性,我們的部落格在保證無誤的前提下,一定是可以幫助到很多同學的,本著這些原則,一週一篇的輸出,希望可以堅持下去。

(正題) 最近在做熱修復的相關調研,接著部落格的專題,可以好好的發一波文章了,今天要分析的是ClassLoader方案最簡單的一個Nvwa,本篇文章將會從class查詢過程到Nvwa的實現,以及在實現的時候解決了什麼問題,這幾個方面展開,逐步講解。

基礎使用

初始化

Nuwa.init(this);
複製程式碼

裝載補丁包

Nuwa.loadPatch(this,patchFile)
複製程式碼

原始碼分析

類的查詢

類載入

對於類的載入,在通過DexClassLoader進行載入的時候,通過DexPathList進行載入,其中維護了一個Element的陣列,在查詢的類的時候,會遍歷陣列查詢類,如果找到則返回。對於陣列遍歷查詢的程式碼如下所示。

public Class findClass(String name) {
    for (Element element : dexElements) {
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext);
            if (clazz != null) {
                return clazz;
            }
        }
    }
    return null;
}
複製程式碼
  • Nvwa的初始化
public static void init(Context context) {
    File dexDir = new File(context.getFilesDir(), DEX_DIR);
    dexDir.mkdir();

    String dexPath = null;
    try {
        //拷貝Asset目錄下的Hack.apk到指定路徑
        dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir);
    } catch (IOException e) {
        e.printStackTrace();
    }
    //從拷貝後的指定路徑載入apk
    loadPatch(context, dexPath);
}
複製程式碼

在nvwaw的init方法中進行的操作是將asset中的一個hack.apk拷貝出來,然後將其作為補丁進行裝載。

  • 補丁的載入
public static void loadPatch(Context context, String dexPath) {

    if (context == null) {
        return;
    }
    if (!new File(dexPath).exists()) {
        return;
    }
    File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR);
    dexOptDir.mkdir();
    try {
        DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath());
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製程式碼

由上面程式碼,可以看出核心的實現在對DexUtilsinjectDexAtFirst呼叫上。

public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
    DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
    Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
    Object newDexElements = getDexElements(getPathList(dexClassLoader));
    Object allDexElements = combineArray(newDexElements, baseDexElements);
    Object pathList = getPathList(getPathClassLoader());
    ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
}
複製程式碼

將兩個Dex進行合併,將補丁dex塞在陣列的前面,然後通過反射的方式設定進去,通過這種方式,根據上面的類載入邏輯,可以知道,對於類的載入是從陣列的最開始的位置進行查詢載入的,當前面的dex查詢到相應的類之後,就會停止後面的查詢,這樣,我們通過補丁的替換的類就會生效。

private static Object combineArray(Object firstArray, Object secondArray) {
    Class<?> localClass = firstArray.getClass().getComponentType();
    int firstArrayLength = Array.getLength(firstArray);
    int allLength = firstArrayLength + Array.getLength(secondArray);
    Object result = Array.newInstance(localClass, allLength);
    for (int k = 0; k < allLength; ++k) {
        if (k < firstArrayLength) {
            Array.set(result, k, Array.get(firstArray, k));
        } else {
            Array.set(result, k, Array.get(secondArray, k - firstArrayLength));
        }
    }
    return result;
}
複製程式碼

存在問題

通過上述的方式,我們將差異補丁單獨打一個包,然後進行下發,從而使得我們的類得到修復。但是在這樣實現的時候,存在一個問題,就是 在Dalvik 虛擬機器對於dex的一個優化。Dalvik 虛擬機器在啟動的時候,會有許多的啟動引數,其中有一項就是verify,當verify被開啟的時候,doVerify變數為true,則進行類的校驗(dvmVerifyClass方法呼叫)。若校驗成功,則這個類會被打上標記:CLASS_ISPREVERIFIED。

這麼做,是防止外部DEX注入的一個安全方案,即保證執行期的Class與其直接引用類之間所在的DEX關係要與安裝時候一致,也是為了防止類被篡改校驗類的合法性。Dalvik 虛擬機器在安裝期間,為Class 打上 CLASS_ISPREVERIFIED 是為了提高效能,下次使用時,則會省去校驗操作,提高訪問效率。dvm在執行期載入Class時候,會對其記憶體中對應的直接引用類進行校驗,如果該類存在與直接引用類所在的dex不是同一個,則直接報“pre-verification” 錯誤,該類無法載入。

由於這一個限制,導致我們的補丁包無法在被呼叫到的時候,就會丟擲異常,因此我們需要讓我們的補丁包,如何通過這次校驗, 不被打上CLASS_ISPREVERIFIED,這樣,我們的補丁包在被載入的時候,就不會丟擲異常了。

nvwa採取的方式就是插樁的方式,在每一個類裡去引用到另一個獨立dex中的類,也會是在初始化的時候載入的hack.apk中的Hack.class,通過這種方式,可以讓我們的類不會被打上這個標籤。這樣就可以繼續裝載其它Dex中的類。

插樁存在一個什麼問題呢?由於沒有打上驗證標籤,導致每個類的裝載的時候都進行驗證。

微信在對插裝和不插樁做的測試中。在連續載入700個50行的類,還有統計應用啟動耗時得到的資料,700個類:不插樁:84ms,插樁:685ms。啟動耗時:4934ms,7240ms。

結語

每週一更,由於最近業務需求較多,更新速度明顯慢了很多了,因此本篇分析的也是一很簡單的框架。接下來,將會逐步深入,分析一些更為複雜的熱修復方案框架。

參考資料

Dalvik中PreVerify問題

相關文章