手把手带你打造一个 Android 热修复框架

1,702 阅读6分钟

前言

热修复和插件化是目前 Android 领域很火热的两门技术,也是 Android 开发工程师必备的技能。

目前比较流行的热修复方案有微信的 Tinker,手淘的 Sophix,美团的 Robust,以及 QQ 空间热修复方案。

QQ 空间热修复方案使用 Java 实现,比较容易上手。

如果还不了解 QQ 空间方案的原理,请先学习安卓App热补丁动态修复技术介绍

今天,我们就基于 QQ 空间方案来深入学习热修复原理,并且手把手完成一个热修复框架。

本文参考了 Nuwa,在此表示感谢。

本文基于 Gradle 2.3.3 版本,支持 Gradle 1.5.0-3.1.4

实战

了解了热修复原理后,我们就开始打造一个热修复框架

  • 关闭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

观察 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 目录

没错,正是我们自己的代码,看来我们的猜测是正确的。

  • 将 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)

我们通过复写 ClassVisitorvisitMethod 方法,得到 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 tooldx 命令,位于 /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);
}

这里采用反射的方法,对 BaseDexClassLoaderdexElements 进行修改。

这个插入操作是在应用启动时完成的,那 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 文件就在 processDebugManifestoutputs.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:3.1.4'
        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:28.0.0'
    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 cfixDebugPatch -P cfixDir=/Users/wcy/Files/Android/AndroidStudioProjects/cfix/sample/cfix/

Debug 表示 buildType

将生成的 patch.jar push 到手机 SD 根目录

adb push ~/Downloads/patch.jar /sdcard/

重启应用

注意,因为我们只是测试,所以把补丁包放在了SD中,因此需要添加读取SD权限,还需要把 targetSdk 改为小于 23 或者手动给予权限。

成功了!

完整代码请参考 Sample

源码

查看源码

该框架可以说是对 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