手把手帶你打造一個 Android 熱修復框架

chay發表於2019-01-11

手把手帶你打造一個 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 HookASM。 還不瞭解 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。 請注意 processDebugManifesttransformClassesWithDexForDebug 這兩個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 中執行。

  • Application 處理

上面我們已經對所有 class 檔案插入了 Hack 的引用,而插入 dex 是在 Application 中,Application 啟動前肯定要先載入 Application.class,但這時 dex 還沒被插入,因此肯定會引起 ClassNotFoundException ,因此我們不能使 Application 引用 Hack。 那麼修改 class 檔案時如何知道哪個是 Application 呢,有人可能會說直接特判不就行了,但是我覺得要作為一個外掛的話就要做到相容,並且儘量減少使用者的手動配置。 那麼如何讓外掛找到 Application 的名字呢,這時就要用到上面的 processDebugManifest Task 了。 我們都知道,Application需要在 Manifest 中註冊,因此只要找到 Manifest 檔案就能得到 Application 的名字了。 沒錯,Manifest 檔案就在 processDebugManifest 的 outputs.files 中,找到 Manifest 後解析 application 標籤即可。

  • 開啟混淆會怎樣?

我們正式上線的應用都是會混淆的,我們剛才測試的使用 debug 未混淆模式,如果我們開啟混淆的話 Task 還會和上面的完全一樣嗎? 我們把 release 的混淆開啟,然後執行 assembleRelease,觀察 Gradle Console 輸出

:app:preBuild UP-TO-DATE
// 省略部分Task
:app:processReleaseJavaRes NO-SOURCE
:app:transformResourcesWithMergeJavaResForRelease
:app:transformClassesAndResourcesWithProguardForRelease
:app:transformClassesWithDexForRelease
:app:mergeReleaseJniLibFolders
:app:transformNativeLibsWithMergeJniLibsForRelease
:app:validateSigningRelease
:app:packageRelease
:app:assembleRelease
複製程式碼

可以看到相比較未開啟混淆多了一個 transformClassesAndResourcesWithProguardForRelease, 那麼這個Proguard Task有用嗎? 有用! 為了保證打包 APK 和 patch 時 class 混淆後的名字不變,我們需要在 Proguard Task 前插入混淆邏輯 使用 Proguard 的 -applymapping 即可實現。 因此,我們還要對打包APK後生成的 mapping 檔案進行儲存。 外掛中程式碼實現

static applymapping(TransformTask proguardTask, File mappingFile) {
    if (proguardTask) {
        ProGuardTransform transform = (ProGuardTransform) proguardTask.getTransform()
        if (mappingFile.exists()) {
            transform.applyTestedMapping(mappingFile)
        } else {
            CFixLogger.i("${mappingFile} does not exist")
        }
    }
}
複製程式碼
  • 補丁簽名

為了安全,上線時我們最好對補丁加上簽名驗證,保證補丁簽名和 APK 簽名一致。 簽名使用 JDK 中的 jarsigner

List<String> command = [JavaEnvUtils.getJdkExecutable('jarsigner'),
                        '-verbose',
                        '-sigalg', 'MD5withRSA',
                        '-digestalg', 'SHA1',
                        '-keystore', extension.storeFile.absolutePath,
                        '-keypass', extension.keyPassword,
                        '-storepass', extension.storePassword,
                        patchFile.absolutePath,
                        extension.keyAlias]
Process proc = command.execute()
複製程式碼

校驗簽名的程式碼我就不貼了,對應的是原始碼中的 SignChecker 類。

檢驗成果

上面我們已經把製作補丁,匯入補丁的過程大致梳理了一遍,接下來就需要把上面的程式碼整理一下。 為了方便使用,我們將其製作為一個 Gradle 外掛。如果還不瞭解如何製作 Gradle 外掛的話快點去學習啦 我已將外掛和依賴庫上傳至 JCenter,在 app 中引入外掛。

// root build.gradle
buildscript {
    repositories {
        jcenter()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:2.3.3'
        classpath 'me.wcy:cfix-gradle:1.1'
    }
}

// app build.gradle
apply plugin: 'com.android.application'
apply plugin: 'me.wcy.cfix'

cfix {
    includePackage = ['me/wcy/cfix/sample'] // 需要插入補丁的包名,一般為應用的包名
    excludeClass = [] // 不需要插入補丁的類
    debugOn = true // debug 模式是否插入補丁

    sign = true // 是否新增簽名
    storeFile = file("release.jks")
    storePassword = 'android'
    keyAlias = 'cfix'
    keyPassword = 'android'
}

// 省略部分程式碼

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:25.3.1'
    compile 'me.wcy:cfix:1.0'
}
複製程式碼

在 Application 中插入 Hack dex 和 patch

@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    CFix.init(this);
    CFix.loadPatch(Environment.getExternalStorageDirectory().getPath().concat("/patch.jar"), !BuildConfig.DEBUG);
}
複製程式碼

首先不對專案做任何修改,直接執行

手把手帶你打造一個 Android 熱修復框架

熟悉的 Hello World 檢查下 class 檔案是否已經引入 Hack 類,編譯後的 class 位於 app/build/intermediates/classes

手把手帶你打造一個 Android 熱修復框架
手把手帶你打造一個 Android 熱修復框架

可以看到,Application 沒有引入 Hack 類,Activity 已經成功引入 Hack 類。

然後我們新增一個對話方塊類,並在Activity中呼叫該類顯示對話方塊

public class FixDialog {

    public void show(Context context) {
        new AlertDialog.Builder(context)
                .setTitle("Congratulations")
                .setMessage("Patch Success!")
                .setPositiveButton("OK", null)
                .show();
    }
}

// MainActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    FixDialog dialog = new FixDialog();
    dialog.show(this);
}
複製程式碼

儲存生成的 Hash 檔案,製作補丁包 開啟終端,執行以下命令

gradlew clean cfixXiaomiDebugPatch -P cfixDir=D:\Android\AndroidStudioProjects\CFix\app\cfix
複製程式碼

Xiaomi 表示 productFlavor,Debug 表示 buildType 將生成的 patch.jar push 到手機 SD 根目錄

adb push D:\Users\wcy\Desktop\patch.jar /sdcard/
複製程式碼

重啟應用 注意,因為我們只是測試,所以把補丁包放在了SD中,因此需要新增讀取SD許可權,還需要把 targetSdk 改為小於 23 或者手動給予許可權。

手把手帶你打造一個 Android 熱修復框架

成功了! 完整程式碼請參考 Sample

原始碼

github.com/wangchenyan… 該框架可以說是對 Nuwa 的優化升級,幾乎支援了目前所有的 Gradle 版本 1.5.0-3.0.1(1.5之前的版本由於太舊未適配)。 再次對 Nuwa 作者表示感謝,給我們提供了很好的例子。 該框架在 9W+程式碼量的線上專案中驗證通過。 框架使用方法請參考 README

宣告:該框架未進行相容性測試,因此不保證相容所有機型。如果要在商業專案中使用,建議進行相容性測試。

總結

今天我們主要對 QQ 空間的熱修復方案進行了可實行性探討,對整個流程進行梳理,並最終實現了整套方案,驗證通過。 其實我在這期間也踩了不少坑,如 QQ 空間部落格中提到的使用 javassist 對 class 進行修改,我使用 javassist 後,一開始在 demo 中可以正常修改 class,但是到了大量程式碼的線上專案中一直報找不到 v4 包中的類,導致無法修改 class 檔案引入 Hack 類。打 log 又發現類已經正常被載入,而且有時能找到有時找不到,每次找不到的類還不一樣,WTF。 最後參考了 Nuwa 的實現,替換為 ASM,問題解決。 近兩年湧現了很多熱修復框架,關於熱修復的文章也有很多,相信大家也看了不少,但是看的再多,終究不如動手實踐來的深刻。

遷移自我的簡書 2017.12.18

相關文章