Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Android 熱修復原理及Gradle插件源碼解析(以Nuwa為例)

Android 熱修復原理及Gradle插件源碼解析(以Nuwa為例)

編輯:關於Android編程

現在,熱修復的具體實現方案開源的也有很多,原理也大同小異,本篇文章以Nuwa為例,深入剖析。
Nuwa的github地址
https://github.com/jasonross/Nuwa
以及用於hotpatch生成的gradle插件地址
https://github.com/jasonross/NuwaGradle

而Nuwa的具體實現是根據QQ空間的熱修復方案來實現的。從QQ空間終端開發團隊的文章中可以總結出要進行熱更新只需要滿足下面兩點就可以了:

動態加載補丁dex,並將補丁dex插入到dexElements最前面要實現熱更新,需要熱更新的類要防止被打上ISPREVERIFIED標記,關於這個標記,請閱讀上面QQ空間團隊的文章。

對於第一點,實現很簡單,通過DexClassLoader對象,將補丁dex對象加載進來,再通過反射將補丁dex插入到dexElements最前面即可。具體可參考谷歌的Multidex的實現。

而對於第二點,關鍵就是如何防止類被打上ISPREVERIFIED這個標記。

簡單來說,就是將所有類的構造函數中,引用另一個hack.dex中的類,這個類叫Hack.class,然後在加載補丁patch.dex前動態加載這個hack.dex,但是有一個類的構造函數中不能引用Hack.class,這個類就是Application類的子類,一旦這個類的構造函數中加入Hack.class這個類,那麼程序運行時就會找不到Hack.class這個類,因為還沒有被加載。也就是說,一個類直接引用到的類不在同一個dex中即可。這樣,就能防止類被打上ISPREVERIFIED標記並能進行熱更新。

我們先來看Nuwa的實現,再去看Nuwa的插件的實現。

使用Nuwa的時候需要在attachBaseContext方法中初始化

@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    Nuwa.init(this);
}

這裡寫圖片描述

Nuwa預先將Hack.class這個類(空實現)打成apk文件,放在asserts目錄中,在init方法中,做的就是將asserts目錄中的這個文件拷貝到文件目錄下。

public static void init(Context context) {
        File dexDir = new File(context.getFilesDir(), DEX_DIR);
        dexDir.mkdir();

        String dexPath = null;
        try {
            dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir);
        } catch (IOException e) {
            Log.e(TAG, "copy " + HACK_DEX + " failed");
            e.printStackTrace();
        }

        loadPatch(context, dexPath);
    }

首先創建文件目錄將asserts目錄下的hack.apk拷到該目錄,然後調用loadPatch方法將該apk動態加載進來。loadPatch方法也是之後進行熱修復的關鍵方法,你的所有補丁文件都是通過這個方法動態加載進來。

 public static void loadPatch(Context context, String dexPath) {

        if (context == null) {
            Log.e(TAG, "context is null");
            return;
        }
        if (!new File(dexPath).exists()) {
            Log.e(TAG, dexPath + " is null");
            return;
        }
        File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR);
        dexOptDir.mkdir();
        try {
            DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "inject " + dexPath + " failed");
            e.printStackTrace();
        }
    }

loadPatch方法中主要是調用DexUtils.injectDexAtFirst()方法將dex插入到dexElements最前面。該方法如下。

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的文件目錄defaultDexOptPath,構造DexClassLoader對象dexClassLoader,然後通過getDexElements方法獲得原來的dexElements對象,之後拿到dexClassLoader對象中的dexElements對象,調用combineArray方法將這兩個對象進行結合,將我們傳進來的dex插到該對象的最前面,之後調用ReflectionUtils.setField()方法,將dexElements進行替換。combineArray方法中做的就是擴展數組,將第二個數組插入到第一個數組的最前面

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

之後如果你有補丁要應用,直接調用Nuwa.loadPatch()方法,傳入補丁的目錄,重啟應用之後就可以進行熱更新了。這是Nuwa應用層的實現,可以看到,並不復雜。相對復雜的是Gradle插件層的實現。Gradle插件要做的事就是拿到所有class,在其構造函數中注入Hack.class,使其直接引用另一個dex中的文件,防止被打上ISPREVERIFIED標記。並且混淆的時候要應用上一次release版本的mapping文件。現在有兩點關鍵內容:

如何拿到所有的class 如何在構造函數中注入代碼

我們先來解決第二點,如何注入代碼,Nuwa使用的是asm注入代碼。

現在假設我們已經存在了hack.apk,並且裡面已經有了Hack.class文件,其源代碼如下

package cn.edu.zafu.hotpatch.asm;

/**
 * @author lizhangqu
 * @since 2016-03-06 10:31
 */
public class Hack {
}

我們編寫一個測試類Test,裡面有一個測試方法,我們需要將Hack.class注入到Test的構造函數中,讓其直接引用另一個dex中的類。

public class Test {
    public void method1(){
        String str="111";
    }
}

我們編譯一下,得到Test.clss,將其復制到一個目錄dir。然後終端進入到該目錄,使用javap命令查看字節碼

這裡寫圖片描述vc281tDT0CA8c3Ryb25nPiZsdDsgaW5pdCAmZ3Q7PC9zdHJvbmc+19bR+aOsuMO0pr7Nyse5udTsuq/K/aOsyLu687+0tb08c3Ryb25nPjQ6cmV0dXJuPC9zdHJvbmc+o6zV4srHubnU7Lqvyv21xL3hyvi1xLXYt72ho8/W1NrO0sPHtsHI67jDzsS8/qOssqK21MbkvfjQ0NfWvdrC69DeuMSjrMi7uvPQtMjruMPEv8K8z8JkZXN0xL/CvM/CoaPU2tXi1q7HsKOs0OjSqrzTyOthc221xNLAwLWjrNbB09phc221xMq508OjrMfr19TQ0LLp0a+hozwvcD4NCjxwcmUgY2xhc3M9"brush:java;"> compile 'org.ow2.asm:asm:5.0.4'

我們先將該文件讀入,獲得輸入流,調用referHackWhenInit方法,將輸入流傳入,用ClassVisitor對象訪問該對象,實現MethodVisitor方法,在該方法中訪問對象中的方法,對方法名進行判斷,如果是構造函數,則對其進行字節碼注入操作,接下來運行main方法,查看dest目錄下生成的文件。

public class Main {

    public static void main(String[] args) throws IOException {
        File srcFile = new File("/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/Test.class");
        File destDir = new File("/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/dest/");

        if (!destDir.exists()) {
            destDir.mkdirs();
        }
        InputStream is = new FileInputStream(srcFile);
        byte[] bytes = referHackWhenInit(is);


        File destFile = new File(destDir, "Test.class");
        FileOutputStream fos = new FileOutputStream(destFile);
        fos.write(bytes);
        fos.close();

    }

    private static byte[] referHackWhenInit(InputStream inputStream) throws IOException {
        ClassReader cr = new ClassReader(inputStream);
        ClassWriter cw = new ClassWriter(cr, 0);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
            @Override
            public MethodVisitor visitMethod(int access, final String name, String desc,
                                             String signature, String[] exceptions) {

                MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
                if ("".equals(name)) {
                    mv = new MethodVisitor(Opcodes.ASM4, mv) {
                        @Override
                        public void visitInsn(int opcode) {
                            if (opcode == Opcodes.RETURN) {
                                super.visitLdcInsn(Type.getType("Lcn/edu/zafu/hotpatch/asm/Hack"));
                            }
                            super.visitInsn(opcode);
                        }
                    };
                }
                return mv;
            }

        };
        cr.accept(cv, 0);
        return cw.toByteArray();
    }

}

生成的Test.class文件內容如下

這裡寫圖片描述

可以看到構造函數中直接引用了Hack.class,然後我們使用javap命令查看字節碼

這裡寫圖片描述

可以看到return之前,插入了我們的字節碼,直接引用了Hack.class

字節碼注入的問題解決了,接下來就是找到要注入字節碼的所有class。

接下來分析Nuwa的Gradle插件,在分析之前,請先了解一下Gralde插件的開發流程,可以閱讀這篇文章如何使用Android Studio開發Gradle插件。下面的內容的gradle版本是基於1.2.3,高版本的可能有所差異。請查看項目依賴的是否是下面的這個版本

classpath 'com.android.tools.build:gradle:1.2.3'

為了找到這些class,實際上,分為了兩種情況

開啟了Multidex的項目沒有開啟Multidex的項目

如果使用了MultiDex,並且沒有混淆,這種情況很簡單,dex任務之前會生成一個jar文件,包含了所有的class,所以做起來很容易。但是如果添加了混淆怎麼辦?試了一下,也是proguard後也是生成了一個jar包,也沒啥問題

為了驗證作者的論證,我們編寫一個插件來驗證一下,關於如何編寫插件,請查看上面貼的文章。

我們先在項目中開啟Multidex

multiDexEnabled true

對於release的構建,開啟混淆,對於debug,關閉混淆

buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug{
            minifyEnabled false
        }
    }

這個插件的作用是什麼的,其實很簡單,就是輸出preDex,dex,proguard這三個Task的輸入文件,當然前提是Task存在。代碼如下

public class PluginImpl implements Plugin {
    public void apply(Project project) {

        project.afterEvaluate {
            project.android.applicationVariants.each { variant ->
                def preDexTask = project.tasks.findByName("preDex${variant.name.capitalize()}")
                def dexTask = project.tasks.findByName("dex${variant.name.capitalize()}")
                def proguardTask = project.tasks.findByName("proguard${variant.name.capitalize()}")


                if (preDexTask) {
                    Set preDexTaskInputFiles = preDexTask.inputs.files.files

                    project.logger.error "Name:preDexTaskInputFiles=====>${preDexTask.name}"
                    preDexTaskInputFiles.each { inputFile ->
                        def path = inputFile.absolutePath
                        project.logger.error path
                    }
                }

                if (dexTask) {
                    Set dexTaskInputFiles = dexTask.inputs.files.files

                    project.logger.error "Name:dexTaskInputFiles=====>${dexTask.name}"
                    dexTaskInputFiles.each { inputFile ->
                        def path = inputFile.absolutePath
                        project.logger.error path

                    }
                }

                if (proguardTask) {
                    Set proguardTaskInputFiles = proguardTask.inputs.files.files

                    project.logger.error "Name:proguardTask=====>${proguardTask.name}"
                    proguardTaskInputFiles.each { inputFile ->
                        def path = inputFile.absolutePath
                        project.logger.error path
                    }
                }
            }
        }
    }
}

應用插件然後查看插件輸出的日志

這裡寫圖片描述

可以看到,對於debug的構建,我們沒有開啟混淆,dex的Task的輸入文件是一個allclasses.jar,而release版本的構建,dex的Task的輸入文件是混淆之後的文件classes.jar。並且無論是debug還是release,對於這種開啟了Multidex的情況下,是不存在preDex這個Task的,對於這種情況,我們可以判斷preDex這個Task是否存在進行操作。查看NuwaGradle的源碼。相關解釋,我已經加入到注釋中了。

/**
 * 了解到preDex會在dex任務之前把所有的庫工程和第三方jar包提前打成dex,
 * 下次運行只需重新dex被修改的庫,以此節省時間。
 * dex任務會把preDex生成的dex文件和主工程中的class文件一起生成class.dex
 */
if (preDexTask){
    //這個Task存在的情況,即沒有開啟Multidex
}else {
    /**
     * 如果preDexTask這個task不存在,即開啟了Multidex
     * dex任務之前會生成一個jar文件,包含了所有的class,即使做了混淆也是一個jar
     * 這種情況下只對jar進行處理即可
     */
    def nuwaJarBeforeDex = "nuwaJarBeforeDex${variant.name.capitalize()}"
    project.task(nuwaJarBeforeDex) << {
        /**
         * 獲得所有輸入文件
         */
        Set inputFiles = dexTask.inputs.files.files
        /**
         * 遍歷所有文件
         */
        inputFiles.each { inputFile ->
            def path = inputFile.absolutePath
            /**
             * 如果是以jar結尾,則對jar進行字節碼注入處理
             */
            if (path.endsWith(".jar")) {
                NuwaProcessor.processJar(hashFile, inputFile, patchDir, hashMap, includePackage, excludeClass)
            }
        }
    }
    /**
     * 處理task依賴
     * nuwaJarBeforeDexTask在dexTask之前,在dexTask原來之前所有task之後
     */
    def nuwaJarBeforeDexTask = project.tasks[nuwaJarBeforeDex]
    nuwaJarBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask)
    dexTask.dependsOn nuwaJarBeforeDexTask

    /**
     * nuwaJarBeforeDexTask開始時執行nuwaPrepareClosure閉包
     * 這個閉包做的就是創建文件夾等初始化話操作
     * 結束時執行copyMappingClosure拷貝mapping文件和hash.txt文件
     */
    nuwaJarBeforeDexTask.doFirst(nuwaPrepareClosure)
    nuwaJarBeforeDexTask.doLast(copyMappingClosure)

    /**
     * patch的dex生成是在=》操作依賴字節碼修改之後的task執行完畢後再執行
     */
    nuwaPatchTask.dependsOn nuwaJarBeforeDexTask
    beforeDexTasks.add(nuwaJarBeforeDexTask)

    /**
     * 以上task的總結
     * dex之前的所有task->獲得dex之前的所有輸入文件->字節碼注入->dex
     */
}

而上面使用到了nuwaPrepareClosure和copyMappingClosure這兩個閉包。以及Gradle插件的初始化操作如下,詳情見注釋

/**
 * hash值對應的map
 */
Map hashMap
/**
 * nuwa的輸出產物目錄
 */
File nuwaDir
/**
 * 需要打patch的classes文件目錄,會對比hash值,如果hash值不一樣,會拷到這個目錄
 */
File patchDir

/**
 * 找到preDex,dex,proguard這三個task
 */
def preDexTask = project.tasks.findByName("preDex${variant.name.capitalize()}")
def dexTask = project.tasks.findByName("dex${variant.name.capitalize()}")
def proguardTask = project.tasks.findByName("proguard${variant.name.capitalize()}")

/**
 * 找到manifest文件
 */
def processManifestTask = project.tasks.findByName("process${variant.name.capitalize()}Manifest")
def manifestFile = processManifestTask.outputs.files.files[0]
/**
 * 這個屬性是從控制台輸入的,代表之前release版本生成的混淆文件和hash文件目錄,這兩個文件發版時需要保持
 * ./gradlew clean nuwaQihooDebugPatch -P NuwaDir=/Users/jason/Documents/nuwa
 */
def oldNuwaDir = NuwaFileUtils.getFileFromProperty(project, NUWA_DIR)
if (oldNuwaDir) {
    /**
     * 如果文件夾存在的話混淆的時候應用mapping
     */
    def mappingFile = NuwaFileUtils.getVariantFile(oldNuwaDir, variant, MAPPING_TXT)
    NuwaAndroidUtils.applymapping(proguardTask, mappingFile)
}
if (oldNuwaDir) {
    /**
     * 如果文件夾存在的話獲得各個class的hash
     */
    def hashFile = NuwaFileUtils.getVariantFile(oldNuwaDir, variant, HASH_TXT)
    /**
     * 將文件中的hash存入這個map
     */
    hashMap = NuwaMapUtils.parseMap(hashFile)
}

/**
 * /qihoo/debug
 */
def dirName = variant.dirName
/**
 * /build/outputs/nuwa/
 */
nuwaDir = new File("${project.buildDir}/outputs/nuwa")
/**
 * 不同variant對應nuwa目錄下不同文件夾
 * /build/outputs/nuwa/qihoo/debug
 */
def outputDir = new File("${nuwaDir}/${dirName}")
/**
 * hash文件
 * /build/outputs/nuwa/qihoo/debug/hash.txt
 */
def hashFile = new File(outputDir, "hash.txt")

/**
 * 創建相關文件的閉包
 */
Closure nuwaPrepareClosure = {
    /**
     * 獲得application類
     */
    def applicationName = NuwaAndroidUtils.getApplication(manifestFile)
    if (applicationName != null) {
        /**
         * 如果已經定義了application類,則加入excludeClass列表,不執行字節碼修改
         */
        excludeClass.add(applicationName)
    }

    /**
     * 創建對應的文件夾及hash文件
     */
    outputDir.mkdirs()
    if (!hashFile.exists()) {
        hashFile.createNewFile()
    }

    /**
     * 創建patch文件夾
     */
    if (oldNuwaDir) {
        /**
         * 此目錄存patch的classes
         * /build/outputs/nuwa/qihoo/debug/patch/
         */
        patchDir = new File("${nuwaDir}/${dirName}/patch")
        patchDir.mkdirs()
        patchList.add(patchDir)
    }
}
/**
 * 注入nuwaPatch的task
 * nuwaQihooDebugPatch
 */
def nuwaPatch = "nuwa${variant.name.capitalize()}Patch"
project.task(nuwaPatch) << {
    if (patchDir) {
        /**
         * 執行patch的dex操作
         */
        NuwaAndroidUtils.dex(project, patchDir)
    }
}
/**
 * 獲得打patch的task
 */
def nuwaPatchTask = project.tasks[nuwaPatch]

/**
 * 拷貝mapping的閉包
 */
Closure copyMappingClosure = {
    /**
     * 將構建產生的mapping文件拷貝至目標nuwa目錄
     */
    if (proguardTask) {
        def mapFile = new File("${project.buildDir}/outputs/mapping/${variant.dirName}/mapping.txt")
        def newMapFile = new File("${nuwaDir}/${variant.dirName}/mapping.txt");
        FileUtils.copyFile(mapFile, newMapFile)
    }
}

而對於沒有開啟Multidex的情況,則會存在一個preDex的Task。preDex會在dex任務之前把所有的庫工程和第三方jar包提前打成dex,下次運行只需重新dex被修改的庫,以此節省時間。dex任務會把preDex生成的dex文件和主工程中的class文件一起生成class.dex,這樣就需要針對有無preDex,做不同的修改字節碼策略即可。源碼解釋如下。

/**
 * 了解到preDex會在dex任務之前把所有的庫工程和第三方jar包提前打成dex,
 * 下次運行只需重新dex被修改的庫,以此節省時間。
 * dex任務會把preDex生成的dex文件和主工程中的class文件一起生成class.dex
 */
if (preDexTask) {
    /**
     * 處理jar文件,這些jar是所有的庫工程和第三方jar包,是preDexTask的輸入文件
     */
    def nuwaJarBeforePreDex = "nuwaJarBeforePreDex${variant.name.capitalize()}"
    project.task(nuwaJarBeforePreDex) << {
        /**
         * 獲得preDex的所有jar文件
         */
        Set inputFiles = preDexTask.inputs.files.files
        /**
         * 遍歷jar文件
         */
        inputFiles.each { inputFile ->
            def path = inputFile.absolutePath
            /**
             * 如果是以classes.jar結尾的文件並且路徑中不包含com.android.support且路徑中中不包含/android/m2repository
             */
            if (NuwaProcessor.shouldProcessPreDexJar(path)) {
                /**
                 * 處理classes.jar,注入字節碼
                 */
                NuwaProcessor.processJar(hashFile, inputFile, patchDir, hashMap, includePackage, excludeClass)
            }
        }
    }
    /**
     * 處理task依賴
     * nuwaJarBeforePreDexTask依賴preDexTask之前所有的task
     * preDexTask依賴nuwaJarBeforePreDexTask
     */
    def nuwaJarBeforePreDexTask = project.tasks[nuwaJarBeforePreDex]
    nuwaJarBeforePreDexTask.dependsOn preDexTask.taskDependencies.getDependencies(preDexTask)
    preDexTask.dependsOn nuwaJarBeforePreDexTask

    /**
     * 這個task之前進行這個閉包處理,主要做創建文件的操作
     */
    nuwaJarBeforePreDexTask.doFirst(nuwaPrepareClosure)

    /**
     * 處理classes文件,注意這裡是主工程的class文件,是dexTask的輸入文件
     */
    def nuwaClassBeforeDex = "nuwaClassBeforeDex${variant.name.capitalize()}"
    project.task(nuwaClassBeforeDex) << {
        Set inputFiles = dexTask.inputs.files.files
        /**
         * 遍歷所有class文件
         */
        inputFiles.each { inputFile ->
            def path = inputFile.absolutePath
            /**
             * 以class結尾,不包含R路徑,不是R.class,不是BuildConfig.class文件
             */
            if (path.endsWith(".class") && !path.contains("/R\$") && !path.endsWith("/R.class") && !path.endsWith("/BuildConfig.class")) {
                /**
                 * 包含在includePackage內
                 */
                if (NuwaSetUtils.isIncluded(path, includePackage)) {
                    /**
                     * 不包含在excludeClass內
                     */
                    if (!NuwaSetUtils.isExcluded(path, excludeClass)) {
                        /**
                         * 往class中注入字節碼
                         */
                        def bytes = NuwaProcessor.processClass(inputFile)
                        path = path.split("${dirName}/")[1]
                        /**
                         * hash校驗
                         */
                        def hash = DigestUtils.shaHex(bytes)
                        /**
                         * 往hash.txt文件中寫入hash值
                         */
                        hashFile.append(NuwaMapUtils.format(path, hash))
                        /**
                         * 與上一個release版本hash值不一樣則復制出來,作為patch.jar的組成部分
                         */
                        if (NuwaMapUtils.notSame(hashMap, path, hash)) {
                            /**
                             * 拷貝到patch目錄
                             */
                            NuwaFileUtils.copyBytesToFile(inputFile.bytes, NuwaFileUtils.touchFile(patchDir, path))
                        }
                    }
                }
            }
        }
    }
    /**
     * 重新處理task依賴關系
     * nuwaClassBeforeDexTask依賴dexTask這個task之前依賴的所有Task
     * dexTask這個Task依賴 nuwaClassBeforeDexTask這個Task
     */
    def nuwaClassBeforeDexTask = project.tasks[nuwaClassBeforeDex]
    nuwaClassBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask)
    dexTask.dependsOn nuwaClassBeforeDexTask

    /**
     * 最後拷貝mapping文件備份
     */
    nuwaClassBeforeDexTask.doLast(copyMappingClosure)


    /**
     * patch的dex操作依賴字節碼修改之後的task,即nuwaClassBeforeDexTask
     */
    nuwaPatchTask.dependsOn nuwaClassBeforeDexTask
    beforeDexTasks.add(nuwaClassBeforeDexTask)
}

這樣就完成了字節碼的修改,至於字節碼修改的函數,其實就和最開始的測試asm修改字節碼的例子差不多,對於jar文件,需要將jar文件中的所有class遍歷一遍處理。字節碼的注入操作全在NuwaProcessor這個類中。源碼解析如下

class NuwaProcessor {
    /**
     * 處理jar
     * @param hashFile
     * @param jarFile
     * @param patchDir
     * @param map
     * @param includePackage
     * @param excludeClass
     * @return
     */
    public static processJar(File hashFile, File jarFile, File patchDir, Map map, HashSet includePackage, HashSet excludeClass) {
        if (jarFile) {
            /**
             * classes.jar dex後的文件
             */
            def optJar = new File(jarFile.getParent(), jarFile.name + ".opt")

            def file = new JarFile(jarFile);
            Enumeration enumeration = file.entries();
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar));

            /**
             * 枚舉jar文件中的所有文件
             */
            while (enumeration.hasMoreElements()) {
                JarEntry jarEntry = (JarEntry) enumeration.nextElement();
                String entryName = jarEntry.getName();
                ZipEntry zipEntry = new ZipEntry(entryName);

                InputStream inputStream = file.getInputStream(jarEntry);
                jarOutputStream.putNextEntry(zipEntry);
                /**
                 * 以class結尾的文件並且在include中不在exclude中,並且不是cn/jiajixin/nuwa/包中的文件
                 */
                if (shouldProcessClassInJar(entryName, includePackage, excludeClass)) {
                    /**
                     * 構造函數中注入字節碼
                     */
                    def bytes = referHackWhenInit(inputStream);
                    /**
                     * 寫入子傑
                     */
                    jarOutputStream.write(bytes);

                    /**
                     * hash校驗
                     */
                    def hash = DigestUtils.shaHex(bytes)
                    /**
                     * 加入hash值
                     */
                    hashFile.append(NuwaMapUtils.format(entryName, hash))
                    /**
                     * hash值與上一release版本不一樣則拷到對應的目錄,作為patch的類
                     */
                    if (NuwaMapUtils.notSame(map, entryName, hash)) {
                        NuwaFileUtils.copyBytesToFile(bytes, NuwaFileUtils.touchFile(patchDir, entryName))
                    }
                } else {
                    /**
                     * 否則直接輸出文件不處理
                     */
                    jarOutputStream.write(IOUtils.toByteArray(inputStream));
                }
                jarOutputStream.closeEntry();
            }
            jarOutputStream.close();
            file.close();
            /**
             * 刪除jar文件
             */
            if (jarFile.exists()) {
                jarFile.delete()
            }
            /**
             * dex後的文件重命名為jar文件
             */
            optJar.renameTo(jarFile)
        }

    }

    //refer hack class when object init
    private static byte[] referHackWhenInit(InputStream inputStream) {
        ClassReader cr = new ClassReader(inputStream);
        ClassWriter cw = new ClassWriter(cr, 0);
        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);
                mv = new MethodVisitor(Opcodes.ASM4, mv) {
                    @Override
                    void visitInsn(int opcode) {
                        /**
                         * 如果是構造函數
                         */
                        if ("".equals(name) && opcode == Opcodes.RETURN) {
                            /**
                             * 注入代碼
                             */
                            super.visitLdcInsn(Type.getType("Lcn/jiajixin/nuwa/Hack;"));
                        }
                        super.visitInsn(opcode);
                    }
                }
                return mv;
            }

        };
        cr.accept(cv, 0);
        return cw.toByteArray();
    }

    /**
     * 是否需要在preDex前處理
     * @param path
     * @return
     */
    public static boolean shouldProcessPreDexJar(String path) {
        return path.endsWith("classes.jar") && !path.contains("com.android.support") && !path.contains("/android/m2repository");
    }

    /**
     * jar中的文件是否需要處理
     * @param entryName
     * @param includePackage
     * @param excludeClass
     * @return
     */
    private static boolean shouldProcessClassInJar(String entryName, HashSet includePackage, HashSet excludeClass) {
        return entryName.endsWith(".class") && !entryName.startsWith("cn/jiajixin/nuwa/") && NuwaSetUtils.isIncluded(entryName, includePackage) && !NuwaSetUtils.isExcluded(entryName, excludeClass) && !entryName.contains("android/support/")
    }

    /**
     * 處理class
     * @param file
     * @return
     */
    public static byte[] processClass(File file) {
        def optClass = new File(file.getParent(), file.name + ".opt")

        FileInputStream inputStream = new FileInputStream(file);
        FileOutputStream outputStream = new FileOutputStream(optClass)
        /**
         * 對class注入字節碼
         */
        def bytes = referHackWhenInit(inputStream);
        outputStream.write(bytes)
        inputStream.close()
        outputStream.close()
        if (file.exists()) {
            file.delete()
        }
        optClass.renameTo(file)
        return bytes
    }
}

字節碼的注入需要將Application類排除在外,這個類如果在Manifest文件中設置了,我們需要將其拿到,並加入到excludeClass中,這個操作在nuwaPrepareClosure閉包中已經處理了。

    /**
     * 獲得application的名字
     * @param manifestFile
     * @return
     */
    public static String getApplication(File manifestFile) {
        def manifest = new XmlParser().parse(manifestFile)
        def androidTag = new groovy.xml.Namespace("http://schemas.android.com/apk/res/android", 'android')
        def applicationName = manifest.application[0].attribute(androidTag.name)

        if (applicationName != null) {
            return applicationName.replace(".", "/") + ".class"
        }
        return null;
    }

然後如果開啟了混淆,我們需要應用上一次發release版本的mapping文件進行混淆

/**
     * 混淆時使用上次發版的mapping文件
     * @param proguardTask
     * @param mappingFile
     * @return
     */
    public static applymapping(DefaultTask proguardTask, File mappingFile) {
        if (proguardTask) {
            if (mappingFile.exists()) {
                proguardTask.applymapping(mappingFile)
            } else {
                println "$mappingFile does not exist"
            }
        }
    }

Nuwa除了支持某個構建執行打Patch的操作之外,還支持批量生產所有構建的Patch,該Task的名字為nuwaPatches,這個Task的依賴關系還要處理一下

/**
 * 下面是nuwaPatches的處理,即所有的構建都打patch
 */

/**
 * nuwaPatches執行dex操作
 */
project.task(NUWA_PATCHES) << {
    /**
     * 對需要patch的classes執行dex操作
     */
    patchList.each { patchDir ->
        NuwaAndroidUtils.dex(project, patchDir)
    }
}


/**
 * 處理依賴nuwaPatches這個Task的依賴,也就是注入字節碼的Task之後執行dex操作
 */
beforeDexTasks.each {
    /**
     * 打patch的task依賴這些task
     */
    project.tasks[NUWA_PATCHES].dependsOn it
}

所有需要注入字節碼的類處理完畢後,我們需要將其進行dex操作,使其能夠運行在Android系統上。這個方法在NuwaAndroidUtils類中。

/**
 * 對jar進行dex操作
 * @param project
 * @param classDir
 * @return
 */
public static dex(Project project, File classDir) {
    if (classDir.listFiles().size()) {
        def sdkDir
        /**
         * 獲得sdk目錄
         */
        Properties properties = new Properties()
        File localProps = project.rootProject.file("local.properties")
        if (localProps.exists()) {
            properties.load(localProps.newDataInputStream())
            sdkDir = properties.getProperty("sdk.dir")
        } else {
            sdkDir = System.getenv("ANDROID_HOME")
        }
        if (sdkDir) {
            /**
             * 如果是windows系統,加入後綴.bat
             */
            def cmdExt = Os.isFamily(Os.FAMILY_WINDOWS) ? '.bat' : ''
            def stdout = new ByteArrayOutputStream()
            /**
             * 拼接命令
             * dx --dex --output=patch.jar classDir
             * classDir是注入字節碼後的補丁目錄
             */
            project.exec {
                commandLine "${sdkDir}/build-tools/${project.android.buildToolsVersion}/dx${cmdExt}",
                        '--dex',
                        "--output=${new File(classDir.getParent(), PATCH_NAME).absolutePath}",
                        "${classDir.absolutePath}"
                standardOutput = stdout
            }
            def error = stdout.toString().trim()
            if (error) {
                println "dex error:" + error
            }
        } else {
            throw new InvalidUserDataException('$ANDROID_HOME is not defined')
        }
    }
}

還有一個hash值的工具類,包括從文件中解析hash值到map,將hash值格式化寫入文件,判斷hash值知否一樣。

class NuwaMapUtils {
    private static final String MAP_SEPARATOR = ":"

    /**
     * 判斷hash值是否一樣
     * @param map
     * @param name
     * @param hash
     * @return
     */
    public static boolean notSame(Map map, String name, String hash) {
        def notSame = false
        if (map) {
            def value = map.get(name)
            if (value) {
                if (!value.equals(hash)) {
                    notSame = true
                }
            } else {
                notSame = true
            }
        }
        return notSame
    }

    /**
     * 從hash.txt文件中解析內容到map
     * @param hashFile
     * @return
     */
    public static Map parseMap(File hashFile) {
        def hashMap = [:]
        if (hashFile.exists()) {
            hashFile.eachLine {
                List list = it.split(MAP_SEPARATOR)
                if (list.size() == 2) {
                    hashMap.put(list[0], list[1])
                }
            }
        } else {
            println "$hashFile does not exist"
        }
        return hashMap
    }
    /**
     * 根據傳入的鍵值對其進行格式化(用:分割)
     * @param path
     * @param hash
     * @return
     */
    public static format(String path, String hash) {
        return path + MAP_SEPARATOR + hash + "\n"
    }
}

以及一個文件操作的工具類

class NuwaFileUtils {
    /**
     * 創建文件
     * @param dir
     * @param path
     * @return
     */
    public static File touchFile(File dir, String path) {
        def file = new File("${dir}/${path}")
        file.getParentFile().mkdirs()
        return file
    }

    /**
     * 寫入字節碼到文件
     * @param bytes
     * @param file
     * @return
     */
    public static copyBytesToFile(byte[] bytes, File file) {
        if (!file.exists()) {
            file.createNewFile()
        }
        FileUtils.writeByteArrayToFile(file, bytes)
    }
    /**
     * 獲得控制台傳入的屬性對應的文件夾
     * @param project
     * @param property
     * @return
     */
    public static File getFileFromProperty(Project project, String property) {
        def file
        if (project.hasProperty(property)) {
            /**
             * ./gradlew clean nuwaQihooDebugPatch -P NuwaDir=/Users/jason/Documents/nuwa
             * 獲得NuwaDir對應的目錄,即上次發包的mapping和hash文件所在目錄
             */
            file = new File(project.getProperties()[property])
            if (!file.exists()) {
                /**
                 * 文件夾不存在扔異常
                 */
                throw new InvalidUserDataException("${project.getProperties()[property]} does not exist")
            }
            if (!file.isDirectory()) {
                /**
                 * 不是目錄扔異常
                 */
                throw new InvalidUserDataException("${project.getProperties()[property]} is not directory")
            }
        }
        return file
    }

    /**
     * 獲得不同variant對應的目錄下的文件
     * 如/qihoo/debug
     * @param dir
     * @param variant
     * @param fileName
     * @return
     */
    public static File getVariantFile(File dir, def variant, String fileName) {
        return new File("${dir}/${variant.dirName}/${fileName}")
    }

}

最後,總結一下NuwaGradle的流程。

首先判斷preDex這個Task是否存在如果不存在,則對dex的輸入文件進行遍歷,這些輸入文件是一系列的jar,對這些jar進行判斷,看其是否滿足注入字節碼的條件,如果滿足,對jar文件中滿足條件的class文件進行遍歷注入字節碼,然後刪除原來的jar,將處理後的文件命名為原來的文件。如果存在這個preDex,將這個preDexTask的輸入文件進行字節碼注入操作,這個Task的輸入文件是一系列的jar文件,這些jar是所有的庫工程和第三方jar包,此外,還需要將主工程的class文件進行處理。完成了注入字節碼操作後,需要對其進行dex操作,也就是最終的patch文件。這個patch文件可以直接被客戶端加載並進行熱修復。不能注入字節碼的類是Application的子類,因為Hack.apk在程序運行之前沒有被加載,所以如果Application類中引用了Hack.apk中的Hack.class文件,則會報Class找不到的異常,之後也永遠找不到了。所以這個類不能注入字節碼,但是需要提前加載初始化方法中動態加載該Hack.apk。發版時的mapping文件以及所有class文件的hash值的文件需要保持下來打patch使用。

Gradle可以做很多自動化的東西

Gradle可以做很多自動化的東西

Gradle可以做很多自動化的東西

重要的事說三遍

   
  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved