本文來自網易雲社群
作者:王晨彥
前言
熱修複和插件化是目前 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
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsISOiZmM3MWN5cTM0QWLkNzM40yM5EDNtAjNzITLlhzMmVmYhlzLcV2ZkVGb39mbr9CXt92YuU2chVGdl5mLz9mbvw1LcpDc0RHaiojIsJye.jpg)
觀察 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+款雲産品!
更多網易研發、産品、營運經驗分享請通路網易雲社群。
相關文章:
【推薦】 Clojure基礎課程2-Clojure中的資料長啥樣?