Android中實現熱補丁動態修復

文章背景

在做互聯網app項目的時候,當我們發佈迭代了一個新版本,把apk發佈到各個Android應用市場上時,由於程序猿或是程序媛在編碼上的疏忽,突然出現了一個緊急Bug時,通常的做法是重新打包,重新發布到各個應用市場,這不僅給公司相關部門增加大量工作量外,好比古時候皇帝下放一道緊急命令時,從州到縣到鎮到村,整條線都提着腦袋忙得不可交,搞的人心惶惶,而且更嚴重的是最終給用戶帶來的是重新下載覆蓋安裝,在一定程度上會流失用戶,嚴重影響了公司的用戶流量。在這種場景我們應該採用熱補丁動態修復技術來解決以上這些問題。可以選擇現成的第三方熱修復SDK,我在這裏不選擇的原因,主要出於兩點:1、使用第三方SDK有可能增大我們的項目包,而且總感覺受制於人;2、追逐技術進階

文章目標

Android類加載機制介紹
javassist動態修改字節碼
實現熱補丁動態修復

Android類加載機制

1.ClassLoader體系結構

classloader

2、如何加載一個類

我們先來看一下BaseDexClassLoader源碼中比較重要的code

cl11

根據截圖可以看到裏面有一個findClass方法,沒錯它就是根據類名來查找指定的某一個類。然後在該方法中調用了 DexPathList 實例的pathList.findClass(name, suppressedExceptions)的方法,我們進到這個方法看看

cl12

可以看出最終在此處找到了某一個類

 Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);

到這裏我們可以直觀的看出該過程是基於android dex分包方案的。其實最終我們打包apk時可能有一個或是多個dex文件,默認是一個叫classes.dex的文件。不管是一個還是多個,都會一一對應一個Element,按順序排成一個有序的數組dexElements,當找類的時候,會按順序遍歷dex文件,然後從當前遍歷的dex文件中找類,如果找類則返回,如果找不到從下一個dex文件繼續查找。

按照這個原理,我們可以把有問題的類打包到一個dex(patch.dex)中去,然後把這個dex插入到Elements的最前面,當遍歷findClass的時候,我們修復的類就會被查找到,從而替代有bug的類即可,那麼下面來進行這一個過程的操作吧。

patch.dex補丁製作

新建一個Hotfix的工程,然後新建一個BugClass類

/*
 * Copyright (C) 2015 Baidu, Inc. All Rights Reserved.
 */
package ydc.hotfix;


/**
 * Created by sunpengfei on 15/11/3.
 */
public class BugClass {

    public String bug() {
        return "fix bug class";
    }
}

在新建一個LoadBugClass類

/*
 * Copyright (C) 2015 Baidu, Inc. All Rights Reserved.
 */
package ydc.hotfix;

/**
 * Created by sunpengfei on 15/11/4.
 */
public class LoadBugClass {
    public String getBugString() {
        BugClass bugClass = new BugClass();
        return bugClass.bug();
    }
}

注意LoadBugClass應用了BugClass類。

然後在界面層是這樣調用的:
13

ok,假設我們把該apk發佈出去了,那麼用戶看到效果應該是“ 測試調用方法:fix bug class”。這個時候公司領導認爲這樣的提示對於用戶是致命的。那麼我們要把BugClass 類中的bug()方法中字符串替換一下,僅僅是修復一句話而已,實在沒有必要走打包發佈下放市場等複雜的流程。

public String bug() {
        return "fix bug class";
    }

ok,把這個有問題的地方修正爲:

  public String bug() {
        return "楊德成正在修復提示語fix bug class";
    }

ok,我們把BugClass類使用dex工具單獨打包成path_dex.jar補丁包

Step

1、配置dex環境變量,最好是對應版本。

cl14

2、驗證dex

cl15

3、先把BugClass.class文件做成成jar,注意路徑,一定要定位到該位置執行以下命令:

jar cvf path.jar ydc/hotfix/BugClass.class

cl16

4、再把path.jar做成補丁包path_dex.jar,只有通過dex工具打包而成的文件才能被Android虛擬機(dexopt)執行。
依然在該路徑下執行以下命令:

dx --dex --output=path_dex.jar path.jar

cl17

5、我們把path_dex文件拷貝到assets目錄下

cl18

ok,這個時候我們可以開始來打補丁

Step

1、將我們的補丁包path_dex插入到上面提到的裝有dex的有序數組dexElements的最前面

首先我們看一下hotfix的源碼:

cl19

根據截圖所示,做了兩個動作

a、創建一個私有目錄,並把補丁包文件寫入到該目錄下
a1、 創建私有目錄

 File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "path_dex.jar");

a2、文件讀寫方式把補丁包文件寫入到剛創建的私有目錄下


public class Utils {
    private static final int BUF_SIZE = 2048;

    public static boolean prepareDex(Context context, File dexInternalStoragePath, String dex_file) {
        BufferedInputStream bis = null;
        OutputStream dexWriter = null;

        try {
            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, 0, BUF_SIZE)) > 0) {
                dexWriter.write(buf, 0, len);
            }
            dexWriter.close();
            bis.close();
            return true;
        } catch (IOException e) {
            if (dexWriter != null) {
                try {
                    dexWriter.close();
                } catch (IOException ioe) {
                    ioe.printStackTrace();
                }
            }
            if (bis != null) {
                try {
                    bis.close();
                } catch (IOException ioe) {
                    ioe.printStackTrace();
                }
            }
            return false;
        }
    }
}

b、path_dex插入到上面提到的裝有dex的有序數組dexElements的最前面

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()) {
                    injectAboveEqualApiLevel14(context, patchDexFile, patchClassName);
                } else {

                    injectBelowApiLevel14(context, patchDexFile, patchClassName);

                }
            } catch (Throwable th) {
            }
        }
    }

根據代碼所示,這根據傳入的文件類型類類加載器ClassLoader的類型做了下判斷,根據上文提到過的ClassLoader 體系原理,我們的補丁包應該走的是hasDexClassLoader()分支,該方法代碼如下:

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

系統中肯定會存在”dalvik.system.BaseDexClassLoader”類,那麼接下來應該進入injectAboveEqualApiLevel14(context, patchDexFile, patchClassName)方法,代碼如下:

 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", 0).getAbsolutePath(), str, context.getClassLoader()))));
        Object a2 = getPathList(pathClassLoader);
        setField(a2, a2.getClass(), "dexElements", a);
        pathClassLoader.loadClass(str2);
    }

根據Android系統源碼解讀源以上代碼

PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();

根據context拿到PathClassLoader,還記得這個類是用來幹嘛的嗎,上面已經提到過,再次提醒一下它是用來加載安裝到Android系統中的apk文件。既然這樣我們可以用它來得到沒有打補丁之前的dexElements有序數組對象

Step

a、getPathList(pathClassLoader)方法解讀:

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

根據以上代碼片段,可以看出這裏根據引用類名稱”BaseDexClassLoader”查找有個叫”pathList”屬性名的被引用類型。

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

上面這個片段通過反射找到對應的被引用類”DexPathList”,上個”BaseDexClassLoader”系統源碼:

cl20

b、getDexElements(getPathList(pathClassLoader))方法解讀:

private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
        return getField(obj, obj.getClass(), "dexElements");
    }

上面的這個代碼片段根據a步驟得到的DexPathList對象獲取到了沒有打補丁之前的dexElements有序數組對象

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

根據代碼可知依然使用反射原理獲取DexPathList對象中的有序數組dexElements。

DexPathList類系統源碼如下:

cl21

將我們的補丁包path_dex.jar轉化爲dexElements對象

Step

a、根據我們在上面所創建的私有目錄及私有文件,創建一個DexClassLoader,還記得這個來是用來幹嘛的嗎,上面已經提到到,再次提醒一下,用來加載從.jar文件內部加載classes.dex文件,沒錯我們要用它來加載我們的補丁包文件。

 new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))

根據該類的系統源碼看出其實該類的構造函數並沒有做具體的事情

cl22

真正做之情的是它的直接父類BaseDexClassLoader的構造函數,如圖所示

cl23

 this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);

看到沒,根據傳入參數初始化了我們補丁包對應的 DexPathList對象,注意這一步僅僅是初始化哦

b、getPathList(
new DexClassLoader(str, context.getDir(“dex”, 0).getAbsolutePath(), str, context.getClassLoader()))方法解讀:

private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,
        IllegalAccessException {
        return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }
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);
    }

上面這兩段代碼根據引用名”dalvik.system.BaseDexClassLoader”和被引用類屬性名”pathList”得到DexPathList對象

c、然後調用getDexElements方法

 private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
        return getField(obj, obj.getClass(), "dexElements");
    }
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);
    }

上面的這兩端片段根據 DexPathList類及屬性名dexElements獲取到我們補丁包對應的有序數組dexElements

上面已經得到了兩個有序數組dexElements,一個存放的的是沒有打補丁之前的dex有序數組dexElements,另外一個是我們的補丁包對應的dex有序數組dexElements,那麼是不是到了該合併兩個數組的時候了呢,沒錯

 Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
            getDexElements(getPathList(
                new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))));

到這裏終於知道這整句代碼到底幹了什麼事情了,Object a 就是我們合併後的有序dex數組dexElements

合併過程如下:

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

其實就是把補丁包對應的dex插入到原來有序數組dexElements的最前面了。

d、得到最新的”PathList”對象

  Object a2 = getPathList(pathClassLoader);

e、重新設置DexPathList 的有序數組對象dexElements值

setField(a2, a2.getClass(), “dexElements”, a);

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

依然是使用反射機制設置新值。

f、加載我們有bug的類

 pathClassLoader.loadClass(str2);

str參數是通過以下代碼傳入,即(ydc.hotfix.BugClass)

 HotFix.patch(this, dexPath.getAbsolutePath(), "ydc.hotfix.BugClass");

這時候loadClass到的就是我們補丁包中的BugClass類了,這是因爲我們把補丁包對應的dex文件插入到dexElements最前面。所以找到就BugClass直接返回了,代碼如下:

 public Class findClass(String name, List<Throwable> suppressed) {
317        for (Element element : dexElements) {
318            DexFile dex = element.dexFile;
319
320            if (dex != null) {
321                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
322                if (clazz != null) {
323                    return clazz;
324                }
325            }
326        }
327        if (dexElementsSuppressedExceptions != null) {
328            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
329        }
330        return null;
331    }

按照我們之前的推論,到這裏應該就完成了補丁動態修復了,那麼真的是這樣的嗎,我們不防運行下項目看看。

很不幸,運行時報錯:

cl24

這是由於LoadBugClass引用了BugClass,但是發現這這兩個類所在的dex不在一起,其中:
1. LoadBugClass在classes.dex中
2. BugClass在path_dex.jar中
結果發生了錯誤。

究其原因是 pathClassLoader.loadClass(str2)的時候,會去校驗LoadBugClass所在的dex和BugClass所在的dex是否是同一個,不是則會報錯。那麼校驗的前提是有一個叫CLASS_ISPREVERIFIED的類標誌,如果引用者被打上這個標識,就會去校驗,就會導致報錯,那麼我們可以想象如果引用者LoadBugClass 沒被打上這個標識,是否就會運行通過了呢,沒錯,就是這個原理。

阻止LoadBugClass打上CLASS_ISPREVERIFIED標誌

我們應該知道LoadBugClass引用了BugClass,類加載器是先加載引用者,所以我在LoadBugClass的構造方法中來做這件事情,其實我們要做的就是動態的在構造方法中,引用一個別的類,然後把這個被引用類打包成一個單獨的dex文件。這樣就可以防止了LoadBugClass類被打上CLASS_ISPREVERIFIED的標誌了,那我們現在來開始做這件事情。

Step

1、動態被注入類的製作

a、新建一個hackdex的Module,我這裏來自HotFix的源碼,你也可以自己新建

cl25

b、在該Module之下,新建一個AntilazyLoad空類。

package dodola.hackdex;

/**
 * Created by sunpengfei on 15/11/3.
 */
public class AntilazyLoad {
}

c、打包成單獨的dex文件,打包步驟完全等同於補丁包的製作,所以我這裏就不在走這個過程了,然後把它放置在assets下

cl26

d、依然要把這個dex文件插入到dexElements有序數組的中,插入原理和補丁包插入原理完全一致,而且這個dex文件需要在程序的入口進行插入,保證它是在有序數組的最前面,因爲我們要把該dex文件中的AntilazyLoad要動態注入到其它包裏面的某一個類的構造方法中。切記,dexElements裏面可以塞入無數個dex文件。


/**
 * Created by sunpengfei on 15/11/4.
 */
public class HotfixApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hackdex_dex.jar");
        Utils.prepareDex(this.getApplicationContext(), dexPath, "hackdex_dex.jar");
        HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hackdex.AntilazyLoad");
        try {
            this.getClassLoader().loadClass("dodola.hackdex.AntilazyLoad");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

    }
}

ok,下面就是如何注入的問題了,這個時候應該到了我們的AOP三劍客之一”javassist”閃亮登場了。

javassist實現動態代碼注入

javassist這貨是個好東西啊,它可以以無侵入的方式重構你的原代碼。我之前編寫過另外一個三劍客之一的文章,原理基本一樣。參考地址:http://blog.csdn.net/xinanheishao/article/details/74082605

Step

a、創建buildSrc模塊,這個項目是使用Groovy開發的,據說這貨具備Java, Javascript, Phython, Ruby等等語言的優點,而且Groovy依賴於Java的,和Java無縫掛接的,你可以到這裏下載SDK:http://groovy-lang.org/download.html;然後,配置path環境變量,Groovy的安裝挺簡單的,基本上和JDK的安裝差不多, 當然,這是Groovy自帶的最基本的開發工具,你可以查看它如何支持as的,如果是eclipse的話選擇菜單項“Help->Install New Software”之後重啓eclipse工具即可利用eclipse開發Groovy應用程序了,但是工程名一定要叫”buildSrc”,這裏我就直接使用了HotFix,你也可以自己構建,若你覺得閒麻煩,也可以下載我的demo裏面獲取。

cl27

b、導入javassist


apply plugin: 'groovy'

repositories {
    mavenCentral()
}

dependencies {
    compile gradleApi()
    compile 'org.codehaus.groovy:groovy-all:2.3.6'
    compile 'org.javassist:javassist:3.20.0-GA'
}

c、PatchClass 代碼截圖如下

cl28

其實很簡單的,這幾句的意思就是通過反射相關類,然後在相關類的構造方法中插入一句輸出語句。

 CtClass c = classes.getCtClass("ydc.hotfix.BugClass")
        if (c.isFrozen()) {
            c.defrost()
        }
        println("====添加構造方法====")
        def constructor = c.getConstructors()[0];
        constructor.insertBefore("System.out.println(dodola.hackdex.AntilazyLoad.class);")
        //constructor.insertBefore("System.out.println(888);")
        c.writeFile(buildDir)

執行完這段代碼之後,也無形中應用了AntilazyLoad這個類。

d、這個工程不需要引用到主app(Module)中,只需要在 app->build.gradle中配置一個任務:

cl29

在配置一下,侵入時期

cl30

ok,總算把整個過程寫完了,準備開始運行了,不管你激不激動,反正本人是挺激動的了。

在運行之前,先看一下我們的引用者類

cl31

沒錯,可以確認這是我們的源代碼,化成灰我也可以認出它來。

在看一下運行之後的引用者類

cl32

沒錯,就是這個效果,我們的源碼被javassist 赤裸裸的侵犯了,是不是瞬間覺的自己的“東西”很不安全,這就是AOP編程的強大之處啊。

項目講解到這裏,我想估計沒有幾個人能有耐心的看到這裏來了,因爲覺得文章實在太長,需要有多大耐心才能扛到這裏,連我自己也懷疑自己如何寫出來的,不過我認爲,這麼強大而且實用的技術點,不是能夠三五兩語就能說清的,我們要有足夠的耐心來探索我們所不知的,有耐心,我們就有希望,有希望就不會失望!

ok,我們見證一下奇蹟。

cl33

看到這效果,我手已累,鍵盤已壞。。。。

Demo下載地址:
http://download.csdn.net/download/xinanheishao/9902530

演示環境:
demo導入不能正常運行,建議先調整環境,跑起來,再進階。

 classpath 'com.android.tools.build:gradle:1.3.0'
#Thu Jul 13 16:40:06 CST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-2.2-all.zip

如果默認的jdk環境找不到,手動指向一下

cl34

如果你已經準備好足夠信心的話,可以按照文章,自己嘗試一方

最後感謝騰訊空間給出的解決方法思路和HotFix開源作者。

如果對你有所幫助的話,賞我1元奶粉錢吧,多謝!

微信:

001

支付寶:

002

發佈了53 篇原創文章 · 獲贊 58 · 訪問量 12萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章