最通俗易懂的字节码插桩实战(Gradle + ASM)—— 自动埋点
Posted xhmj12
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了最通俗易懂的字节码插桩实战(Gradle + ASM)—— 自动埋点相关的知识,希望对你有一定的参考价值。
相关阅读:一个90后员工猝死的全过程
作者:miaowmiaow
链接:https://www.jianshu.com/p/c2132273257a
字节码插桩,看起来挺牛皮,实际上是真的很牛皮。
但是牛皮不代表难学,只需要一点前置知识就能轻松掌握。
Gradle Transform
Google在android Gradle的1.5.0 版本以后提供了 Transfrom API,允许开发者在项目的编译过程中操作 .class 文件。Transfrom需要介绍的地方不多,唯一的难点就是要熟悉API,我会在文尾推荐相关文章,这里就不过多介绍,影响大家的阅读体验。
ASM
ASM是一种通用Java字节码操作和分析框架。它可以用于修改现有的class文件或动态生成class文件。
刚去了解ASM的时候,我是真的差点被字节码吓退,字节码这东西根本就不是给人读的,在我认知里能去读字节码的都是大神。就在我准备放弃时,ASM Bytecode Viewer从天而降拯救了我。
ASM Bytecode Viewer
ASM Bytecode Viewer是一款能 查看字节码 和 生成ASM代码 的插件,帮助我们打败了ASM学习路上最大的拦路虎,剩下就是对ASM的熟悉和使用可以说是so easy。
1.在Android Studio中搜索 ASM Bytecode Viewer Support Kotlin 找到并安装
2.代码右键 ASM Bytecode Viewer 便能自动生成ASM插桩代码,效果如下:
实战:
前面介绍了 Gradle Transform 、 ASM 及 ASM Bytecode Viewer,现在就正式进入实战,先看下目录结构:
1、StatisticPlugin
顾名思义就是我们本次编写的插件,在apply
方法的注册 BuryPointTransform,读取 build.gradle 里面配置的需要埋点的方法和注解。(Gradle Transform属实没啥好介绍,后面我就不过多哔哔,直接上代码和注释。熟悉并觉得无聊可直接跳到 BuryPointMethodVisitor)
class StatisticPlugin implements Plugin<Project> {
public final static HashMap<String, BuryPointCell> HOOKS = new HashMap<>()
@Override
void apply(Project project) {
def android = project.extensions.findByType(AppExtension)
// 注册BuryPointTransform
android.registerTransform(new BuryPointTransform())
// 获取gradle里面配置的埋点信息
def extension = project.extensions.create('buryPoint', BuryPointExtension)
project.afterEvaluate {
// 遍历配置的埋点信息,将其保存在HOOKS方便调用
extension.hooks.each { Map<String, Object> map ->
BuryPointCell cell = new BuryPointCell()
if(map.containsKey("isAnnotation")){
cell.isAnnotation = map.get("isAnnotation")
}
if(map.containsKey("isMethodExit")){
cell.isMethodExit = map.get("isMethodExit")
}
if(map.containsKey("agentName")){
cell.agentName = map.get("agentName")
}
if(map.containsKey("agentDesc")){
cell.agentDesc = map.get("agentDesc")
}
if(map.containsKey("agentParent")){
cell.agentParent = map.get("agentParent")
}
if (cell.isAnnotation) {
if(map.containsKey("annotationDesc")){
cell.annotationDesc = map.get("annotationDesc")
}
if(map.containsKey("annotationParams")){
cell.annotationParams = map.get("annotationParams")
}
HOOKS.put(cell.annotationDesc, cell)
} else {
if(map.containsKey("methodName")){
cell.methodName = map.get("methodName")
}
if(map.containsKey("methodDesc")){
cell.methodDesc = map.get("methodDesc")
}
if(map.containsKey("methodParent")){
cell.methodParent = map.get("methodParent")
}
HOOKS.put(cell.methodName + cell.methodDesc, cell)
}
}
}
}
}
2、BuryPointTransform
通过transform
方法的 Collection<TransformInput> inputs
对 .class文件遍历拿到所有方法
class BuryPointTransform extends Transform {
...省略中间非关键代码,详细请到github中查看...
/**
*
* @param context
* @param inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历
* @param outputProvider 输出路径
*/
@Override
void transform(
@NonNull Context context,
@NonNull Collection<TransformInput> inputs,
@NonNull Collection<TransformInput> referencedInputs,
@Nullable TransformOutputProvider outputProvider,
boolean isIncremental
) throws IOException, TransformException, InterruptedException {
if (!incremental) {
//不是增量更新删除所有的outputProvider
outputProvider.deleteAll()
}
inputs.each { TransformInput input ->
//遍历目录
input.directoryInputs.each { DirectoryInput directoryInput ->
handleDirectoryInput(directoryInput, outputProvider)
}
// 遍历jar 第三方引入的 class
input.jarInputs.each { JarInput jarInput ->
handleJarInput(jarInput, outputProvider)
}
}
}
/**
* 处理文件目录下的class文件
*/
static void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
//是否是目录
if (directoryInput.file.isDirectory()) {
//列出目录所有文件(包含子文件夹,子文件夹内文件)
directoryInput.file.eachFileRecurse { File file ->
def name = file.name
if (filterClass(name)) {
ClassReader classReader = new ClassReader(file.bytes)
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
ClassVisitor classVisitor = new BuryPointVisitor(classWriter)
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
FileOutputStream fos = new FileOutputStream(file.parentFile.absolutePath + File.separator + name)
fos.write(code)
fos.close()
}
}
}
//文件夹里面包含的是我们手写的类以及R.class、BuildConfig.class以及R$XXX.class等
// 获取output目录
def dest = outputProvider.getContentLocation(
directoryInput.name,
directoryInput.contentTypes,
directoryInput.scopes,
Format.DIRECTORY)
//这里执行字节码的注入,不操作字节码的话也要将输入路径拷贝到输出路径
FileUtils.copyDirectory(directoryInput.file, dest)
}
/**
* 处理jar文件,一般是第三方依赖库jar文件
*/
static void handleJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {
if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
//重名名输出文件,因为可能同名,会覆盖
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
JarFile jarFile = new JarFile(jarInput.file)
Enumeration enumeration = jarFile.entries()
File tmpFile = new File(jarInput.file.getParent() + File.separator + "temp.jar")
//避免上次的缓存被重复插入
if (tmpFile.exists()) {
tmpFile.delete()
}
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
//用于保存
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement()
String entryName = jarEntry.getName()
ZipEntry zipEntry = new ZipEntry(entryName)
InputStream inputStream = jarFile.getInputStream(jarEntry)
//插桩class
if (filterClass(entryName)) {
//class文件处理
jarOutputStream.putNextEntry(zipEntry)
ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
ClassVisitor classVisitor = new BuryPointVisitor(classWriter)
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
byte[] bytes = classWriter.toByteArray()
jarOutputStream.write(bytes)
} else {
jarOutputStream.putNextEntry(zipEntry)
jarOutputStream.write(IOUtils.toByteArray(inputStream))
}
jarOutputStream.closeEntry()
}
//结束
jarOutputStream.close()
jarFile.close()
//生成输出路径 + md5Name
def dest = outputProvider.getContentLocation(
jarName + md5Name,
jarInput.contentTypes,
jarInput.scopes,
Format.JAR)
FileUtils.copyFile(tmpFile, dest)
tmpFile.delete()
}
}
/**
* 检查class文件是否需要处理
* @param fileName
* @return
*/
static boolean filterClass(String name) {
return (name.endsWith(".class")
&& !name.startsWith("R\\$")
&& "R.class" != name
&& "BuildConfig.class" != name)
}
}
3、BuryPointVisitor
通过visitMethod
拿到方法进行修改
class BuryPointVisitor extends ClassVisitor {
...省略中间非关键代码,详细请到github中查看...
/**
* 扫描类的方法进行调用
* @param access 修饰符
* @param name 方法名字
* @param descriptor 方法签名
* @param signature 泛型信息
* @param exceptions 抛出的异常
* @return
*/
@Override
MethodVisitor visitMethod(int methodAccess, String methodName, String methodDescriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(methodAccess, methodName, methodDescriptor, signature, exceptions)
return new BuryPointMethodVisitor(methodVisitor, methodAccess, methodName, methodDescriptor)
}
}
4、BuryPointMethodVisitor
终于到了本次文章的核心代码了。visitAnnotation
在扫描到注解时调用。我们通过 descriptor 来判断是否是需要埋点的注解,如果是则保存注解参数和对应的方法名称,等到onMethodEnter
时进行代码插入。visitInvokeDynamicInsn
在描到lambda表达式时调用,bootstrapMethodArguments[0] 得到方法描述,通过 name + desc 判断当前lambda表达式是否是需要的埋点的方法,如果是则保存lambda方法名称,等到onMethodEnter
时进行代码插入。onMethodEnter
在进入方法时调用,这里就是我们插入代码的地方了。通过 methodName + methodDescriptor 判断当前方法是否是需要的埋点的方法,如果是则插入埋点方法。
——重点,要考,画起来——
mv.visitVarInsn(store, i + methodArgumentSize + 1)
为什么要 +methodArgumentSize 呢?
答:简单的说就是我们通过visitLdcInsn
把注解参数压入到局部变量表中,而局部变量表(Local Variable Table)是一组变量值存储空间,用于存放 方法参数和方法内定义的局部变量,所以在取值的时候要方法参数的数量。
2.int slotIndex = isStatic(methodAccess) ? 0 : 1
为什么 static 方法是0开始计算?
答:普通方法的局部变量表第一个参数是this(当前对象的引用)所以要加1。
class BuryPointMethodVisitor extends AdviceAdapter {
int methodAccess
String methodName
String methodDescriptor
BuryPointMethodVisitor(MethodVisitor methodVisitor, int access, String name, String desc) {
super(Opcodes.ASM7, methodVisitor, access, name, desc)
this.methodAccess = access
this.methodName = name
this.methodDescriptor = desc
}
/**
* 扫描类的注解时调用
* @param descriptor 注解名称
* @param visible
* @return
*/
@Override
AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
AnnotationVisitor annotationVisitor = super.visitAnnotation(descriptor, visible)
// 通过descriptor判断是否是需要扫描的注解
BuryPointCell cell = StatisticPlugin.HOOKS.get(descriptor)
if (cell != null) {
BuryPointCell newCell = cell.clone()
return new BuryPointAnnotationVisitor(annotationVisitor) {
@Override
void visit(String name, Object value) {
super.visit(name, value)
// 保存注解的参数值
newCell.annotationData.put(name, value)
}
@Override
void visitEnd() {
super.visitEnd()
newCell.methodName = methodName
newCell.methodDesc = methodDescriptor
StatisticPlugin.HOOKS.put(newCell.methodName + newCell.methodDesc, newCell)
}
}
}
return annotationVisitor
}
/**
* lambda表达式时调用
* @param name
* @param descriptor
* @param bootstrapMethodHandle
* @param bootstrapMethodArguments
*/
@Override
void visitInvokeDynamicInsn(String name, String descriptor, Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) {
super.visitInvokeDynamicInsn(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments)
String desc = (String) bootstrapMethodArguments[0]
BuryPointCell cell = StatisticPlugin.HOOKS.get(name + desc)
if (cell != null) {
String parent = Type.getReturnType(descriptor).getDescriptor()
if (parent == cell.methodParent) {
Handle handle = (Handle) bootstrapMethodArguments[1]
BuryPointCell newCell = cell.clone()
newCell.isLambda = true
newCell.methodName = handle.getName()
newCell.methodDesc = handle.getDesc()
StatisticPlugin.HOOKS.put(newCell.methodName + newCell.methodDesc, newCell)
}
}
}
/**
* 方法进入时调用
*/
@Override
protected void onMethodEnter() {
super.onMethodEnter()
BuryPointCell buryPointCell = StatisticPlugin.HOOKS.get(methodName + methodDescriptor)
if (buryPointCell != null && !buryPointCell.isMethodExit) {
onMethod(buryPointCell)
}
}
/**
* 方法退出前调用
*/
@Override
protected void onMethodExit(int opcode) {
BuryPointCell buryPointCell = StatisticPlugin.HOOKS.get(methodName + methodDescriptor)
if (buryPointCell != null && buryPointCell.isMethodExit) {
onMethod(buryPointCell)
}
super.onMethodExit(opcode)
}
private void onMethod(BuryPointCell cell) {
// 获取方法参数
Type methodType = Type.getMethodType(methodDescriptor)
Type[] methodArguments = methodType.getArgumentTypes()
int methodArgumentSize = methodArguments.size()
if (cell.isAnnotation) { // 遍历注解参数并赋值给采集方法
def entrySet = cell.annotationParams.entrySet()
def size = entrySet.size()
for (int i = 0; i < size; i++) {
def key = entrySet[i].getKey()
if (key == "this") {
mv.visitVarInsn(Opcodes.ALOAD, 0)
} else {
def load = entrySet[i].getValue()
def store = getVarInsn(load)
mv.visitLdcInsn(cell.annotationData.get(key))
mv.visitVarInsn(store, i + methodArgumentSize + 1)
mv.visitVarInsn(load, i + methodArgumentSize + 1)
}
}
mv.visitMethodInsn(INVOKESTATIC, cell.agentParent, cell.agentName, cell.agentDesc, false)
// 防止其他类重名方法被插入
StatisticPlugin.HOOKS.remove(methodName + methodDescriptor, cell)
} else { // 将扫描方法参数赋值给采集方法
// 采集数据的方法参数起始索引( 0:this,1+:普通参数 ),如果是static,则从0开始计算
int slotIndex = isStatic(methodAccess) ? 0 : 1
// 获取采集方法参数
Type agentMethodType = Type.getMethodType(cell.agentDesc)
Type[] agentArguments = agentMethodType.getArgumentTypes()
List<Type> agentArgumentList = new ArrayList<Type>(Arrays.asList(agentArguments))
// 遍历方法参数
for (Type argument : methodArguments) {
int size = argument.getSize()
int opcode = argument.getOpcode(ILOAD)
String descriptor = argument.getDescriptor()
Iterator<Type> agentIterator = agentArgumentList.iterator()
// 遍历采集方法参数
while (agentIterator.hasNext()) {
Type agentArgument = agentIterator.next()
String agentDescriptor = agentArgument.getDescriptor()
if (agentDescriptor == descriptor) {
mv.visitVarInsn(opcode, slotIndex)
agentIterator.remove()
break
}
}
slotIndex += size
}
if (agentArgumentList.size() > 0) { // 无法满足采集方法参数则return
return
}
mv.visitMethodInsn(INVOKESTATIC, cell.agentParent, cell.agentName, cell.agentDesc, false)
if(cell.isLambda){
StatisticPlugin.HOOKS.remove(methodName + methodDescriptor, cell)
}
}
}
/**
* 推断类型
* int ILOAD = 21; int ISTORE = 54;
* 33 = ISTORE - ILOAD
*
* @param load
* @returno
*/
private static int getVarInsn(int load) {
return load + 33
}
private static boolean isStatic(int access) {
return (access & Opcodes.ACC_STATIC) != 0
}
}
5、 如何使用?
5.1、 先打包插件到本地仓库进行引用
5.2、 在项目的根build.gradle加入插件的依赖
repositories {
google()
mavenCentral()
jcenter()
maven{
url uri('repos')
}
}
dependencies {
classpath "com.android.tools.build:gradle:$gradle_version"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.meituan.android.walle:plugin:1.1.7'
// 使用自定义插件
classpath 'com.example.plugin:statistic:1.0.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
5.3、 在app的build.gradle中使用并配置参数
plugins {
id 'com.android.application'
id 'statistic'
}
buryPoint {
hooks = [
[
'agentName' : 'viewOnClick', //采集数据的方法名
'agentDesc' : '(Landroid/view/View;)V', //采集数据的方法描述(参数应在methodDesc范围之内)
'agentParent' : 'com/example/fragment/project/utils/StatisticHelper', //采集数据的方法的路径
'isAnnotation': false,
'methodName' : 'onClick', //插入的方法名
'methodDesc' : '(Landroid/view/View;)V', //插入的方法描述
'methodParent': 'Landroid/view/View$OnClickListener;', //插入的方法的实现接口
],
[
//注解标识
'isAnnotation' : true,
//方式插入时机,true方法退出前,false方法进入时
'isMethodExit' : true,
//采集数据的方法的路径
'agentParent' : 'com/example/fragment/library/common/utils/StatisticHelper',
//采集数据的方法名
'agentName' : 'testAnnotation',
//采集数据的方法描述(对照annotationParams,注意参数顺序)
'agentDesc' : '(Ljava/lang/Object;ILjava/lang/String;)V',
//扫描的注解名称
'annotationDesc' : 'Lcom/example/fragment/library/common/utils/TestAnnotation;',
//扫描的注解的参数
'annotationParams': [
//参数名 : 参数类型(对应的ASM指令,加载不同类型的参数需要不同的指令)
//this : 所在方法的当前对象的引用(默认关键字,按需可选配置)
'this' : org.objectweb.asm.Opcodes.ALOAD,
'code' : org.objectweb.asm.Opcodes.ILOAD,
'message': org.objectweb.asm.Opcodes.ALOAD,
]
],
]
}
6、 运行项目查看输出日志
2021-06-28 20:04:49.544 25211-25211/com.example.fragment.project.debug I/----------自动埋点:注解: MainActivity.onCreate:false
2021-06-28 20:05:03.535 25211-25211/com.example.fragment.project.debug I/----------自动埋点: ViewId:menu ViewText:null
2021-06-28 20:05:06.085 25211-25211/com.example.fragment.project.debug I/----------自动埋点: ViewId:coin ViewText:我的积分
2021-06-28 20:05:08.039 25211-25211/com.example.fragment.project.debug I/----------自动埋点: ViewId:black ViewText:null
2021-06-28 20:05:11.616 25211-25211/com.example.fragment.project.debug I/----------自动埋点: ViewId:username ViewText:去登录
2021-06-28 20:05:16.816 25211-25211/com.example.fragment.project.debug I/----------自动埋点: ViewId:login ViewText:登录
项目地址
以上是关于最通俗易懂的字节码插桩实战(Gradle + ASM)—— 自动埋点的主要内容,如果未能解决你的问题,请参考以下文章
Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战
Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战