热修复和插件化是目前 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