本文来自网易云社区
作者:王晨彦
前言
热修复和插件化是目前 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中的数据长啥样?