我的编程空间,编程开发者的网络收藏夹
学习永远不晚

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

短信预约 -IT技能 免费直播动态提醒
省份

北京

  • 北京
  • 上海
  • 天津
  • 重庆
  • 河北
  • 山东
  • 辽宁
  • 黑龙江
  • 吉林
  • 甘肃
  • 青海
  • 河南
  • 江苏
  • 湖北
  • 湖南
  • 江西
  • 浙江
  • 广东
  • 云南
  • 福建
  • 海南
  • 山西
  • 四川
  • 陕西
  • 贵州
  • 安徽
  • 广西
  • 内蒙
  • 西藏
  • 新疆
  • 宁夏
  • 兵团
手机号立即预约

请填写图片验证码后获取短信验证码

看不清楚,换张图片

免费获取短信验证码

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

引言

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

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

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

对于每个模块,如果想要插桩需要引入robust插件,所以如果自己实现一个简单的robust的功能,就需要创建一个插件,然后在插件中处理逻辑,我个人喜欢在buildclass="lazy" data-src里写插件然后发布,当然也可以自己创建一个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个文件夹

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仓库的地址,这里就是写在本地一个文件夹

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

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

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包时找不到清单文件,因此需要在资源文件夹下声明清单文件

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

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

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

2 Javassist

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

2.1 准备工作

引入Javassist,插件工程引入Javassist

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

2.2 Transform

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

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

在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{
    
    @Override
    String getName() {
        return "MyRobust"
    }
    
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }
    
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }
    
    @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等文件做处理

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

Task :app:transformClassesWithMyRobustForDebug

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.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)
        }
    }
    println "transform end"
}

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

public interface TransformInput {
    
    @NonNull
    Collection<JarInput> getJarInputs();
    
    @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文件

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

//对于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)
}

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


private void findTargetClass(File file, String fileName) {
    //递归查找
    if (file.isDirectory()) {
        file.listFiles().each {
            findTargetClass(it, fileName)
        }
    } else {
        //如果是文件
        modify(file, fileName)
    }
}

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


private void modify(File file, String fileName) {
    def fullName = file.absolutePath
    if (!fullName.endsWith(SdkConstants.DOT_CLASS)) {
        return
    }
    if (fileName.contains("BuildConfig.class") || fileName.contains("R")) {
        return
    }
    //获取当前class的全类名 com.tal.demo02.MainActivity.class
    def temp = fullName.replace(fileName, "").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 美团热修复的资料请关注编程网其它相关文章!

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

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

下载Word文档到电脑,方便收藏和打印~

下载Word文档

猜你喜欢

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

这篇文章主要为大家介绍了Android进阶从字节码插桩技术了解美团热修复实例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
2023-01-29

编程热搜

  • Android:VolumeShaper
    VolumeShaper(支持版本改一下,minsdkversion:26,android8.0(api26)进一步学习对声音的编辑,可以让音频的声音有变化的播放 VolumeShaper.Configuration的三个参数 durati
    Android:VolumeShaper
  • Android崩溃异常捕获方法
    开发中最让人头疼的是应用突然爆炸,然后跳回到桌面。而且我们常常不知道这种状况会何时出现,在应用调试阶段还好,还可以通过调试工具的日志查看错误出现在哪里。但平时使用的时候给你闹崩溃,那你就欲哭无泪了。 那么今天主要讲一下如何去捕捉系统出现的U
    Android崩溃异常捕获方法
  • android开发教程之获取power_profile.xml文件的方法(android运行时能耗值)
    系统的设置–>电池–>使用情况中,统计的能耗的使用情况也是以power_profile.xml的value作为基础参数的1、我的手机中power_profile.xml的内容: HTC t328w代码如下:
    android开发教程之获取power_profile.xml文件的方法(android运行时能耗值)
  • Android SQLite数据库基本操作方法
    程序的最主要的功能在于对数据进行操作,通过对数据进行操作来实现某个功能。而数据库就是很重要的一个方面的,Android中内置了小巧轻便,功能却很强的一个数据库–SQLite数据库。那么就来看一下在Android程序中怎么去操作SQLite数
    Android SQLite数据库基本操作方法
  • ubuntu21.04怎么创建桌面快捷图标?ubuntu软件放到桌面的技巧
    工作的时候为了方便直接打开编辑文件,一些常用的软件或者文件我们会放在桌面,但是在ubuntu20.04下直接直接拖拽文件到桌面根本没有效果,在进入桌面后发现软件列表中的软件只能收藏到面板,无法复制到桌面使用,不知道为什么会这样,似乎并不是很
    ubuntu21.04怎么创建桌面快捷图标?ubuntu软件放到桌面的技巧
  • android获取当前手机号示例程序
    代码如下: public String getLocalNumber() { TelephonyManager tManager =
    android获取当前手机号示例程序
  • Android音视频开发(三)TextureView
    简介 TextureView与SurfaceView类似,可用于显示视频或OpenGL场景。 与SurfaceView的区别 SurfaceView不能使用变换和缩放等操作,不能叠加(Overlay)两个SurfaceView。 Textu
    Android音视频开发(三)TextureView
  • android获取屏幕高度和宽度的实现方法
    本文实例讲述了android获取屏幕高度和宽度的实现方法。分享给大家供大家参考。具体分析如下: 我们需要获取Android手机或Pad的屏幕的物理尺寸,以便于界面的设计或是其他功能的实现。下面就介绍讲一讲如何获取屏幕的物理尺寸 下面的代码即
    android获取屏幕高度和宽度的实现方法
  • Android自定义popupwindow实例代码
    先来看看效果图:一、布局
  • Android第一次实验
    一、实验原理 1.1实验目标 编程实现用户名与密码的存储与调用。 1.2实验要求 设计用户登录界面、登录成功界面、用户注册界面,用户注册时,将其用户名、密码保存到SharedPreference中,登录时输入用户名、密码,读取SharedP
    Android第一次实验

目录