1. 程式人生 > >Android熱修復之QQ空間與QFix方案

Android熱修復之QQ空間與QFix方案

前文介紹了阿里的Hotfix,它的熱修復思路是粗暴的底層方法指標的替換,今天我們來看看另一種思路,也就是QQ空間團隊提供的熱修復方案。要理解這個方案的思想,先要理解dex分包技術,這類文章很多,大家可以自己google研究學習,這裡通過簡單分析一下Android ClassLoader的原始碼來說一下這個問題。

我們知道除了BootClassLoader外,Android主要提供了兩個ClassLoader,一個是PathClassLoader,一個是DexClassLoader,這兩個類的原始碼裡其實除了繼承了BaseDexClassLoader覆寫了構造方法外,啥也沒幹,所以類載入的核心邏輯還是在父類中。我們看一下父類中的findClass方法:

@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 }

其實是呼叫了一個pathList的findClass方法,我們來看這個pathList是一個DexPathList類,裡面的findClass方法:

public Class findClass(String name, List<Throwable> suppressed) {  
    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; }

這個方法中有一個迴圈,遍歷了一個Element陣列,每次從element陣列中取出一個DexFile並執行了它的loadClassBinaryName方法實現類的載入,繼續跟進去看下這個DexFile:

public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
    return defineClass(name, loader, mCookie, this, suppressed);
    }

private static Class defineClass(String name, ClassLoader loader, Object cookie, DexFile dexFile, List<Throwable> suppressed) {
    Class result = null;
    try {
        result = defineClassNative(name, loader, cookie, dexFile);
    } catch (NoClassDefFoundError e) {
        if (suppressed != null) {
            suppressed.add(e);
        }
    } catch (ClassNotFoundException e) {
        if (suppressed != null) {
            suppressed.add(e);
        }
    }
    return result;
}

分析到這裡我們知道,Android類載入原始碼的大致邏輯為:遍歷一個裝載dex檔案(每個dex檔案實際上是一個DexFile物件)的陣列(Element陣列,Element是一個內部類),然後依次去載入所需要的class檔案,直到找到為止。而dex分包其實就是一個注入的解決方案,假如我們將第二個dex檔案放入Element陣列中,那麼在載入第二個dex包中的類時,就可以直接找到。我們根據這個邏輯寫一段注入的程式碼在應用的Application裡:

public String inject(String libPath) {  
    boolean hasBaseDexClassLoader = true;  
    try {  
        Class.forName("dalvik.system.BaseDexClassLoader");  
    } catch (ClassNotFoundException e) {  
        hasBaseDexClassLoader = false;  
    }  
    if (hasBaseDexClassLoader) {  
        PathClassLoader pathClassLoader = (PathClassLoader)sApplication.getClassLoader();  
        DexClassLoader dexClassLoader = new DexClassLoader(libPath, sApplication.getDir("dex", 0).getAbsolutePath(), libPath, sApplication.getClassLoader());  
        try {  
            Object dexElements = combineArray(getDexElements(getPathList(pathClassLoader)), getDexElements(getPathList(dexClassLoader)));  
            Object pathList = getPathList(pathClassLoader);  
            setField(pathList, pathList.getClass(), "dexElements", dexElements);  
            return "SUCCESS";  
        } catch (Throwable e) {  
            e.printStackTrace();  
            return android.util.Log.getStackTraceString(e);  
        }  
    }  
    return "SUCCESS";  
}   

這段程式碼通過反射獲取PathClassLoader中DexPathList中的Element陣列,此時這裡面只有apk裡的dex。然後讓DexClassLoader中去載入了我們新增的dex,並取出其中DexPathList中的Element陣列,將兩個Element數組合並之後,再將其賦值給PathClassLoader的Element陣列,到此,注入完畢。以上就是dex分包的基本思想,概括來說就是可以載入沒有打包到我們apk中的程式碼。那這個和我們的熱修復有啥關係呢?

我們繼續複習,classLoader有一個核心的載入邏輯叫做雙親委託機制,講人話就是:爹classLoader載入過某個類後,子classLoader遇到相同的類就不會再載入。對比我們上面的程式碼,也就是說一個ClassLoader可以包含多個dex檔案,每個dex檔案是一個Element,多個dex檔案排列成一個有序的陣列dexElements,當找類的時候,會按順序遍歷dex檔案。然後從當前遍歷的dex檔案中找類,如果找到則返回,如果找不到從下一個dex檔案繼續查詢。所以,如果在不同的dex中有相同的類存在,那麼會優先選擇排在前面的dex檔案的類,而後面的就不會再被載入。也就是說,後面有bug的類被前面的“修復”了。沒錯,這就是QQ空間的方案,做一個patch.apk進行動態載入,然後優先載入patch中的類。

但這個方案直接用會有一個問題,舉例來說:當apk中的某個類A,引用了另一個類B,而我們修復的是B,此時會崩潰,原因就是AB不是由同一個classLoader載入的,到底是哪裡丟擲了這個異常呢,看程式碼(/dalvik/vm/oo/Resolve.cpp):
這裡寫圖片描述

從程式碼上來看,如果兩個相關聯的類在不同的dex中就會報錯,但是拆分dex沒有報錯這是為什麼,原來這個校驗的前提是:

這裡寫圖片描述

如果引用者(也就是A)這個類被打上了CLASS_ISPREVERIFIED標誌,那麼就會進行dex的校驗。那麼這個標誌是什麼時候被打上去的?程式碼在DexPrepare.cpp中,就不貼了,這裡直接說結論:我們知道當一個apk在安裝的時候,apk中的classes.dex會被虛擬機器(dexopt)優化成odex檔案,然後才會拿去執行。虛擬機器在啟動的時候,會有許多的啟動引數,其中一項就是verify選項,當verify選項被開啟的時候,那麼就會執行dvmVerifyClass進行類的校驗,如果dvmVerifyClass校驗類成功,那麼這個類會被打上CLASS_ISPREVERIFIED的標誌,概括一下就是如果某個類中直接引用到的類(第一層級關係,不會進行遞迴搜尋)和clazz都在同一個dex中的話,那麼這個類就會被打上CLASS_ISPREVERIFIED。

好了,知道了崩潰的原理,解決方案就是要想辦法防止類被打上CLASS_ISPREVERIFIED標誌就OK了。最終QQ空間的方案是往所有類的建構函式裡面插入了一段程式碼,程式碼如下:

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載入之前就使用該類,該類一次找不到,會被永遠的打上找不到的標誌)

綜上,大致的流程是:在dx工具執行之前,將B.class檔案呢,進行修改,再其構造中新增前面那段System.out.println的程式碼,然後繼續打包的流程。注意:AntilazyLoad.class這個類是獨立在hack.dex中。那麼,如何去修改一個類的class檔案,在dx之前去進行類的修改呢,這裡需要用到javassist工具,具體的可以百度去研究一下,我們這篇只講原理,具體實現就留給各位自己去玩了。

—————–已經不再流行的分割線君————————

看上去很美的方案其實有較大的效能問題,根據手Q團隊的報告來看,插樁的解決方案會影響到執行時效能的原因在於:app 內的所有類都預埋引用一個獨立 dex 的空類,導致安裝 dexopt 階段的 preverify 失敗,執行時將再次 verify+optimize。近期我們通過 ReDex 嘗試優化手Q的啟動效能時發現:保留手Q現有的插樁,啟動效能沒有任何優化效果,但去掉插樁,優化手Q啟動相關類的 dex 分佈,啟動效能提升30%。另外即使後期手Q的釋出版本實際上無需釋出補丁,我們也需要預埋插樁的邏輯,這本身也是不合理的一點,所以確實有必要去探索新的方向,既保留補丁的能力,同時去掉插樁帶來的負面影響。繼續看前面分析的程式碼:

這裡寫圖片描述

其實QQ空間的方案是從1處的&&後的判斷入手解決了dex驗證的問題,那1處有沒有什麼辦法呢,如果fromUnverifiedConstant直接為true的話,下面不就都不會走了嗎,沒錯,QFix的方案就是搞定了這個,我們看上面程式碼的前面一段程式碼:
這裡寫圖片描述

dvmResolveClass 在最開始會優先從當前dex已解析類的快取裡找被引用類,找到了直接返回,找不到時說明被引用類還沒有被載入,接著載入成功後,會往當前dex快取裡設定上這個類的引用,後續所有對補丁類的解析引用都不會走到後面的“unexpected DEX”異常邏輯裡。也就是說,補丁安裝後,預先以 const-class/instance-of 方式主動引用補丁類,這次引用會觸發載入補丁類並將引用放入dex的已解析類快取裡,後續app實際業務邏輯引用到補丁類時,直接從已解析快取裡就能取到,這樣很簡單地就繞開了“unexpected DEX”異常,而且這裡只是很簡單地執行了一條輕量級的語句,並沒有其它額外的影響。

下面最重要的問題就是這個引用放哪裡,demo中我們可以預先在Application中進行引用,但在實際運用中我們是無法預先設定哪些類要打補丁的,dex裡對補丁類const-class/instance-of方式的引用指令是編譯時確定的,但具體是哪些類又需要在執行時動態確定,所以這種動態方式行不通,QFix團隊最初想到的還是類似插樁的做法,預先把 app 裡所有類都以 const-class 方式引用一遍,但很明顯有以下問題:1)由於 app 裡類的數量很多,所有類的預先引用統一放在一個地方肯定不現實,需要分散在多個區,只對補丁類所在的少數幾個區執行預先引用的操作,但這裡如何劃分的粒度不好把握,而且 app 裡的類及數量一直變化。2)預先引用解析所有類,會增加引用類的載入耗時和引用語句本身的執行耗時,對於執行耗時,可以通過新增條件判斷來優化,如果要解析的類在補丁類名列表裡就執行該語句,否則就不執行,對於載入耗時,經過測試發現,載入的耗時較長,而且補丁類不可預期,如果不巧分佈在多個區裡,累計耗時的影響將會嚴重得多。3)該方案實現起來特別繁瑣,不實用。

這裡的關鍵是能獲取到前兩個引數的值,第一個引數引用類的 ClassObject,用到了dvmFindLoadedClass:
這裡寫圖片描述

這個方法只用傳入類的描述符即可,但必須是已經載入成功的類,在補丁注入成功後,在每個 dex 裡找一個固定的已經載入成功的引用類並不難。對於主dex,直接用 XXXApplication 類就行,對於其它分 dex,手Q的分 dex 方案有這樣的邏輯:每當一個分 dex 完成注入,手Q都會嘗試載入該 dex 裡的一個固定空類來驗證分 dex 是否注入成功了,所以這個固定的空類可以作為補丁的引用類使用。第二個引數classIdx,可以通過 dexdump -h 獲取。因為該方案沒有開源,有興趣的童鞋可以自己嘗試實現。

再次感慨,所謂的黑科技都不過是原始碼理解後的hack罷了。