开发者

Android进阶从字节码插桩技术了解美团热修复实例详解

目录
  • 引言
  • 1 插件发布
  • 2 Javassist
    • 2.1 准备工作
    • 2.2 Transform
    • 2.3 transform函数注入代码
      • 2.3.1 Jar包处理
      • 2.3.2 字节码处理
    • 2.4 Javassist织入代码
      • 2.4.1 ClassPool
      • 2.4.2 CtClass

引言

热修复技术如今已经不是一个新颖的技术,很多公司都在用,而且像阿里、腾讯等互联网巨头都有自己的热修复框架,像阿里的AndFix采用的是hook native底层修改代码指令集的方式;腾讯的Tinker采用类加载的方式修改dexElement;而美团则是采用字节码插桩的方式,也就是本文将介绍的一种技术手段。

我们知道,如果上线出现bug,通常是发生在方法的调用阶段,某个方法异常导致崩溃;字节码插桩,就是在编译阶段将一段代码插入该方法中,如果线上崩溃,需要发布补丁包,同时在执行该方法时,如果检测到补丁包的存在,将会走插桩插入的逻辑,而不是原逻辑。

如果想要知道美团实现的热修复框架原理,那么首先需要知道,robust该怎么用

Android进阶从字节码插桩技术了解美团热修复实例详解

对于每个模块,如果想要插桩需要引入robust插件,所以如果自己实现一个简单的robust的功能,就需要创建一个插件,然后在插件中处理逻辑,我个人喜欢在buildSrc里写插件然后发布,当然也可以自己创建一个java工程改造成groovy工程

plugins {
    id 'groovy'
    id 'maven-publish'
}
dependencies {
    implementation gradleApi()
    implementation localGroovy()
    implementation 'com.android.tools.build:gradle:3.1.2'
}

如果创建一个java模块,如果要【改装】成一个groovy工程,就需要做上述的配置

1 插件发布

初始化之后,我一般会先建2个文件夹

Android进阶从字节码插桩技术了解美团热修复实例详解

plugin用于自定义插件,定义输入输出; task用于任务执行。

class MyRobustPlugin implements Plugin<Project>{
    @Override
    void apply(Project project) {
        //项目配置阶段执行,配置完成之后,
        project.afterEvaLuate {
            println '插件开始执行了'
        }
    }
}

如果需要发布插件到maven仓库,或者放在本地,可以通过maven-publish(gradle 7.0+)插件来实现

afterEvaluate {
    publishing {
        publications{
            releaseType(MavenPublication){
                from components.java
                groupId  'com.demo'
                artifactId  'robust'
                version  '0.0.1'
            }
        }
        repositories {
            maven {
                url uri('../repo')
            }
        }
    }
}

publications:这里可以添加你要发布的maven版本配置 repositories:maven仓库的地址,这里就是写在本地一个文件夹

Android进阶从字节码插桩技术了解美团热修复实例详解

重新编译之后,在publish文件夹下会生成很多任务,执行发布到maven仓库的任务,就会在本地的repo文件夹下生成对应的jar包

Android进阶从字节码插桩技术了解美团热修复实例详解

接下来我们尝试用下这个插件

buildscript {
    repositories {
        google()
        mavenCentral()
        jcenter()
        //这里配置了我们的插件依赖的本地仓库地址
        maven {
            url uri('repo')
        }
    }
    dependencies {
        classpath "com.android.tools.build:gradle:7.0.3"
        classpath "com.hujiang.ASPectjx:gradle-android-plugin-aspectjx:2.0.10"
        classpath "com.demo:robust:0.0.1"
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

配置完成后,在app模块添加插件依赖

apply plugin:'com.demo'

这里会报错,com.demo这个插件id找不到,原因就是,其实插件是一个jar包,然后我们只是创建了这个插件,并没有声明入口,在编译jar包时找不到清单文件,因此需要在资源文件夹下声明清单文件

Android进阶从字节码插桩技术了解美团热修复实例详解

implementation-class=com.tal.robust.plugin.MyRobustPlugin

创建插件名字的属性文件,声明插件的入口,就是我们自己定义的插件,再次编译运行

Android进阶从字节码插桩技术了解美团热修复实例详解

这也意味着,我们的插件执行成功了,所以准备工作已完成,如果需要插桩的模块,那么就需要依赖这个插件

2 Javassist

Javassist号称字节码手术刀,能够在class文件生成之后,打包成dex文件之前就将我们自定义的代码插入某个位置,例如在getClassId方法第62行代码的位置,插入逻辑判断代码

Android进阶从字节码插桩技术了解美团热修复实例详解

2.1 准备工作

引入Javassist,插件工程引入Javassist

implementation 'org.javassist:javassist:3.20.0-GA'

2.2 Transform

Android进阶从字节码插桩技术了解美团热修复实例详解

Javassist作用于class文件生成之后,在dex文件生成之前,所以如果想要对字节码做处理,就需要在这个阶段执行代码插入,这里就涉及到了一个概念 --- transform;

Android官方对于transform做出的定义就是:Transform用于在class打包成dex这个中间过程,对字节码做修改

Android进阶从字节码插桩技术了解美团热修复实例详解

在build文件夹中,我们可以看到这些文件夹,像merged_assets、merged_java_res等,这是Gradle的Transform,用于打包资源文件到apk文件中,执行的顺序为串行执行,一个任务的输出为下一个任务的输入,而在transforms文件夹下就是我们自己定义的transform

implementation 'com.android.tools.build:transform-api:1.5.0'

导入Transform依赖

class MyRobustTransform extends Transform{
    /**
     * 在transforms文件夹下的文件夹名字
     * @return
     */
    @Override
    String getName() {
        return "MyRobust"
    }
    /**
     * Transform要处理的输入文件类型 : 字节码
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }
    /**
     * 作用域:整个项目
     * @return
     */
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }
    /**
     * 是否为增量编译
     * @return
     */
    @Override
    boolean isIncremental() {
        return false
    }
    @Override
    void transform(TransformInvocation transformInvocation) throws IOException, TransformException, InterruptedException {
    }
}

如何让自定义的Transform生效,需要在插件中注册这个Transform

@Override
void apply(Project project) {
    println '插件开始执行了'
    //注册Transform
    def ext = project.extensions.getByType(AppExtension)
    if(ext != null){
       ext.registerTransform(new MyRobustTransform(project));
    }
}

对于每个模块,Gradle编译时都是创建一个Project对象,这里就是拿到了当前模块gradle中的android扩展,然后调用了registerTransform函数注册Transform,MyRobustTransform中的transform函数会被调用,将class、jar、resource等文件做处理

Android进阶从字节码插桩技术了解美团热修复实例详解

把一开始的流程图细分一下,其实class字节码在处理的时候是经历了多个transform,这里可以把transform看做是任务,每个任务执行完成之后,都将输出交由下一个task作为输入,我们自定义的transform是被放在transform链的头部

Task :app:transformClassesWithMyRobustForDebug

Android进阶从字节码插桩技术了解美团热修复实例详解

2.3 transform函数注入代码

OK,我们注册完成之后,这个Transform任务就能够执行了,执行的时候,会执行transform函数中的代码,我们注入代码也是在这个函数中进行

 @Override
void transform(TransformInvocation transformInvocation)  {
    super.transform(transformInvocation)
    println "transform start"
    transformInvocation.inputs.each { input ->
        //对于class字节码,需要处理
        input.directoryInputs.each { dic ->
            println "dic路径 $dic.file.absolutePath"
            classPool.appendClassPath(dic.file.absolutePath)
            //插入代码 -- javassist
            //找到class在哪,需要遍历class
            findTargetClass(dic.file, dic.file.absolutePath)
            def nextTransform = transformInvocation.outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY)
            FileUtils.copyDirectory(dic.file, nextTransform)
        }
        //对jar包不处理,直接扔给下一个Transform
        input.jarIn开发者_C入门http://www.devze.computs.each { jar ->
            println "jar包路径  $jar.file.absolutePath"
            classPool.appendClassPath(jar.file.absolutePath)
            def nextTransform = transformInvocation.outputProvider.getContentjsLocation(jar.name, jar.contentTypes,jar.scopes, Format.JAR)
            FileUtils.copyFile(jar.file, nextTransform)
        }
    }
    println "transform end"
}

在transform函数中有一个参数TransformInvocation,能够获取输入,因为自定义transform是放在头部,所以能够获取到的就是jar包、class字节码等资源,如下:

public interface TransformInput {
    /**
     * Returns a collection of {@link JarInput}.
     */
    @NonNull
    Collection<JarInput> getJarInputs();
    /**
     * Returns a collection of {@link DirectoryInput}.
     */
    @NonNull
    Collection<DirectoryInput> getDirectoryInputs();
}

2.3.1 Jar包处理

对于jar包,我们不需要处理,直接作为输出扔给下一级的transform处理,那么如何获取到输出,就是通过TransformInvocation获取TransformOutputProvider,获取输出文件的位置,将jar包拷贝进去即可

//对jar包不处理,直接扔给下一个Transform
input.jarInputs.each { jar ->
    println "jar包路径  $jar.file.absolutePath"
    classPool.appendClassPath(jar.file.absolutePath)
    def nextTransform = transformInvocation.outputProvider.getContentLocation(jar.name, jar.contentTypes,jar.scopes, Format.JAR)
    FileUtils.copyFile(jar.file, nextTransform)
}

2.3.2 字节码处理

对于字节码处理,transform拿到的就是javac文件夹下的全部class文件

Android进阶从字节码插桩技术了解美团热修复实例详解

通过日志打印就能得知,只从这个位置取class文件

Android进阶从字节码插桩技术了解美团热修复实例详解

//对于class字节码,需要处理
input.directoryInputs.each { dic ->
    println "dic路径 $dic.file.absolutePath"
    classPool.appendClassPath(dic.file.absolutePath)
    //插入代码 -- javassist
python    //找到class在哪,需要遍历class
    findTargetClass(dic.file, dic.file.absolutePath)
    def nextTransform = transformInvocation.outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY)
    FileUtils.copyDirectory(dic.file, nextTransform)
}

在拿到classes文件夹根目录之后,只需要递归遍历这个文件夹,然后拿到全部的class文件,执行代码插入

/**
 * 递归查找class文件
 * @param file classes文件夹
 * @param fileName  ../build/javac/debug/classes 路径名
 */
private void findTargetClass(File file, String fileName) {
    //递归查找
    if (file.isDirectory()) {
        file.listFiles().each {
            findTargetClass(it, fileName)
        }
    } else {
        //如果是文件
        modify(file, fileName)
    }
}

递归查找,我们拿本小节开始的那个图,如果拿到了BuildConfig.class文件,那么就需要获取当前字节码文件的全类名,然后从字节码池子中获取这个字节码信息

/**
 * 获取字节码文件全类名
 * @param file   BuildConfig.class
 * @param fileName  ../build/javac/debug/classes 路径名
 */
private void modify(File file, String fileName) {
    def fullName = file.absolutePath
    if (!fullName.endsWith(SdkConstants.DOT_CLASzppZPzTS)) {
        return
    }
    if (fileName.contains("BuildConfig.class") || fileName.contains("R")) {
        return
    }
    //获取当前class的全类名 com.tal.demo02.MainActivity.class
    def temp = fullName.replace(filhttp://www.devze.comeName, "").replace("/", ".")
    def className = temp.replace(SdkConstants.DOT_CLASS, "").substring(1)
    println "className $className"
    //从字节码池中找到ctClass
    def ctClass = classPool.get(className)
    if (className.contains("com.tal.demo02")) {
        //如果是在当前这个包名下的类,才会执行插桩操作
        insertCode(ctClass, fileName)
    }
}

怎么获取字节码文件的全类名,其实这里是用了一个取巧的方式,因此我们能拿到字节码文件所在的绝对路径,然后把classes文件夹路径去掉,将 / 替换为 . ,然后再把.class后缀去掉,就拿到了全类名。

2.4 Javassist织入代码

前面我们已经拿到了字节码的全类名,那么就可以从Javassist提供的ClassPool字节码池中,通过全类名获取CtClass,CtClass包含了当前字节码的全部信息,可以通过类似反射的方式,来获取方法、参数等属性,加以构造

2.4.1 ClassPool

ClassPool可以看做是一个字节码池,在ClassPool中维护了一个Hashtable,key为类的名字也就是全类名,通过全类名能够获取CtClass

public ClassPool(ClassPool parent) {
    this.classes = new Hashtable(INIT_HASH_SIZE);
    this.source = new ClassPoolTail();
    this.parent = parent;
    if (parent == null) {
        CtClass[] pt = CtClass.primitiveTypes;
        for (int i = 0; i < pt.length; ++i)
            classes.put(pt[i].getName(), pt[i]);
    }
    this.cflow = null;
    this.compressCount = 0;
    clearImportedPackages();
}

在遍历输入文件的时候,我们把字节码的路径添加到ClassPool中,那么在查找的时候(调用get方法),其实就是从这个路径下查找字节码文件,如果查找到了就返回CtClass

classPool.appendClassPath(jar.file.absolutePath)

2.4.2 CtClass

通过CtClass能够像使用反射的方式那样获取方法CtMethod

private void insertCode(CtClass ctClass, String fileName) {
    //拿到了这个类,需要反射获取方法,在某些方法下面加
    try {
        def method = ctClass.getDeclaredMethod("getClassId")
        if(method != null){
            //在这个方法之前插入
            method.insertBefore("if(a &gt; 0){\n" +
                    "            \n" +
                    "            return \"\";\n" +
                    "        }")
            ctClass.writeFile(fileName)
        }
    }catch(Exception e){
    }finally{
        ctClass.detach()
    }
}

通过CtMethod可以设置,在方法之前、方法之后、或者方法中某个行号中插入代码,最终通过CtClass的writeFile方法,将字节码重新规整,最终像处理Jar文件一样,将处理的文件交给下一级的transform处理。

最终可以看一下效果,在MainActivity中一个getClassId方法,一开始只是返回了id_0009989799,我们将一部分代码织入后,字节码变成下面的样子。

 public String getClassId() {
    return this.a > 0 ? "" : "id_0009989799";
 }

所以,美团Robust在热修复时,是以同样的方式(美团采用的是ASM字节码插桩,本文使用的是Javassist),在每个方法中织入了一段判断逻辑代码,当线上出现问题之后,通过某种方式使得代码执行这个判断逻辑,实现了即时修复

以上就是Android进阶从字节码插桩技术了解美团热修复实例详解的详细内容,更多关于Android 美团热修复的资料请关注我们其它相关文章!

0

上一篇:

下一篇:

精彩评论

暂无评论...
验证码 换一张
取 消

最新开发

开发排行榜