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 > 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