Android 熱修復,沒你想的那麼難 原 薦

寫在前面

本文原創,轉載請以鏈接形式註明地址:http://kymjs.com/code/2016/05/08/01

一種動態加載最簡單的實現方式,代碼實現起來非常簡單,重要的是這種思路和原理

《插件化從放棄到撿起》第一章,首先看一張圖:
Android插件化
這張圖是我所理解的 Android 插件化技術的三個技術點以及它們的應用場景。今天以 【Qzone 熱修復方案爲例】,跟大家講一講插件化中 熱修復方案 的實現。

原理

ClassLoader

在 Java 中,要加載一個類需要用到ClassLoader
Android 中有三個 ClassLoader, 分別爲URLClassLoaderPathClassLoaderDexClassLoader。其中

  • URLClassLoader 只能用於加載jar文件,但是由於 dalvik 不能直接識別jar,所以在 Android 中無法使用這個加載器。
  • PathClassLoader 它只能加載已經安裝的apk。因爲 PathClassLoader 只會去讀取 /data/dalvik-cache 目錄下的 dex 文件。例如我們安裝一個包名爲com.hujiang.xxx的 apk,那麼當 apk 安裝過程中,就會在/data/dalvik-cache目錄下生產一個名爲data@app @[email protected]的 ODEX 文件。在使用 PathClassLoader 加載 apk 時,它就會去這個文件夾中找相應的 ODEX 文件,如果 apk 沒有安裝,自然會報ClassNotFoundException
  • DexClassLoader 是最理想的加載器。它的構造函數包含四個參數,分別爲:
    1. dexPath,指目標類所在的APK或jar文件的路徑.類裝載器將從該路徑中尋找指定的目標類,該類必須是APK或jar的全路徑.如果要包含多個路徑,路徑之間必須使用特定的分割符分隔,特定的分割符可以使用System.getProperty(“path.separtor”)獲得.
    2. dexOutputDir,由於dex文件被包含在APK或者Jar文件中,因此在裝載目標類之前需要先從APK或Jar文件中解壓出dex文件,該參數就是制定解壓出的dex 文件存放的路徑.在Android系統中,一個應用程序一般對應一個Linux用戶id,應用程序僅對屬於自己的數據目錄路徑有寫的權限,因此,該參數可以使用該程序的數據路徑.
    3. libPath,指目標類中所使用的C/C++庫存放的路徑
    4. classload,是指該裝載器的父裝載器,一般爲當前執行類的裝載器

framework源碼中的dalvik.system包下,找到DexClassLoader源碼,並沒有什麼卵用,實際內容是在它的父類BaseDexClassLoader中,順帶一提,這個類最低在API14開始有用。包含了兩個變量:

/** originally specified path (just used for {@code toString()}) */
private final String originalPath;
 
/** structured lists of path elements */
private final DexPathList pathList;

可以看到註釋:pathList就是多dex的結構列表,查看其源碼

/*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";

    /** class definition context */
    private final ClassLoader definingContext;

    /** list of dex/resource (class path) elements */
    private final Element[] dexElements;

    /** list of native library directory elements */
    private final File[] nativeLibraryDirectories;

可以看到 dexElements 註釋,dexElements 就是一個dex列表,那麼我們就可以把每個 Element 當成是一個 dex。

此時我們整理一下思路,DexClassLoader 包含有一個dex數組Element[] dexElements,其中每個dex文件是一個Element,當需要加載類的時候會遍歷 dexElements,如果找到類則加載,如果找不到從下一個 dex 文件繼續查找。

那麼我們的實現就是把這個插件 dex 插入到 Elements 的最前面,這麼做的好處是不僅可以動態的加載一個類,並且由於 DexClassLoader 會優先加載靠前的類,所以我們同時實現了宿主 apk 的熱修復功能。

ODEX過程

上文就是整個熱修復的原理了,就是向Classloader列表中插入一個dex。但是如果你這兒實現了,會發現一個問題,就是 ODEX 過程中引發的問題。
在講這個蛋疼的過程之前,有幾個問題是要搞懂的。
爲什麼 Android 不能識別 .class 文件,而只能識別 dex 文件。
因爲 dex 是對 class 的優化,它對 class 做了極大的壓縮,比如以下是一個 class 文件的結構(摘自鄧凡平老師博客)

class文件結構

dex 將整個 Android 工程中所有的 class 壓縮到一個(或幾個) dex 文件中,合併了每個 class 的常量、class 版本信息等,例如每個 class 中都有一個相同的字符串,在 dex 中就只存一份就夠了。所以,在Android 上,dalvik 虛擬機是無法識別一個普通 class 文件的,因爲無法識別這個 class 文件的結構。
以下是一個 dex 文件的結構

dex文件結構

感興趣的可以閱讀《深入理解Android》這本書。

繼續往下,其實 dalvik 虛擬機也並不是直接讀取 dex 文件的,而是在一個 APK 安裝的時候,會首先做一次優化,會生成一個 ODEX 文件,即 Optimized dex。 爲什麼還要優化,依舊是爲了效率。
只不過,Class -> dex 是爲了平臺無關的優化;
而 dex -> odex 則是針對不同平臺,不同手機的硬件配置做針對性的優化。
就是在這一過程中,虛擬機在啓動優化的時候,會有一個選項就是 verify 選項,當 verify 選項被打開的時候,就會執行一次校驗,校驗的目的是爲了判斷,這個類是否有引用其他 dex 中的類,如果沒有,那麼這個類會被打上一個 CLASS_ISPREVERIFIED 的標誌。一旦被打上這個標誌,就無法再從其他 dex 中替換這個類了。而這個選項開啓,則是由虛擬機控制的。

字節碼操作

那麼既然知道了原因,解決的辦法自然也有了。你不是沒有引用其他 dex 中的類就會被標記嗎,那咱們就引用一個其他 dex 中的類。

ClassReader:該類用來解析編譯過的class字節碼文件。
ClassWriter:該類用來重新構建編譯後的類,比如說修改類名、屬性以及方法,甚至可以生成新的類的字節碼文件。
ClassAdapter:該類也實現了ClassVisitor接口,它將對它的方法調用委託給另一個ClassVisitor對象。

/**
 * 當對象初始化的時候注入Inject類
 *
 * @Note https://www.ibm.com/developerworks/cn/java/j-lo-asm30/
 * @param inputStream 需要注入的Class的文件輸入流
 * @return 返回注入以後的Class文件二進制數組
 */
private static byte[] referHackWhenInit(InputStream inputStream) {
    //該類用來解析編譯過的class字節碼文件。
    ClassReader cr = new ClassReader(inputStream);
    //該類用來重新構建編譯後的類,比如說修改類名、屬性以及方法,甚至可以生成新的類的字節碼文件
    ClassWriter cw = new ClassWriter(cr, 0);
    //類的訪問者,可以用來創建對一個Class的改動操作
    ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc,
                                         String signature, String[] exceptions) {
            MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
            //如果方法名是<init>,每個類的構造函數函數名叫<init>
            if ("<init>".equals(name)) {
                //在原本的visitMethod操作中添加自己定義的操作
                mv = new MethodVisitor(Opcodes.ASM4, mv) {
                    @Override
                    void visitInsn(int opcode) {
                        //Opcodes可以看做爲關鍵字
                        if (opcode == Opcodes.RETURN) {
                            //visitLdcInsn() 將一個值寫入到棧中,可以是一個Class類名/method方法名/desc方法描述
                            //這裏相當於插入了一條語句:Class a = Inject.class;
                            super.visitLdcInsn(Type.getType("Lcom/hujiang/hotfix/Inject;"));
                        }
                        //執行opcode對應的其他操作
                        super.visitInsn(opcode);
                    }
                }
            }
            //責任鏈完成,返回
            return mv;
        }
    };
    //accept這個方法接受一個實現了 ClassVisitor接口的對象實例作爲參數,然後依次調用 ClassVisitor接口的各個方法
    //用戶無法控制各個方法調用順序,但是可以提供不同的 Visitor(訪問者) 來對字節碼樹進行不同的修改
    //在這裏,調用這一步的目的是爲了讓上面的visitMethod方法被調用
    cr.accept(cv, 0);
    return cw.toByteArray();
}

代碼實現

可以參考 nuwa 中的實現,首先是 dex 怎樣去插入到Classloader列表中,其實就是一段反射:

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 中的PathList.dexElements, 並把兩個 dexElements 數組做拼接,將補丁數組放在前面,最後將拼接後生成的數組再賦值回Classloader

nuwa 更主要的是他的 groovy 腳本,完整代碼:這裏,由於代碼很多,就只跟大家講兩個關鍵的點的實現以及目的,具體的內容可以直接查看源碼。

//獲得所有輸入文件,即preDex的所有jar文件
Set<File> inputFiles = preDexTask.inputs.files.files
inputFiles.each { inputFile ->
    def path = inputFile.absolutePath
    //如果不是support包或者引入的依賴庫,則開始生成代碼修改部分的hotfix包
    if (HotFixProcessors.shouldProcessPreDexJar(path)) {
        HotFixProcessors.processJar(classHashFile, inputFile, patchDir, classHashMap, includePackage, excludeClass)
    }
}

其中HotFixProcessors.processJar()是腳本的第一個作用,就是找出哪些類是發生了改變,應該生成對應的補丁。
循環遍歷工程中的全部類,聲明忽略的直接跳過.對每個類計算hash,並寫入到hashFile文件中.通過比較hashFile文件與原先host工程的hashFile(即這裏的classHashMap參數),得到所有修改過的類生成這些類的class文件,以及所有修改過的class文件的集合jar文件。

Set<File> inputFiles = dexTask.inputs.files.files
inputFiles.each { inputFile ->
    def path = inputFile.absolutePath
	if (path.endsWith(".class") && !path.contains("/R\$") && !path.endsWith("/R.class") && !path.endsWith("/BuildConfig.class")) {
        if (HotFixSetUtils.isIncluded(path, includePackage)) {
            if (!HotFixSetUtils.isExcluded(path, excludeClass)) {
                def bytes = HotFixProcessors.processClass(inputFile)
                path = path.split("${dirName}/")[1]
                def hash = DigestUtils.shaHex(bytes)
                classHashFile.append(HotFixMapUtils.format(path, hash))

                if (HotFixMapUtils.notSame(classHashMap, path, hash)) {
                    HotFixFileUtils.copyBytesToFile(inputFile.bytes, HotFixFileUtils.touchFile(patchDir, path))
                }
            }
        }
    }
}

這一段是腳本的第二個作用,也就是上文字節碼操作的目的,爲了防止類被虛擬機打上CLASS_ISPREVERIFIED,所以需要執行字節碼寫入。其中HotFixProcessors.processClass()就是實際寫入字節碼的代碼。

好像差個結尾

同樣的方案,除了 nuwa 還有一個開源的實現,HotFix 兩者是差不多的,所以看一個就可以了。

看到有很多朋友問,如果混淆後代碼怎麼辦。在 Gradle 插件編譯過程中,有一個proguardTask,看名字應該就知道他是負責 proguard 任務的,我們可以保存首次執行時的混淆規則(也就是線上出BUG的包),這個混淆規則保存在工程目錄中的一個mapping文件,當我們需要執行熱修復補丁生成的時候,將線上包的mapping規則拿出來應用到本次編譯中,就可以生成混淆後的類跟線上混淆後的類相同的類名的補丁了。具體實現可以看 nuwa 項目的applymapping()方法。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章