Android热修复技术(三)字节码注入

前言

首先我们考虑一个问题,为什么需要进行字节码注入代码?

那是因为apk在加载外部dex包的时候程序会出现崩溃(5.0以下),那为什么会崩溃呢?
java.lang.IllegalAccessError:Class ref in pre-verified class resolved to unexpected implementation

1. 崩溃原因—>类预校验问题引起的

  • 在apk安装的时候系统会将dex文件优化成odex文件,在优化过程中会涉及一个预校验过程

  • 如果一个类的static方法,private方法,override方法以及构造函数中引用了其他类,而这些类都属于同一个dex文件,此时类就会被打上CLASS_ISPREVERIFIED

  • 如果在运行时被打上CLASS_ISPREVERIFIED的类引用其他dex的类,就会报错

  • 正常的分包方案会保证相关类被打入同一个dex文件

  • 要想使得patch可以被正常加载,就必须保证类不会被打上CLASS_ISPREVERIFIED标记,而要实现这个目的就必须要在分包完成前的class中植入对其他dex文件中类的引用

  • 要在已经编译完成后的类中植入对其他类的引用,就需要操作字节码,惯用的方案是插桩,常见的工具有javassist、asm等

所以热修复的关键就在于字节码的注入而非dex的加载了。

2. 为什么只在5.0以下崩溃

Android 5.0(API 级别 21)之前的平台版本使用 Dalvik 运行时来执行应用代码。默认情况下,Dalvik 限制应用的每个 APK 只能使用单个 classes.dex 字节码文件。要想绕过这一限制,您可以使用 Dalvik 可执行文件分包支持库(Multidex),它会成为您的应用主要 DEX 文件的一部分,然后管理对其他 DEX 文件及其所包含代码的访问。

Android 5.0(API 级别 21)及更高版本使用名为 ART 的运行时,后者原生支持从 APK 文件加载多个 DEX 文件。ART 在应用安装时执行预编译,扫描 classesN.dex 文件,并将它们编译成单个 .oat 文件,供 Android 设备执行。因此,如果您的 minSdkVersion 为 21 或更高值,则不需要 Dalvik 可执行文件分包支持库。所以5.0以上不存在类预校打标记验问题,进而导致加载其他dex崩溃的问题,但是为了兼容5.0以下,我们还是需要注入代码

扫描二维码关注公众号,回复: 12726368 查看本文章
  • ./gradle中配置 multiDexEnabled true 表示编译apk需要分包
  • Application中配置MultiDex.install(this);表示要加载其他dex包,5.0以下起作用(内部有判断),5.0以上原生支持从APK文件加载多个dex文件

google文档:配置方法数超过 64K 的应用

一、Transform

由之前的分析我们知道,Gradle构建工程实质是通过一系列的Task的完成的,在构建apk的过程中存在打包dex的任务

Task :app:transformClassesWithDexBuilderForXXX
Task :app:transformDexArchiveWithDexMergerForXXX

Gradle 1.5以上版本提供了一个新的API:Transform,这个API允许第三方插件在class文件转为dex文件前操作编译好的class文件,这个Trasnform的目标是简化注入自定义类操作,而不必处理Task,并提供更灵活的操作,dex任务已经全部移动到这个新的机制中。

Transform任务一经注册就会被插入到任务执行队列中,并且其恰好在dex打包任务之前,所以要想实现插桩就需要创建一个Transform,其实它内部也是通过创建task来实现的,它的task名为transformClassesWithPreDexInjectForXXX

有一个TransformManager对Transform进行管理,从一个Transform流入,然后对字节码进行加工处理以后再输出,然后流入下一个Transform,直到所有的Transform过滤处理完成,所以我们的Transform就需要注册到TransformManager中去

  • 创建一个Transform类,继承一个抽象类Transform
class PreDexInjectTransform extends Transform {
    Project mProject
    InjecterByTransform mInjecter


    PreDexInjectTransform(Project project) {
        this.mProject = project
    }

    @Override
    String getName() {
        return "preDexInject"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        //获取输入类型
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        //指定范围
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        //是否支持增量编译,如果返回true,可以根据TransformInput来获得更改、移除或者添加的文件目录
        //JarInput  --> getStatus()
        //DirectoryInput --> getChangedFiles()
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        //TransformInvocation包含了输入,输出相关信息
        //可以通过transformInvocation.inputs得到输入然后进行遍历得到多个TransformInput,每个TransformInput都包含目录的输入(directoryInputs)和jar包的输入(jarInputs)
        //其输出相关内容由TransformOutputProvider来做处理,getContentLocation()方法可以获取文件的输出目录,如果目录存在的话直接返回,如果不存在就会重新创建一个
    }
}
  • 主要重写transform方法,在这里对字节码进行处理
@Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        //遍历transform的inputs
        //inputs有两种类型,一种是目录,一种是jar,需要分别遍历
        transformInvocation.inputs.each { TransformInput input ->
            input.directoryInputs.each { DirectoryInput directoryInput ->
                //TODO 在这里可以注入代码

                def dest = transformInvocation.outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

                //将input的目录复制到output指定目录
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

            input.jarInputs.each { JarInput jarInput ->
                //注入代码
                def jarPath = jarInput.file.absolutePath
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                def jarInputFile = jarInput.file

                //重命名输出文件(同目录copyFile会冲突)
                def jarName = jarInput.name
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                def dest = transformInvocation.outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)
                FileUtils.copyFile(jarInputFile, dest)
            }
        }
    }

这里用到了apache-common包,需要在build.gradle中引入

 compile "commons-io:commons-io:+"
 compile 'commons-codec:commons-codec:+'
  • 对自定义的Transform进行注册
def preDexTransform = new PreDexInjectTransform(project)
project.android.registerTransform(preDexTransform)

在执行编译过程中会生成对应的目录,例如在/app/build/intermediates/transforms目录下生成了名为preDexInject目录,这个名称是根据自定义的Transform类getName()方法返回的字符串来的

这里写图片描述

这里写图片描述

该目录下还有有一个content.json文件,该文件配置了文件内容

[
{
    "name": "android.local.jars:WeiboSDK_fat.jar:98d96d3a2f5f41858187ce3a9c6be4e2c0ef1ada2d8d0dc3850d7b81fbaba211ed26348f",
    "index": 0,
    "scopes": [
      "EXTERNAL_LIBRARIES"
    ],
    "types": [
      "CLASSES"
    ],
    "format": "JAR",
    "present": true
  },
  {
    "name": "com.android.support:appcompat-v7:27.1.19e6cc2c0adcd05ea48fa0acf923bfc09",
    "index": 1,
    "scopes": [
      "EXTERNAL_LIBRARIES"
    ],
    "types": [
      "CLASSES"
    ],
    "format": "JAR",
    "present": true
  },
{
    "name": ":testlibrary07c1666863d4d53d82ec5fd8c2d80afd",
    "index": 19,
    "scopes": [
      "SUB_PROJECTS"
    ],
    "types": [
      "CLASSES"
    ],
    "format": "JAR",
    "present": true
  }
  ...
]

Transform中的transform()方法是如何被执行的?

我们之前讲到的project.android.registerTransform()注册方法,实际上只是把Transform对象放到了一个List集合中,实际上每个Transform都会有一个对应的TransformTask,其内部有一个transform方法,被@TaskAction所注解,TransformTask本质上就是表示Gradle中的一个Task,那么在一个Task执行的时候其@TaskAction注解的方法就会被执行,然后内部调用了Transform的transform()方法,也就是可以任务Transform是TransformTask的包装,它帮我们去定义Task并且指定了Task的执行时机,我们只需要做我们自己的操作而不用关心其他

那么又有一个问题来了,他把TransformTask的执行时机定义在哪呢?他内部并没有使用dependsOn()显式的保证依赖关系,其实在Transform API中,使用的是TransformStream来连接TransformTask的依赖关系,进而控制Transform的执行顺序,指定TransformTask的输入输出是在TransformManager的addTransform()方法中,创建task的时候指定了输入和输出。

所以当我们自定义Plugin要注入多个Transform的时候,按照添加顺序来保证依赖关系,先添加的Transform先执行

二、Javassist

Javassist是一个可以用来检查、动态修改以及创建Java字节码的动态类库

1.创建一个新的class

ClassPool pool = ClassPool.getDefault();

//定义类
CtClass stuClass = pool.makeClass("com.huli.Student");

//如果类已经存在,可以直接获取
CtClass cc = pool.get("java.lang.String");

2.构造成员变量

CtField idField = new CtField(CtClass.longType,"id",stuClass);
stuClass.addField(idField);

3.构造方法

CtMethod getMethod = CtNewMethod.make("public int getAge() { return this.age }", stuClass);
CtMethod setMethod = CtNewMethod.make("public int setAge(int age) { this.age = age }", stuClass);

stuClass.addMethod(getMethod);
stuClass.addMethod(setMethod);

4.设置父类

stuClass.setSuperClass(pool.get("com.huli.Person"));
stuClass.writeFile();

5.将类冻结

如果一个CtClass通过writeFile()、toClass()、toByteCode()方法被转换成一个类文件,此时此CtClass对象就会被冻结起来,不允许被修改。

但是,一个被冻结的CtClass也可以被解冻,例如

stuClass.defrost(); //解冻
stuClass.setSuperClass(...) //被解冻后又可以修改了,如果不调用解冻方法会报错

6.类搜索路径

通过ClassPool.getDefault()获取的ClassPool使用的是JVM的类搜索路径,但有时候我们可能需要添加其他搜索路径,使用insertClassPath或者appendClassPath,区别在于一个是插入到前面,一个是添加到后面

pool.insertClassPath(new ClassPath(this.getClass()));

上面的语句将this指向的类添加到pool的类加载路径中,你可以使用任意Class对象来代替,从而将Class对象添加到类加载路径中

当然,我们也可以添加一个目录或者jar包作为搜索路径

pool.insertClassPath("/Users/xueshanshan/huli/project/HotfixPatchProject/app/build/intermediates/classes/release")

pool.insertClassPath("/Users/xueshanshan/huli/project/HotfixPatchProject/app/libs/a.jar")

7.避免内存溢出

ClassPool是CtClass对象的容器,一旦一个CtClass被创建,它就会保存在ClassPool中

如果CtClass对象的数量变得非常大,ClassPool可能会导致巨大的内存消耗,为了避免此问题,可以从ClassPool中显示删除不必要的CtClass对象。如果对CtClass对象调用detach(),那么该CtClass对象将会从ClassPool中移除

另一个办法是用新的ClassPool替换旧的ClassPool,并将旧的ClassPool丢弃,如果旧的ClassPool被垃圾回收掉,那么包含在ClassPool中的CtClass对象也会被回收,要创建一个新的ClassPool,可以使用下面代码:

ClassPool pool = new ClassPool(true);  //true表示添加系统搜索路径

三、项目代码注入流程分析

  1. 首先定义Transform,并且实现相应方法

  2. 然后plugin中注册Transform

  3. 使用plugin

  4. 执行assemble命令,查看输出结果

猜你喜欢

转载自blog.csdn.net/qq_33666539/article/details/82629001