熱修復和插件化是目前 Android 領域很火熱的兩門技術,也是 Android 開發工程師必備的技能。 目前比較流行的熱修復方案有微信的 Tinker,手淘的 Sophix,美團的 Robust,以及 QQ 空間熱修復方案。 QQ 空間熱修復方案使用Java實現,比較容易上手。 若是還不瞭解 QQ 空間方案的原理,請先學習安卓App熱補丁動態修復技術介紹 今天,咱們就基於 QQ 空間方案來深刻學習熱修復原理,而且手把手完成一個熱修復框架。 本文參考了 Nuwa,在此表示感謝。 本文基於 Gradle 2.3.3 版本,支持 Gradle 1.5.0-3.0.1
。java
瞭解了熱修復原理後,咱們就開始打造一個熱修復框架android
根據文章中提到的第一個問題,在 Android 5.0 以上,APK安裝時,爲了提升 dex 加載速度,未引用其餘 dex 的 class 將會被打上 CLASS_ISPREVERIFIED
標誌。 打上 CLASS_ISPREVERIFIED 標誌的 class,類加載器就不會去其餘 dex 中尋找 class,咱們就沒法使用插樁的方式替換 class。 文章給出瞭解決辦法,即讓全部類都依賴其餘 dex。如何實現呢? 新建一個 Hack 類,讓全部類都依賴該類,將該類打包成 dex,在應用啓動時優先將該 dex 插入到數組的最前面,便可實現。 OK,肯定思路後,咱們就開始動手。git
聽起來好像很簡單,那麼如何讓全部類依賴 Hack 類呢,總不能一個一個類改吧,怎麼才能在打包時自動添加依賴呢? 接下來就要用到 Gradle Hook
和 ASM
。 還不瞭解 Gradle 構建流程的趕快去學習啦 要想修改編譯後的 class 文件,首先要 Hook 打包過程,在 Gradle 編譯出 class 文件到打包成 APK 之間植入咱們的代碼,對 class 文件進行修改。 找到編譯後的class文件要依賴 Gradle Hook ,而修改 class 文件要依賴 ASM。 首先,咱們要找到編譯後的 class 文件 新建一個 Project CFixExample,而後執行 assembleDebuggithub
觀察 Gradle Console 輸出api
: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
目錄bash
沒錯,正是咱們本身的代碼,看來咱們的猜想是正確的。微信
找到了編譯後的 class 文件,接下來使用 ASM 對 class 文件進行修改app
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()
複製代碼
咱們今天的目的是打造一個熱修復框架,因從咱們須要對於引入了 Hack 的 class 作一個記錄,讓咱們在修改代碼後打補丁包時能夠知道哪些類發生了改變,只須要打包修改了的類做爲補丁便可。 如何記錄呢,咱們知道,Java 在編譯時一樣的 Java 文件編譯爲 class 後字節碼是一致的,所以直接計算文件 Hash 值並保存便可。 製做補丁時對比 class 文件的 Hash 值,若是不一樣,則打包進補丁。
新建 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
中執行。
上面咱們已經對全部 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);
}
複製代碼
首先不對項目作任何修改,直接運行
熟悉的 Hello World 檢查下 class 文件是否已經引入 Hack 類,編譯後的 class 位於 app/build/intermediates/classes
能夠看到,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 或者手動給予權限。
成功了! 完整代碼請參考 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