天天看點

手把手帶你打造一個 Android 熱修複架構(上篇)

本文來自網易雲社群

作者:王晨彥

前言

熱修複和插件化是目前 Android 領域很火熱的兩門技術,也是 Android 開發工程師必備的技能。

目前比較流行的熱修複方案有微信的 Tinker,手淘的 Sophix,美團的 Robust,以及 QQ 空間熱修複方案。

QQ 空間熱修複方案使用Java實作,比較容易上手。

如果還不了解 QQ 空間方案的原理,請先學習安卓App熱更新檔動态修複技術介紹

今天,我們就基于 QQ 空間方案來深入學習熱修複原理,并且手把手完成一個熱修複架構。

本文參考了 Nuwa,在此表示感謝。

本文基于 Gradle 2.3.3 版本,支援 Gradle 1.5.0-3.0.1。

實戰

了解了熱修複原理後,我們就開始打造一個熱修複架構

  • 關閉dex校驗

根據文章中提到的第一個問題,在 Android 5.0 以上,APK安裝時,為了提高 dex 加載速度,未引用其他 dex 的 class 将會被打上 CLASS_ISPREVERIFIED 标志。

打上 CLASS_ISPREVERIFIED 标志的 class,類加載器就不會去其他 dex 中尋找 class,我們就無法使用插樁的方式替換 class。

文章給出了解決辦法,即讓所有類都依賴其他 dex。如何實作呢?

建立一個 Hack 類,讓所有類都依賴該類,将該類打包成 dex,在應用啟動時優先将該 dex 插入到數組的最前面,即可實作。

OK,确定思路後,我們就開始動手。

  • 找出編譯後的 class

聽起來好像很簡單,那麼如何讓所有類依賴 Hack 類呢,總不能一個一個類改吧,怎麼才能在打包時自動添加依賴呢?

接下來就要用到 Gradle Hook 和 ASM。

還不了解 Gradle 建構流程的趕快去學習啦

要想修改編譯後的 class 檔案,首先要 Hook 打包過程,在 Gradle 編譯出 class 檔案到打包成 APK 之間植入我們的代碼,對 class 檔案進行修改。

找到編譯後的class檔案要依賴 Gradle Hook ,而修改 class 檔案要依賴 ASM。

首先,我們要找到編譯後的 class 檔案

建立一個 Project CFixExample,然後執行 assembleDebug

手把手帶你打造一個 Android 熱修複架構(上篇)

觀察 Gradle Console 輸出

:app:preBuild UP-TO-DATE
:app:preDebugBuild UP-TO-DATE
:app:checkDebugManifest
:app:preReleaseBuild UP-TO-DATE
:app:prepareComAndroidSupportAnimatedVectorDrawable2540Library// 省略部分Task:app:prepareComAndroidSupportSupportVectorDrawable2540Library
:app:prepareDebugDependencies
:app:compileDebugAidl UP-TO-DATE
:app:compileDebugRenderscript UP-TO-DATE
:app:generateDebugBuildConfig UP-TO-DATE
:app:generateDebugResValues UP-TO-DATE
:app:generateDebugResources UP-TO-DATE
:app:mergeDebugResources UP-TO-DATE
:app:processDebugManifest UP-TO-DATE
:app:processDebugResources UP-TO-DATE
:app:generateDebugSources UP-TO-DATE
:app:incrementalDebugJavaCompilationSafeguard
:app:javaPreCompileDebug
:app:compileDebugJavaWithJavac
:app:compileDebugNdk NO-SOURCE
:app:compileDebugSources
:app:mergeDebugShaders
:app:compileDebugShaders
:app:generateDebugAssets
:app:mergeDebugAssets
:app:transformClassesWithDexForDebug
:app:mergeDebugJniLibFolders
:app:transformNativeLibsWithMergeJniLibsForDebug
:app:processDebugJavaRes NO-SOURCE
:app:transformResourcesWithMergeJavaResForDebug
:app:validateSigningDebug
:app:packageDebug
:app:assembleDebug

BUILD SUCCESSFUL in 10s      

這些就是 Gradle 打包時執行的所有任務,不同版本的 Gradle 會有所不同,這裡我們基于 Gradle 2.3.3。

請注意 processDebugManifest 和 transformClassesWithDexForDebug 這兩個Task,根據名字我們可以先猜測一下

第一個 Task 的作用應該是處理Manifest,這個我們等會兒會用到

第二個 Task 的作用應該是将 class 轉換為 dex,這不正是我們要找的 Hook 點嗎?

沒錯,為了驗證我們的猜測,我們列印一下 transformClassesWithDexForDebug 的輸入檔案

在 app 的 build.gradle 中添加如下代碼

project.afterEvaluate {
    project.android.applicationVariants.each { variant ->
        Task transformClassesWithDexTask = project.tasks.findByName("transformClassesWithDexFor${variant.name.capitalize()}")
        println("transformClassesWithDexTask inputs")
        transformClassesWithDexTask.inputs.files.each { file ->
            println(file.absolutePath)
        }
    }
}      

再次打包,觀察輸出

transformClassesWithDexTask inputs
C:\Users\hzwangchenyan\.android\build-cache\97c23f4056f5ee778ec4eb674107b6b52d506af5\output\jars\classes.jar
C:\Users\hzwangchenyan\.android\build-cache\6afe39630b2c3d3c77f8edc9b1e09a2c7198cd6d\output\jars\classes.jar
C:\Users\hzwangchenyan\.android\build-cache\c30268348acf4c4c07940f031070b72c4efa6bba\output\jars\classes.jar
C:\Users\hzwangchenyan\.android\build-cache\5b09d9d421b0a6929ae76b50c69f95b4a4a44566\output\jars\classes.jar
C:\Users\hzwangchenyan\.android\build-cache\e302262273df85f0776e06e63fde3eb1bdc3e57f\output\jars\classes.jar
C:\Users\hzwangchenyan\.gradle\caches\modules-2\files-2.1\com.android.support\support-annotations\25.4.0\f6a2fc748ae3769633dea050563e1613e93c135e\support-annotations-25.4.0.jar
C:\Users\hzwangchenyan\.android\build-cache\36b7224f035cc886381f4287c806a33369f1cb1a\output\jars\classes.jar
C:\Users\hzwangchenyan\.android\build-cache\5d757d92536f0399625abbab92c2127191e0d073\output\jars\classes.jar
C:\Users\hzwangchenyan\.android\build-cache\011eb26fd0abe9f08833171835fae10cfda5e045\output\jars\classes.jar
D:\Android\sdk\extras\m2repository\com\android\support\constraint\constraint-layout-solver\1.0.2\constraint-layout-solver-1.0.2.jar
C:\Users\hzwangchenyan\.android\build-cache\36b443908e839f37d7bd7eff1ea793f138f8d0dd\output\jars\classes.jar
C:\Users\hzwangchenyan\.android\build-cache\40634d621fa35fcca70280efe0ae897a9d82ef8f\output\jars\classes.jar
D:\Android\AndroidStudioProjects\CFixExample\app\build\intermediates\classes\debug      

build-cache 就是 support 包

看起來這些都是 app 依賴的 library,但是我們自己的代碼呢

看看最後一行 app\build\intermediates\classes\debug 目錄

手把手帶你打造一個 Android 熱修複架構(上篇)

沒錯,正是我們自己的代碼,看來我們的猜測是正确的。

  • 将 class 插入對 Hack 的引用[重點]

找到了編譯後的 class 檔案,接下來使用 ASM 對 class 檔案進行修改

ClassReader cr = new ClassReader(inputStream)
ClassWriter cw = new ClassWriter(cr, 0)
ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {    @Override
    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 ("<init>".equals(name) && opcode == Opcodes.RETURN) {                    super.visitLdcInsn(Type.getType("Lme/wcy/cfix/Hack;"))
                }                super.visitInsn(opcode)
            }
        }        return mv
    }
}
cr.accept(cv, 0)      

我們通過複寫 ClassVisitor 的 visitMethod 方法,得到 class 的所有方法,在構造函數中插入 Hack 類的引用。

可以看到,即将打包為dex的源檔案既有 jar 又有 class,class 檔案我們直接修改就好,而對于 jar 檔案,我們需要先将其解壓,對解壓後的 class 檔案進行修改,然後再壓縮。

File optDirFile = new File(jarFile.absolutePath.substring(0, jarFile.absolutePath.length() - 4))
File metaInfoDir = new File(optDirFile, "META-INF")
File optJar = new File(jarFile.parent, jarFile.name + ".opt")

CFixFileUtils.unZipJar(jarFile, optDirFile)if (metaInfoDir.exists()) {
    metaInfoDir.deleteDir()
}

optDirFile.eachFileRecurse { file ->
    if (file.isFile()) {
        processClass(file, hashFile, hashMap, patchDir, extension)
    }
}

CFixFileUtils.zipJar(optDirFile, optJar)
jarFile.delete()
optJar.renameTo(jarFile)
optDirFile.deleteDir()      
  • 儲存檔案 Hash 值

我們今天的目的是打造一個熱修複架構,因從我們需要對于引入了 Hack 的 class 做一個記錄,讓我們在修改代碼後打更新檔包時可以知道哪些類發生了改變,隻需要打包修改了的類作為更新檔即可。

如何記錄呢,我們知道,Java 在編譯時同樣的 Java 檔案編譯為 class 後位元組碼是一緻的,是以直接計算檔案 Hash 值并儲存即可。

制作更新檔時對比 class 檔案的 Hash 值,如果不同,則打包進更新檔。

  • 插入 Hack dex

建立 Hack.java

public class Hack {
}      

上面我們提到,将包含 Hack 類的 dex 插入到 dex 數組的最前面,不然的話将會出現 Hack ClassNotFoundException,打包 dex 可以使用 build tool 的 dx 指令,位于 /sdk/build-tools/version/dx

dx --dex --output=patch.jar classDir      

打包為 dex 并壓縮為 jar

打包完成,如何插入到數組最前面呢,其實就和普通的更新檔檔案一樣,隻不過在普通更新檔之前插入

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

這裡采用反射的方法,對 BaseDexClassLoader 的 dexElements 進行修改。

這個插入操作是在應用啟動時完成的,那 dex 檔案從哪裡來呢,我們可以将 dex 放在 assets 中,插入前先将其複制到應用目錄。

這個操作我們放在 Application 的 attachBaseContext 中執行。

網易雲免費體驗館,0成本體驗20+款雲産品! 

更多網易研發、産品、營運經驗分享請通路網易雲社群。

相關文章:

【推薦】 Clojure基礎課程2-Clojure中的資料長啥樣?