手把手带你打造一个 Android 热修复框架(上篇)

本文来自网易云社区

作者:王晨彦

前言

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

观察 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:Usershzwangchenyan.androiduild-cache97c23f4056f5ee778ec4eb674107b6b52d506af5outputjarsclasses.jar
C:Usershzwangchenyan.androiduild-cache6afe39630b2c3d3c77f8edc9b1e09a2c7198cd6doutputjarsclasses.jar
C:Usershzwangchenyan.androiduild-cachec30268348acf4c4c07940f031070b72c4efa6bbaoutputjarsclasses.jar
C:Usershzwangchenyan.androiduild-cache5b09d9d421b0a6929ae76b50c69f95b4a4a44566outputjarsclasses.jar
C:Usershzwangchenyan.androiduild-cachee302262273df85f0776e06e63fde3eb1bdc3e57foutputjarsclasses.jar
C:Usershzwangchenyan.gradlecachesmodules-2files-2.1com.android.supportsupport-annotations25.4.0f6a2fc748ae3769633dea050563e1613e93c135esupport-annotations-25.4.0.jar
C:Usershzwangchenyan.androiduild-cache36b7224f035cc886381f4287c806a33369f1cb1aoutputjarsclasses.jar
C:Usershzwangchenyan.androiduild-cache5d757d92536f0399625abbab92c2127191e0d073outputjarsclasses.jar
C:Usershzwangchenyan.androiduild-cache11eb26fd0abe9f08833171835fae10cfda5e045outputjarsclasses.jar
D:Androidsdkextrasm2repositorycomandroidsupportconstraintconstraint-layout-solver1.0.2constraint-layout-solver-1.0.2.jar
C:Usershzwangchenyan.androiduild-cache36b443908e839f37d7bd7eff1ea793f138f8d0ddoutputjarsclasses.jar
C:Usershzwangchenyan.androiduild-cache40634d621fa35fcca70280efe0ae897a9d82ef8foutputjarsclasses.jar
D:AndroidAndroidStudioProjectsCFixExampleappuildintermediatesclassesdebug

build-cache 就是 support 包

看起来这些都是 app 依赖的 library,但是我们自己的代码呢

看看最后一行 appuildintermediatesclassesdebug 目录

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

  • 将 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中的数据长啥样?

原文地址:https://www.cnblogs.com/163yun/p/9728164.html