1. 程式人生 > >手把手帶你打造一個 Android 熱修復框架(上篇)

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

本文來自網易雲社群

作者:王晨彥

前言

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

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

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

今天,我們就基於 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

觀察 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 目錄

沒錯,正是我們自己的程式碼,看來我們的猜測是正確的。

  • 將 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+款雲產品! 

更多網易研發、產品、運營經驗分享請訪問網易雲社群