DexClassLoader熱修復的入門到放棄

cuieney發表於2017-06-27

前提

寫這篇文章的目的也是為了瞭解android原始碼及hack技術,讀了這篇文章相信你也可以瞭解到Dalvik的工作流程,apk的生成過程,及build.gradle中plugin中ApplicationPlugin的Task有哪些,如何通過hack技術來完成hotfix。有興趣的同學也可以看看groovy如何編寫Plugin,及如何優化dex來讓優化app

熱修復需要注意的幾個問題

  • 如何進行hack來達到熱修復
  • hack操作中需要使用哪些class
  • apk是生成的生命週期
  • gradle build 指令碼(groovy)
    我們先了解這些問題後再進行具體的操作步驟,來個循序漸進。問題接下來會一一的詳解
如何進行hack來達到熱修復

為什麼會有熱修復這個東西呢?大家都知道如果我們的線上的app 由於某種原因crash?我們這時候不能怨測試沒測好,後臺介面有變化什麼的,這不是解決問題的最終方式!要是以前我們肯定就是把重新上傳app到各大渠道,從新上線,這個過程嚴重的影響到我們的使用者體驗非常不好,而且很耗時!作為程式設計師如何通過程式碼進行線上修復crash bug。。。呢?所以有了熱修復這個功能 bat 每家都有自己的開源熱修復庫?我這裡就講一下如何通過反射的方式來實現修復功能吧!也就是通過DexClassLoader。如果大家對其他的開源庫想要了解的話可以通過一下傳送門
AndFix
tinker
HotFix
Robust
我這裡也就講一下Dex的方式修復

hack操作中需要使用哪些class

  • 顧名思義DexClassLoader這個必須要用到的
  • javaassist用於程式碼的打樁(就是class檔案程式碼的植入,這裡不詳解了)
  • groovy一個android plugin外掛開發語言 底下會提及到
    -

apk是生成的生命週期

我們的專案如何在編譯的時候變成apk呢?

  1. 第一步當然是把我們的資原始檔生成R.Java檔案了
  2. 處理AIDL檔案,生成對應的.java檔案(當然,有很多工程沒有用到AIDL,那這個過程就可以省了)
  3. 編譯Java檔案,生成對應的.class檔案
  4. 把.class檔案轉化成Davik VM支援的.dex檔案
  5. 打包生成未簽名的.apk檔案
  6. 對未簽名.apk檔案進行簽名
  7. 對簽名後的.apk檔案進行對齊處理(不進行對齊處理是不能釋出到Google Market的)
    我感覺還是貼圖比較靠譜不然看文字沒有感覺

build.png
build.png

這就是一個apk編譯所走的生命週期,但是我們的build指令碼到底走了哪些任務呢。如果想看的話可以在我們module中的build.gradle 加入如下程式碼 即可在console中看到相應的任務

tasks.whenTaskAdded { task ->
    println(task.name+"===")
}複製程式碼

這個就是我們apkbuild的時候的每一個task。既然知道了這些task 那我們如何才能知道這些task到底在後臺做了些什麼呢?

gradle build 指令碼(groovy)

時常見到卻不知道他在幹嘛的一句程式碼,apply plugin: 'com.android.application'如果我們把com.android.application代替為com.android.library,那我們的build目錄下的output那就是aar包了。元件化開發會用到這樣的切換想了解的可以看看(元件化)

想要了解這句話幹嘛的那你必須的知道這個開發語言groovy,他是支援android studio的。我們可以自定義我們想要的外掛,在編譯的時候進行一些好玩的操作。這裡面可以定義許多task,迴歸正題,這句程式碼到底幹了哪些事情呢,那我們就必須的瞭解這個原始碼想要了解的同學可以看看這裡就不多說了,這裡面有我們build中的所有task

####app啟動過程
以上了解了這麼多,接下來就要進入正題了!現在說app的啟動過程,過程就不細說了,因為經歷了很多複雜的過程,我就說一下與DexClassLoader有關的事情吧。

app每次啟動fork一個程式但同時也會同樣會分配一個dalvik虛擬機器供這個app執行,在dalvik中每次執行都需要讀取apk裡面的dex檔案,這樣會耗費很多cpu資源,然後採用odex,把dex檔案優化成odex檔案,那麼odex操作給我們熱修復帶來了哪些問題呢?我們先把這個問題記錄下來,之後會分析具體原因。啟動先說到這!!!

如何進行熱修復

  • 通過上面瞭解了app啟動過程中每次都要通過dvm來載入dex檔案。
  • 同樣大家也知道了dex檔案是由 .java->.class->dex 一步一步轉化來的
    瞭解了上面的兩個重要的東西,熱修復就是每次在我們app啟動的時候載入我們自己的patch.dex檔案而不是載入之前的dex檔案,這樣就可以達到熱修復了(這時大概會有很多同學困惑,dvm怎麼知道就用我們的patch.dex而不用之前的呢?好問題 讓老夫徐徐道來)

動態載入patch.dex

  • 在 Android 中,App 安裝到手機後,apk 裡面的 class.dex 中的 class 均是通過 PathClassLoader 來載入的。
  • DexClassLoader 可以用來載入 SD 卡上載入包含 class.dex 的 .jar 和 .apk 檔案
  • DexClassLoader 和 PathClassLoader 的基類 BaseDexClassLoader 查詢 class 是通過其內部的 DexPathList pathList 來查詢的
  • DexPathList 內部有一個 Element[] dexElements 陣列,其 findClass() 方法(原始碼如下)的實現就是遍歷該陣列,查詢 class ,一旦找到需要的類,就直接返回,停止遍歷:
public Class findClass(String name, List 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;
}複製程式碼

通過上面的步驟是不是知道了我們app每次啟動一個class是如何找到類的呢?現在知道了吧,DexClassLoader -> DexPathList -> Element[]
好的 現在應該有一些系統的瞭解了,通過上面的步驟可以知道 每次查詢類都是通過Element[]中查詢的。如果找到就會return 而不會繼續找!這時候嘿嘿嘿我們知道了他是如何findclass 的那我們就可以悄悄的幹些壞事了(這裡會有一些同學會懵逼,Element[]是什麼鬼)

Element[]是什麼鬼

我們在每次建立DexClassLoader時他的建構函式是這樣的

 public DexClassLoader(String dexPath, String optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }複製程式碼

從原始碼可以看出第一個引數顧名思義是dex路徑,第二個呢可以看看原始碼,第二個要傳一個路徑dex優化後odex的路徑。第三個呢就是父類嗎,直接getClassLoader()就好那麼我們看看他的父類拿這些引數幹了些什麼

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);

        this.originalPath = dexPath;
        this.pathList =
            new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }複製程式碼

父類也就做了一些初始化操作。最主要的是初始化了DexPathList這個類,然後我們看看BaseDexClassLoader裡面的findClass做了些什麼呢原始碼如下

  @Override
protected Class> findClass(String name) throws ClassNotFoundException {
        Class clazz = pathList.findClass(name);

        if (clazz == null) {
            throw new ClassNotFoundException(name);
        }

        return clazz;
    }複製程式碼

看到了嗎通過我們建構函式初始化的DexPathList來查詢的,上面我們已經貼了DexPathList內部findclass的他是通過Element[]來拿到的。接下來我們來看看DexPathList的建構函式

public DexPathList(ClassLoader definingContext, String dexPath,
            String libraryPath, File optimizedDirectory) {
        if (definingContext == null) {
            throw new NullPointerException("definingContext == null");
        }

        if (dexPath == null) {
            throw new NullPointerException("dexPath == null");
        }

        if (optimizedDirectory != null) {
            if (!optimizedDirectory.exists())  {
                throw new IllegalArgumentException(
                        "optimizedDirectory doesn't exist: "
                        + optimizedDirectory);
            }

            if (!(optimizedDirectory.canRead()
                            && optimizedDirectory.canWrite())) {
                throw new IllegalArgumentException(
                        "optimizedDirectory not readable/writable: "
                        + optimizedDirectory);
            }
        }

        this.definingContext = definingContext;
        this.dexElements =
            makeDexElements(splitDexPath(dexPath), optimizedDirectory);
        this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
    }複製程式碼

好了這就是他的原始碼了可看到在夠著函式中有一個很重要的一步就是對(makeDexElements這個方法)Element[]初始化 說了這麼多終於到這個地方了 這是什麼鬼,進入這個方法來看一下

private static Element[] makeDexElements(ArrayList files,
            File optimizedDirectory) {
        ArrayList elements = new ArrayList();

        /*
         * Open all files and load the (direct or contained) dex files
         * up front.
         */
        for (File file : files) {
            ZipFile zip = null;
            DexFile dex = null;
            String name = file.getName();

            if (name.endsWith(DEX_SUFFIX)) {
                // Raw dex file (not inside a zip/jar).
                try {
                    dex = loadDexFile(file, optimizedDirectory);
                } catch (IOException ex) {
                    System.logE("Unable to load dex file: " + file, ex);
                }
            } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
                    || name.endsWith(ZIP_SUFFIX)) {
                try {
                    zip = new ZipFile(file);
                } catch (IOException ex) {
                    /*
                     * Note: ZipException (a subclass of IOException)
                     * might get thrown by the ZipFile constructor
                     * (e.g. if the file isn't actually a zip/jar
                     * file).
                     */
                    System.logE("Unable to open zip file: " + file, ex);
                }

                try {
                    dex = loadDexFile(file, optimizedDirectory);
                } catch (IOException ignored) {
                    /*
                     * IOException might get thrown "legitimately" by
                     * the DexFile constructor if the zip file turns
                     * out to be resource-only (that is, no
                     * classes.dex file in it). Safe to just ignore
                     * the exception here, and let dex == null.
                     */
                }
            } else {
                System.logW("Unknown file type for: " + file);
            }

            if ((zip != null) || (dex != null)) {
                elements.add(new Element(file, zip, dex));
            }
        }

        return elements.toArray(new Element[elements.size()]);
    }複製程式碼

程式碼有點多哈...不要著急 我來慢慢說,首選呢建構函式傳進來了一個file陣列不論是jar檔案還是apk檔案我們在這一步都是吧他們轉換成dex檔案相當於做了一個操作把patch.jar 改成patch.dex然後轉存到最初我們傳進來的那個optimizedDirectory資料夾下。然後我們的Element這個類是個靜態內部類,可以看看下面的原始碼的建構函式

 public Element(File file, ZipFile zipFile, DexFile dexFile) {
            this.file = file;
            this.zipFile = zipFile;
            this.dexFile = dexFile;
        }複製程式碼

可以看到他傳入了這些引數。好了現在知道了這是什麼鬼了吧,一系列的原始碼恐怕會看的頭暈腦脹的吧。反正知道了Element就是儲存我們dex檔案的每次findclass的時候從這裡面取得,知道這個就行了。

插入我們需要的載入的patch.dex

現在知道往哪裡插入我們的dex檔案了吧,只要在我們app啟動的時候,把我們的dex檔案載入到Element[]陣列最前面就行了,每次findclass的時候肯定先查詢我們的dex了。這樣不就可以達到熱修復了嗎!

  • 第一步建立一個我們的DexClassLoader 把我們的patch.dex(或.jar)檔案傳進去
  • 第二步通過反射拿到我們建立的DexClassLoader裡面的DexPathList裡面的Element[]
  • 拿到apk的DexClassLoader(getClassLoader()這個方法就可以拿到)然後同樣反射的方式拿到DexPathList裡面的Element[]。
  • 最關鍵的一部就是把我們patch的Element[]和apk的Element[]合併在一起然後通過反射修改apk裡面的Element[](別合併錯了,要把我們的資料插入最前面)
  • 以上步驟要在Application生命週期中的attachBaseContext進行執行不然,在onCreate裡面執行的話app就已經初始化好了
    這裡我就用Nuva熱修復的程式碼來舉例吧,他這邊寫的很詳細的 git傳送門哈哈哈博主已棄坑 放棄維護了
 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);複製程式碼

這就是我所說的那四步。

大功告成是不是很帶勁吧。md終於把我們的dex檔案插入了。嘿嘿嘿黑科技啊,熱修復原來如此簡單。別高興的太早。接下來重點來了

坑1(CLASS_ISPREVERIFIED)預定義

這時候你執行專案的時候會發現app 掛了 哈哈哈 真是日了狗了,不出意外的話會報一下錯誤class ref in pre-verified class resolved to unexpected implementation 這個就是上面所說的odex操作帶來的麻煩。
出問題嗎?當然要慢慢解決了。先了解一下odex吧

  • 在apk安裝的時候系統會將dex檔案優化成odex檔案,在優化的過程中會涉及一個預校驗的過程
  • 如果一個類的static方法,private方法,override方法以及建構函式中引用了其他類,而且這些類都屬於同一個dex檔案,此時該類就會被打上CLASS_ISPREVERIFIED
  • 如果在執行時被打上CLASS_ISPREVERIFIED的類引用了其他dex的類,就會報錯
  • 所以你的類中引用另一個dex的類就會出現上文中的問題
  • 正常的分包方案會保證相關類被打入同一個dex檔案
  • 想要使得patch可以被正常載入,就必須保證類不會被打上CLASS_ISPREVERIFIED標記。而要實現這個目的就必須要在分完包後的class中植入對其他dex檔案中類的引用
  • 要在已經編譯完成後的類中植入對其他類的引用,就需要操作位元組碼,慣用的方案是插樁。常見的工具有javaassist,asm等

這時候大家就要了解這個了javaassist,一個程式碼植入庫,幾個簡單的api大家看看都會

 /**
     * 植入程式碼
     * @param buildDir 是專案的build class目錄,就是我們需要注入的class所在地
     * @param lib 這個是hackdex的目錄,就是AntilazyLoad類的class檔案所在地
     */
    public static void process(String buildDir, String lib) {
        System.out.println(buildDir)
        println(lib);
        ClassPool classes = ClassPool.getDefault()
        classes.appendClassPath(buildDir)
        classes.appendClassPath(lib)

        // 將需要關聯的類的構造方法中插入引用程式碼
        CtClass c = classes.getCtClass("cn.jiajixin.nuwasample.Hello.Hello")
        if (c.isFrozen()) {
            c.defrost()
        }
        println("====新增構造方法====")
        def constructor = c.getConstructors()[0];
        constructor.insertBefore("System.out.println(com.cuieney.hookdex.AntilazyLoad.class);")
        c.writeFile(buildDir)


    }複製程式碼

如果我們想不被打上標記就只能這樣了,就是通過這個方法,讓現在這個類Hello在當dex裡面引用其他的dex檔案裡面的AntilazyLoad.class簡單的說就是對其他dex檔案有依賴就不會被打上標記。

那麼我們這個程式碼段改在哪裡執行呢。好問題!!!這也是重點。不知道老鐵們還記得上面的程式碼嗎。apk的生成過程的生命週期,就是在build的是那幾個步驟。我們需要在.class檔案程式設計成.dex檔案前 進行程式碼植入。這樣是不是很完美呢。那我們從哪裡下手呢。當然是我們的build.gradle檔案下手,我們編譯專案的時候每次是不是都是在這裡進行操作的

這裡要用到一個新的姿勢哦(不對是知識哈哈哈)groovy這個語言plugin外掛語言。我們原生的android studio 是對groovy支援的。在我們的專案中建立一個buildsrc專案,一定要這個名字。然後我們在專案中建立一個類patch.groovy 目錄結構如下不用的都刪了。

Screen Shot 2017-06-27 at 11.23.21 AM.png
Screen Shot 2017-06-27 at 11.23.21 AM.png

然後我們在我們的app的build.gradle裡面做一下操作

task ('processWithJavassist') << {
    String classPath = file('build/intermediates/classes/debug')//專案編譯class所在目錄
    com.cuieney.groovy.PatchClass.process(classPath, project(':hookdex').buildDir
            .absolutePath + '/intermediates/classes/debug')//第二個引數是hackdex的class所在目錄

}複製程式碼

這個是執行程式碼植入操作project(':hookdex')這個使我們植入的類的module
但是我麼這個task你得保證在.class 到 .dex檔案之間操作,我們怎麼保證呢?接下來見證奇蹟的時候到了在build.g裡在新增如下程式碼

applicationVariants.all { variant ->
        variant.dex.dependsOn << processWithJavassist //在執行dx命令之前將程式碼打入到class中
    }複製程式碼

這樣就完成了我們的程式碼植入操作 哈哈哈哈 牛逼不牛逼不

but 你在植入程式碼之前一定要把我們的植入的類的dex提前插入到Element[]裡面不然 會報找不到這個類的。 然後在只要真正的patch.dex 我們的補丁。

補丁製作

  • 將class檔案打入一個jar包中 jar cvf path.jar xxxx.java
  • 將jar包轉換成dex的jar包 dx --dex --output=path_dex.jar path.jar
  • 用adb將你的path_dex.jar push到你的dexpath中。每次app啟動吧這個補丁打入就好

坑2 以上程式碼植入在高版本的gradle不行

包以下錯誤Gradle1.40 裡TransformAPI無法打包的情況,只相容Gradle1.3-Gradle2.1.0版本
哈哈哈我也沒則,目前RocooFix這個專案博主通過一種新的方式進行了程式碼植入(之前我們通過植入程式碼來完成避免打上標誌,他則是反其道而行,PatchClassLoader每次載入apk裡面的dex時,把標誌去了這樣也可以防止出現之前那種crash 只能說牛逼牛逼,裡面程式碼還在研究...)有興趣的同學可以看一下。

ending

說了這麼多,其實網上這種帖子很多,自己只是想系統的整理一下,其實在這個過程自己學到了很多,不論是原始碼還是各方面的擴充套件知識吧,對自己都有很大的提升,不論老鐵們看沒看玩,希望這次分享給大家帶來的知識的提升。 stay hungry stay foolish

下一篇文章
手把手教你寫熱修復(HOTFIX)

相關文章