天天看點

循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐

參考資料

https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a&scene=1&srcid=1106Imu9ZgwybID13e7y2nEi#wechat_redirect

https://github.com/dodola/HotFix

http://blog.csdn.net/lmj623565791/article/details/49883661

http://www.cnblogs.com/alibaichuan/p/5863616.html

http://blog.csdn.net/huachao1001/article/details/51819972

什麼是熱修複?

1.正常開發流程

循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐

從流程來看,傳統的開發流程存在很多弊端:

  • 重新釋出版本代價太大
  • 使用者下載下傳安裝成本太高
  • BUG修複不及時,使用者體驗太差

2.熱修複開發流程

循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐

熱修複的開發流程顯得更加靈活,優勢很多:

  • 無需重新發版,實時高效熱修複
  • 使用者無感覺修複,無需下載下傳新的應用,代價小
  • 修複成功率高,把損失降到最低

前言

熱修複作為當下熱門的技術,在業界内比較著名的有阿裡巴巴的AndFix、Dexposed,騰訊QQ空間的熱更新檔動态修複和微信的Tinker。本文基于QQ空間的熱更新檔動态修複方案。

關于Gradle 1.5版本以前對HotFix的分析文章,網上有很多,推薦鴻洋大大的部落格

http://blog.csdn.net/lmj623565791/article/details/49883661

但在Gradle 1.5開始,preDex和Dex這兩個task已經消失,取而代之的是com.android.build.api.transform.Transfrom,這也就導緻了目前的Gradle版本有很多問題要處理。

Google Transform API

http://tools.android.com/tech-docs/new-build-system/transform-api

本文旨在讓以前從來沒有接觸過HotFix的同學從零學習,而不需切換到Gradle 1.5以前的版本。

需要掌握的知識

  • Groovy語言:掌握基本文法和閉包即可
  • Gradle插件開發:掌握目前項目插件開發即可
  • Javassist:掌握基本動态改變類結構方法即可
  • Dex分包

針對Groovy語言和Javassist相關方法介紹,網上有很多相關資料,在這裡不再重複。本文隻需掌握Groovy基本文法,閉包以及Javassist動态改變類結構方法即可。

Javassist API

http://jboss-javassist.github.io/javassist/html/index.html

其餘知識,下面做一個簡單介紹。

Gradle插件開發

關于Gradle插件開發,本文就建立目前項目插件做一個簡單總結。

  1. File->New->New Module->選擇Phone Module或Android Library->将Application Name及Module Name改為buildsrc。注意這裡如果要建立目前項目插件,必須改為buildsrc,其餘名稱均不可。
  2. 将該子產品除src/main及build.gradle外檔案全部删除,将build.gradle内内容清空。
  3. 在main目錄下建立groovy檔案夾。
  4. 在groovy檔案夾下建立包,并建立檔案,命名為HotFixInjectPlugin.groovy
  5. 修改build.gradle檔案如下
apply plugin: 'groovy'

dependencies {
    compile gradleApi()
    compile localGroovy()
}

repositories {
    jcenter()
}
           

建立的HotFixInjectPlugin.groovy需要實作Plugin接口,代碼如下。

package zjm.cst.dhu.plugin

import org.gradle.api.Plugin
import org.gradle.api.Project

public class HotFixInjectPlugin implements Plugin<Project> {


    @Override
    void apply(Project project) {
        println("--->Apply HotFixInjectPlugin")
    }
}
           

在App的build.gradle中加入

當App應用該插件時即會調用apply方法。

注意,若更改插件子產品中内容,請先clean工程後再make工程

Dex分包

關于Dex分包基本概念與原理,網上有很多相關資料,這裡就不再談了,但具體實作大多是Gradle 1.5版本以前的。而Gradle 1.5以後版本,由于dex任務取消,導緻之前在build.gradle中控制分包參數失效,而替換的則是dexOptions參數。

dexOptions {
        javaMaxHeapSize "4g"
        preDexLibraries = false
        additionalParameters = ['--multi-dex', '--main-dex-list=' + project.rootDir.absolutePath + '/app/maindexlist.txt', '--minimal-main-dex','--set-max-idx-number=30000']
    }
           

其中maindexlist.txt檔案為最先加載class清單。将\app\build\intermediates\multi-dex\debug目錄下的maindexlist.txt複制到app目錄下,并在其中加入我們希望分到主dex的class檔案即可。

HotFix原理

關于HotFix基本原理,QQ空間團隊的文章已有介紹

https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a&scene=1&srcid=1106Imu9ZgwybID13e7y2nEi#wechat_redirect

這裡我總結一下HotFix的原理。

  • 類加載器中存放dex的數組位置位于ClassLoader中DexPathList執行個體 pathList中。具體為DexPathList類中dexElements數組。關于對ClassLoader及DexPathList源碼研究,後面在使用反射機制插入dex時也會用到。

BaseDexClassLoader源碼

http://androidxref.com/4.4.2_r1/xref/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java

DexPathList源碼

http://androidxref.com/4.4.2_r1/xref/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java#splitLibraryPath

  • 系統在dex校驗過程中,若clazz與static方法/private方法/構造函數等直接引用到的類在同一個dex中時,會被打上CLASS_ISPREVERIFIED标記。但由于我們需要在dexElements數組頭插入更新檔的dex,插入後,與它直接引用到的類不在同一個dex,導緻驗證時會報錯。是以我們需要在clazz的構造函數中直接引用與它不在同一個dex的類以防止打上标記。
  • 在所有類構造函數中加入System.out.println(AntilazyLoad.class),我們通過javassist擷取并修改每個類的構造器方式,向其添加該語句。
  • 我們需要在class轉換為dex檔案前增加該語句。我們通過自定義Gradle插件,并注冊自定義Transform,執行javassist相關任務。
  • 由于所有類都在構造函數中需要AntilazyLoad.class,是以我們需要在dexElements數組頭優先插入AntilazyLoad.class單獨打包的hack_dex.jar。

下面給出我畫的HotFix基本的流程圖,針對一些關鍵步驟,我下面具體分析。

循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐

自定義Transform

上面已經講了如何自定義Gradle插件,那麼我們隻需在apply方法中注冊Transform即可。

@Override
    void apply(Project project) {
        def android = project.extensions.findByType(AppExtension)
        android.registerTransform(new HotFixInjectTransform(project))
    }
           

關鍵點在于如何自定義Transform。

我們先看一下Transform的過程

循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐

其實很簡單Transform就是将一個輸入處理,最後輸出的過程,那麼我們自定義Transform其實就是做處理的過程。

我們先像之前那樣在插件子產品中建立HotFixInjectTransform.groovy。這裡直接貼出代碼,注釋已經很明白了。

package zjm.cst.dhu.plugin

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.FileUtils
import org.gradle.api.Project

public class HotFixInjectTransform extends Transform {

    Project project

    // 構造函數,我們将Project儲存下來備用
    public HotFixInjectTransform(Project project) {
        this.project = project
    }

    // 設定我們自定義的Transform對應的Task名稱
    @Override
    String getName() {
        return "HotFixInjectTransform"
    }

    // 指定輸入的類型,通過這裡的設定,可以指定我們要處理的檔案類型
    //這樣確定其他類型的檔案不會傳入
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    // 指定Transform的作用範圍
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs,
                   Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental)
            throws IOException, TransformException, InterruptedException {
        // Transform的inputs有兩種類型,一種是目錄,一種是jar包,要分開周遊
        inputs.each { TransformInput input ->
            //對類型為“檔案夾”的input進行周遊
            input.directoryInputs.each { DirectoryInput directoryInput ->
                //檔案夾裡面包含的是我們手寫的類以及R.class、BuildConfig.class以及R$XXX.class等
                HotFixInjectProcess.injectDir(directoryInput.file.absolutePath, "zjm\\cst\\dhu\\hotfix")
                // 擷取output目錄
                def dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes,
                        Format.DIRECTORY)
                // 将input的目錄複制到output指定目錄
                FileUtils.copyDirectory(directoryInput.file, dest)
            }
            //對類型為jar檔案的input進行周遊
            input.jarInputs.each { JarInput jarInput ->
                //jar檔案一般是第三方依賴庫jar檔案
                // 重命名輸出檔案(同目錄copyFile會沖突)
                def jarName = jarInput.name
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(, jarName.length() - )
                }
                //生成輸出路徑
                def dest = outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)
                //将輸入内容複制到輸出
                FileUtils.copyFile(jarInput.file, dest)
            }
        }
    }
}
           

HotFixInjectProcess其實就是我們具體處理目錄類型class的類。

與之前一樣,建立HotFixInjectProcess.groovy。直接貼出代碼。

package zjm.cst.dhu.plugin

import javassist.ClassPool
import javassist.CtClass
import javassist.CtConstructor

public class HotFixInjectProcess {
    private static ClassPool pool = ClassPool.getDefault()
    static String injectStr = "System.out.println(zjm.cst.dhu.hotfix.hack.AntilazyLoad.class); ";

    public static void injectDir(String path, String packageName) {
        pool.appendClassPath(path)
        File dir = new File(path)
        if (dir.isDirectory()) {
            dir.eachFileRecurse { File file ->
                String filePath = file.absolutePath
                //確定目前檔案是class檔案,并且不是系統自動生成的class檔案
                if (filePath.endsWith(".class")
                        && !filePath.contains('R$')
                        && !filePath.contains('R.class')
                        && !filePath.contains("BuildConfig.class")) {
                    // 判斷目前目錄是否是在我們的應用包裡面
                    int index = filePath.indexOf(packageName);
                    boolean isMyPackage = index != -;
                    if (isMyPackage) {
                        int end = filePath.length() -  // .class = 6
                        String className = filePath.substring(index, end).replace('\\', '.').replace('/', '.')
                        //開始修改class檔案
                        CtClass c = pool.getCtClass(className)
                        //若已加載則修改
                        if (c.isFrozen()) {
                            c.defrost()
                        }
                        //擷取預設構造器
                        CtConstructor[] cts = c.getDeclaredConstructors()
                        pool.importPackage("android.util.Log");
                        if (cts == null || cts.length == ) {
                            //手動建立一個構造函數
                            CtConstructor constructor = new CtConstructor(new CtClass[], c)
                            constructor.insertBeforeBody(injectStr)
                            c.addConstructor(constructor)
                        } else {
                            cts[].insertBeforeBody(injectStr)
                        }
                        c.writeFile(path)
                        c.detach()
                    }
                }
            }
        }
    }
}
           

這裡我們就運用到了javassist,擷取該包下所有class的預設構造器,并添加注入語句。

到這裡,自定義Transform就結束啦。

執行完transform後,我們就需要插入hack_dex.jar,那麼我們就遇到了一個問題,如何将AntilazyLoad.java轉化為hack_dex.jar。

java代碼打包成獨立Dex包

将java代碼打包成獨立的Dex包,我們需要兩步。第一步,将java代碼編譯生成class檔案,第二步,将class檔案通過dx工具進行打包轉化。

将java代碼編譯生成class檔案很簡單,我們build一下項目,然後在app/build/intermediates/classes/debug中就可以找到編譯好的class檔案。

将class轉化打包成jar,我們需要用到build-tools中的dx.bat指令。

首先,我們先将class一般打包。

然後使用我們在Android SDK,build-tools檔案夾下的dx.bat指令

這樣我們就獲得了hack_dex.jar包

我們用相同的方法把需要打更新檔的class打包成patch_dex.jar。

插入jar包

我們之前已經擷取到了需要的兩個包,一個hack,一個patch。這裡我就插入hack包做一個例子。

//加載hack.AntilazyLoad包
        //在私有目錄下建立hack_dex.jar
        File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hack_dex.jar");
        try {
            Utils.prepareDex(this.getApplicationContext(), dexPath, "hack_dex.jar");
        } catch (IOException e) {
            e.printStackTrace();
        }
        HotFix.patch(this, dexPath.getAbsolutePath(), "zjm.cst.dhu.hotfix.hack.AntilazyLoad");
           

這就是Application中onCreate方法,插入hack包的代碼。

首先,我們将更新檔包放入assets資源檔案夾中,然後在App私有目錄下建立該名稱的JAR。

接着,就是Util.prepareDex(),将assets中檔案寫入該目錄。代碼如下。

//讀取assets檔案夾下檔案并寫入新路徑
    public static boolean prepareDex(Context context, File dexInternalStoragePath, String dex_file) throws IOException {
        BufferedInputStream bis = null;
        OutputStream dexWriter = null;
        bis = new BufferedInputStream(context.getAssets().open(dex_file));
        dexWriter = new BufferedOutputStream(new FileOutputStream(dexInternalStoragePath));
        byte[] buf = new byte[BUF_SIZE];
        int len;
        while ((len = bis.read(buf, , BUF_SIZE)) > ) {
            dexWriter.write(buf, , len);
        }
        dexWriter.close();
        bis.close();
        return true;

    }
           

最後就是HotFix.patch(),這個就是具體插入的方法,通過反射機制,修改相關屬性。比較複雜,我們單獨拿出來看。

public static void patch(Context context, String patchDexFile, String patchClassName) {
        if (patchDexFile != null && new File(patchDexFile).exists()) {
            try {
                if (hasLexClassLoader()) {
                    //阿裡類構造器
                    injectInAliyunOs(context, patchDexFile, patchClassName);
                } else if (hasDexClassLoader()) {
                    //高于API 14 類構造器
                    injectAboveEqualApiLevel14(context, patchDexFile, patchClassName);
                } else {
                    //低于API 14 類構造器
                    injectBelowApiLevel14(context, patchDexFile, patchClassName);

                }
            } catch (Throwable th) {
            }
        }
    }
           

首先判斷檔案是否存在,接着三個判斷語句,我們這裡隻針對高于API 14的類構造器進行講解。

進入方法的前提是

private static boolean hasDexClassLoader() {
        try {
            Class.forName("dalvik.system.BaseDexClassLoader");
            return true;
        } catch (ClassNotFoundException e) {
            return false;
        }
    }
           

判斷是否存在BaseDexClassLoader。這個類是不是看起來很眼熟?沒錯,就是之前我們在HotFix原理中所講到的類。在原理中,我提到了插入dex,實際上是将dex插入到BaseDexClassLoader類中DexPathList執行個體 pathList的dexElements數組前。那麼接下來的injectAboveEqualApiLevel14方法其實也就是這個過程。

我們來看這個方法。

private static void injectAboveEqualApiLevel14(Context context, String str, String str2)
            throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {

        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
        Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
                getDexElements(getPathList(
                        //用私有目錄下檔案初始化新類構造器
                        new DexClassLoader(str, context.getDir("dex", ).getAbsolutePath(), str, context.getClassLoader()))));
        Object a2 = getPathList(pathClassLoader);
        //替換dexElements屬性
        setField(a2, a2.getClass(), "dexElements", a);
        pathClassLoader.loadClass(str2);
    }
           

其實就是将新的dex檔案放在數組頭,剩餘的dex放在尾部,形成一個新的對象。并用這個對象替換原對象的dexElements屬性即可。

我們來看這其中用到的剩餘方法。

//獲得BaseDexClassLoader的pathlist
    private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,
            IllegalAccessException {
        return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    //獲得該對象的dexElements
    private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
        return getField(obj, obj.getClass(), "dexElements");
    }

    //擷取該對象obj的str屬性
    private static Object getField(Object obj, Class cls, String str)
            throws NoSuchFieldException, IllegalAccessException {
        Field declaredField = cls.getDeclaredField(str);
        declaredField.setAccessible(true);
        return declaredField.get(obj);
    }

    //向該對象obj的str屬性設定新值
    private static void setField(Object obj, Class cls, String str, Object obj2)
            throws NoSuchFieldException, IllegalAccessException {
        Field declaredField = cls.getDeclaredField(str);
        declaredField.setAccessible(true);
        declaredField.set(obj, obj2);
    }

    //将obj2插入到obj前
    private static Object combineArray(Object obj, Object obj2) {
        Class componentType = obj2.getClass().getComponentType();
        int length = Array.getLength(obj2);
        int length2 = Array.getLength(obj) + length;
        Object newInstance = Array.newInstance(componentType, length2);
        for (int i = ; i < length2; i++) {
            if (i < length) {
                Array.set(newInstance, i, Array.get(obj2, i));
            } else {
                Array.set(newInstance, i, Array.get(obj, i - length));
            }
        }
        return newInstance;
    }
           

就像我剛才所講,運用java的反射機制,将dexElements屬性替換。

實踐

我們先建立一個Activity,TextView文本被設定為is a test。然後執行個體化一個空對象

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.layout_test);
        TextView tv=(TextView) findViewById(R.id.text);
        tv.setText("is a test");
        System.out.println("==========start===============");
        new Test();
        System.out.println("==========end===============");
    }
           

但是呢,我們這裡隻插入System.out.println(zjm.cst.dhu.hotfix.hack.AntilazyLoad.class);,并且把hack_dex.jar插入到頭部。但不把更新檔包插入。我們看一下結果

循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐
循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐

已經成功把System.out.println(zjm.cst.dhu.hotfix.hack.AntilazyLoad.class);插入進去啦。

這時候我們修改一下Activity,把TextView文本内容設定為fix Success。然後生成更新檔包,插入更新檔。看一下結果。

循序漸進學HotFix (基于Gradle 1.5+版本)什麼是熱修複?前言Gradle插件開發Dex分包HotFix原理自定義Transformjava代碼打包成獨立Dex包插入jar包實踐

更新檔成功打入啦。

源碼位址:

https://github.com/CMonoceros/HotFix