Android 中外掛化學習—教你實現熱補丁動態修復

codeGoogle發表於2017-07-25

文章背景

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

文章目標

  • android類載入機制介紹
  • javassist動態修改位元組碼
  • 實現熱補丁動態修復
  • Android類載入機制

    1.ClassLoader體系結構

    classloader
    classloader

2、如何載入一個類

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

cl11
cl11

  • 根據截圖可以看到裡面有一個findClass方法
  • 它就是根據類名來查詢指定的某一個類
  • 然後在該方法中呼叫了 DexPathList 例項的pathList.findClass(name, suppressedExceptions)的方法

cl12
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類

package ydc.hotfix;
public class BugClass {

    public String bug() {
        return "fix bug class";
    }
}複製程式碼

在新建一個LoadBugClass類

public class LoadBugClass {
    public String getBugString() {
        BugClass bugClass = new BugClass();
        return bugClass.bug();
    }
}複製程式碼

注意 LoadBugClass應用了BugClass類。

然後在介面層是這樣呼叫的:

13
13

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

public String bug() {
        return "fix bug class";
    }複製程式碼

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

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

通過dex工具單獨打包成path_dex.jar補丁包

  • 1、配置dex環境變數,最好是對應版本。
    cl14
    cl14
  • 2、驗證dex

cl15
cl15

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

jar cvf path.jar ydc/hotfix/BugClass.class複製程式碼

cl16
cl16

  • 4、做成補丁包path_dex.jar
    再把path.jar做成補丁包path_dex.jar,只有通過dex工具打包而成的檔案才能被Android虛擬機器(dexopt)執行。

依然在該路徑下執行以下命令:

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

cl17
cl17

  • 5、拷貝path_dex

    我們把path_dex檔案拷貝到assets目錄下

    cl18
    cl18

開始來打補丁

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

首先我們看一下hotfix的原始碼:

cl19
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();複製程式碼

得到沒有打補丁之前的dexElements有序陣列物件

  • 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
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
    cl21

    補丁包path_dex.jar轉化為dexElements物件

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

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

根據該類的系統原始碼看出其實該類的建構函式並沒有做具體的事情

cl22
cl22

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

cl23
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) {
       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;
    }複製程式碼

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

很不幸,執行時報錯:

cl24
cl24

-這是由於LoadBugClass引用了BugClass,但是發現這這兩個類所在的dex不在一起,其中:

  • LoadBugClass在classes.dex中
  • BugClass在path_dex.jar中結果發生了錯誤。

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

阻止LoadBugClass打上CLASS_ISPREVERIFIED標誌

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

  • 1、動態被注入類的製作

    • a、新建一個hackdex的Module,我這裡來自HotFix的原始碼,你也可以自己新建

cl25
cl25

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

    ```
    package dodola.hackdex;

/**

  • Created by sunpengfei on 15/11/3.
    */
    public class AntilazyLoad {
    }
    ```
    c、打包成單獨的dex檔案,打包步驟完全等同於補丁包的製作,所以我這裡就不在走這個過程了,然後把它放置在assets下

cl26
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這貨是個好東西啊,它可以以無侵入的方式重構你的原始碼。我之前編寫過另外一個三劍客之一的文章,原理基本一樣。參考地址:blog.csdn.net/xinanheisha…

步驟
  • a、建立buildSrc模組,這個專案是使用Groovy開發的,據說這貨具備Java, JavaScript, Phython, Ruby等等語言的優點,而且Groovy依賴於Java的,和Java無縫掛接的

    • 你可以到這裡下載SDK:groovy-lang.org/download.ht…
    • 然後,配置path環境變數,Groovy的安裝挺簡單的,基本上和JDK的安裝差不多
    • 當然,這是Groovy自帶的最基本的開發工具,你可以檢視它如何支援as的
    • 如果是eclipse的話選擇選單項“Help->Install New Software”之後重啟eclipse工具即可利用eclipse開發Groovy應用程式了
    • 但是工程名一定要叫”buildSrc”,這裡我就直接使用了HotFix,你也可以自己構建,若你覺得閒麻煩,也可以下載我的demo裡面獲取。

    cl27
    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](http://upload-images.jianshu.io/upload_images/4614633-fa0bae9c8d2cd99e?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

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](http://upload-images.jianshu.io/upload_images/4614633-480c93281a78a0ca?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

在配置一下,侵入時期

![cl30](http://upload-images.jianshu.io/upload_images/4614633-788d25a94c10e7ad?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

ok,總算把整個過程寫完了,準備開始執行了,不管你激不激動,反正本人是挺激動的了。在執行之前,先看一下我們的引用者類

![cl31](http://upload-images.jianshu.io/upload_images/4614633-0e9fd52e4d9d09b4?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
沒錯,可以確認這是我們的原始碼,化成灰我也可以認出它來。在看一下執行之後的引用者類

![cl32](http://upload-images.jianshu.io/upload_images/4614633-5fe92167e9cd0644?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

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

ok,我們見證一下奇蹟。

![cl33](http://upload-images.jianshu.io/upload_images/4614633-dbffea3064afe73e?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

看到這效果,我手已累,鍵盤已壞。。。。
>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
cl34

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

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

原文地址

blog.csdn.net/xinanheisha…

專案相關:

相關demo下載地址:

download.csdn.net/download/xi…

如果你覺得此文對您有所幫助,歡迎入群 QQ交流群 :232203809
微信公眾號:終端研發部

技術+職場
技術+職場

相關文章