四、java字节码插桩入门

本文讨论的是 Android开发中的字节码插桩。

而插桩的实现方式,门派繁多,有各种流派,本文只是介绍其中一种。特别是谷歌推行 build.gradle.ktx之后,又兴起了一套新的插桩写法。

概念

字节码插桩,实际上就是在 java代码的编译过程中创建hook,将 我们编写的java(koltin)代码生成最终apk之前,对 中间产物进行篡改,达到在不改动业务代码的前提下 嵌入自定义逻辑的目的。

下图表示了 android 工程中java代码到dex的过程:

  • 在javac这个阶段,我们可以通过APT 自定义注解的方式 生成更多的java文件,或者修改已有的java文件,这同样也是一种hook
  • 在dx阶段,我们可以通过AspectJ 或者 ASM 处理class文件,在class文件中加入我们自己的逻辑

两个阶段我们都能hook,区别是,后者难度更大,门槛更高,但是操作上限也更高。

ASM插桩之后,整个流程的示意图如下:

避坑必读

很多人看了网上一些视频教程之后发现,视频上运行蛮好的,到了自己手里就死活不行,运行结果达不到预期。这其实就是因为开发环境。

就个人踩坑的经验而言,主要的坑来自于 Andorid编译环境中各种依赖的版本关联,以及AndoridStudio版本对于Gradle版本存在硬性要求。

主要的版本关联关系在以下几个东西之间:

  • AndroidStudio版本

    如下图,

它对于 Gradle版本有一个最低要求和最高要求。随着AndroidStudio版本的迭代,最低要求可能提高,它将不再兼容某些低版本的AGP,而同时它也将适配更高版本的AGP。

As与AGP版本关联参考链接

如下图:

也就是说,我目前的AndoridStudio版本,最低只能兼容到AGP的3.2版本。


  • Gradle版本

    Gradle是Android官方指定的项目构建工具,如图所示,在安卓工程中会指定它的版本号

  • Android Gradle Plugin(AGP)版本

    • AGP是它的缩写,每一个AGP版本都是基于一个Gradle版本而开发的,所以一旦Gradle升级大版本而出现了破坏性的改动,那么这个Gradle版本将不再兼容旧的ATP版本。AGP必须跟随gradle进行迭代。

    • 安卓项目中AGP版本在这里:

    • 官方放出的版本对应关系如图:

      developer.android.google.cn/studio/rele...

综上所述,如果我的AndroidStudio版本是上图所示的 Electric Ele 2022.1.1,那么AGP的最低版本是3.2,进而3.2需要的最低Gradle版本是 4.6

常见问题

Minimum supported

vbnet 复制代码
Minimum supported Gradle version is 6.7.1. Current version is 5.4.1.

Please fix the project's Gradle settings.
Change Gradle version in Gradle wrapper to 6.7.1 and re-import project
Open Gradle wrapper properties
Gradle Settings.

它在提示你升级Gradle版本,也就说明,你要想使用当前的AGP版本,就要把这个AGP版本所依赖的Gradle版本升级到它的最低要求。

插桩的应用场景

在复杂的业务场景里面,经常会有一些重复性的动作,比如:

  • 日志埋点
  • 双击防抖
  • 整治乱开线程
  • 监控大图加载
  • 登录鉴权拦截重定向

插桩工具介绍

  • aspectJ

    经典的插桩工具,它是老牌的AOP (面向切面编程)框架,成熟稳定,无需对class文件由深入了解便能插入逻辑

  • ASM

    直接在字节码层面操作字节码,可以生成新的字节码,也可以修改已有的字节码

理论知识

Transform

ASM是我们用来操作字节码的工具,那么首先我们必须要找到想要操作的字节码文件。这一切的前提,就是 Gradle为我们提供的 trasform 任务。

transform任务是 在所有java文件都被编译成class之后执行的任务,我们可以通过自定义Gradle插件,在当前gradle project对象中插入我们自己的Transform对象。

如下图:

ASM

通过transform任务拿到所有的 class之后,我们必须过滤出我们需要处理的class文件,然后再启用ASM来操作这些class。

在具体使用时,ASM其实就是 两个java依赖库:

java 复制代码
    implementation 'org.ow2.asm:asm:7.1'
    implementation 'org.ow2.asm:asm-commons:7.1'

它所发挥的作用主要有:

1、转化

将一个class文件转化成一个可处理的 类对象

java 复制代码
ClassReader classReader = new ClassReader(file.bytes)
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)

此阶段的最终产物是:classWriter对象

2、传递

上阶段中的classWriter传入 自定义的 XXXClassVisitor (类访问者)

java 复制代码
ClassVisitor classVisitor = new XXXClassVisitor(classWriter) 
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)

3、访问

XXXClassVisitor (类访问者) 的代码如下:

假如我们想要在每一个继承自 AppCompatActivity的子类的onCreate方法中打印我们自己的日志,就可以如下书写:

java 复制代码
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class XXXClassVisitor extends ClassVisitor {

    private String className;
    private String superName;

    public LifecycleClassVisitor(ClassVisitor cv) {
        super(Opcodes.ASM5, cv);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        this.className = name;
        this.superName = superName;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        System.out.println("ClassVisitor visitMethod name---" + name +
                " ,superName----" + superName);
        MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);

        if (superName.equals("android/support/v7/app/AppCompatActivity")
                || superName.equals("androidx/appcompat/app/AppCompatActivity")
        ) {
            if (name.startsWith("onCreate")) {
                return new XXXMethodVisitor(mv, className, name);
            }
        }

        return mv;
    }
}

4、篡改

这其中,又提到了新的概念 XXXMethodVisitor(方法访问者):

通常我们会在 方法访问者中,在方法的执行前或者执行后,加入自定义逻辑。示例代码如下:

java 复制代码
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class LifecycleMethodVisitor extends MethodVisitor {

    private String className;
    private String methodName;

    public LifecycleMethodVisitor(MethodVisitor mv, String className, String methodName) {
        super(Opcodes.ASM5, mv);
        this.className = className;
        this.methodName = methodName;
    }

    @Override
    public void visitCode() {
        super.visitCode();
        System.out.println("Method visitor visited code---------");

        // ....编写自定义逻辑

    }
}

实践

其实ASM大部分关键操作已经在上文中贴出了代码,本节将从0开始,告诉大家如何从 自定义Gradle插件开始,引入ASM并操作class,最后将会贴出本文的github Demo地址。

Gradle插件有多种编写方式

上面提到的Transform是Gradle中的一个概念,代表了 apk打包流程中的一个环节。我们必须编写gradle插件来手动插入我们自己的transform。

编写gradle的方式有3种:

1. 直接编写xx.gradle文件

直接新建一个xx.gradle文件,或者 在已有的.gradle(比如build.gradle)文件中编写 groovy代码:

java 复制代码
class MyPluginExtension {
    // 为插件扩展定义一个字符串类型的变量
    String message = "Hello this is my custom plugin..."
}

// gradle自定义的插件必须继承Plugin接口
class GreetingPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        // 创建插件扩展,greeting为插件扩展的名称,可以在gradle文件其他地方使用
        def extension = project.extensions.create('greeting', MyPluginExtension)

        project.task('hello') {
            doLast {
                // 插件的任务就是打印message信息
                println extension.message
            }
        }
    }
}

// 使用这个自定义的插件
apply plugin: GreetingPlugin

此方法 难度最小,对项目的侵入性最小。

2. 创建buildSrc目录

它是安卓项目默认的脚本目录,名称必须是:buildSrc。插件代码可以用 groovy,java,或者kotlin来编写。gradle会自动识别并加载 其中的插件代码,并使它们成为 项目的一部分。无需声明依赖或者手动配置。

这种方式的特征是,gradle插件代码和 工程代码在一起,适用于针对项目做的特别定制。

不适用做通用性质的gradle插件。

3. 创建独立java library模块

如果你想开发一款gradle插件,发布到公网,供他人引用,此时,用 独立java lib模块的方式是最合适的,也是此文的重点。

先声明我的开发环境,环境不同可能会引起无法解决的问题:

  • Android Studio Electric Eel | 2022.1.1 Patch 1
  • Gradle gradle-5.4.1
  • android gradle plugin 3.4.2

下面是详细步骤:

一、 创建javaLibrary

创建完成之后,将其中的 build.gradle文件清空,然后输入如下内容:

java 复制代码
apply plugin: 'maven-publish' // 引入maven插件用于上传插件
apply plugin: 'groovy' // 引入groovy插件用于 编写groovy代码

dependencies {
    implementation gradleApi() // gradle插件必须依赖
    implementation localGroovy() // 返回当前gradle版本对应的 Groovy依赖

    // 依赖 android gradle plugin(AGP) 
    implementation 'com.android.tools.build:gradle:3.4.2' 

    // 依赖asm
    implementation 'org.ow2.asm:asm:7.1'
    implementation 'org.ow2.asm:asm-commons:7.1'

}


// 配置 maven仓库
afterEvaluate {
    publishing {
        repositories {
            maven {
                url = uri('../asm_lifecycle_repo') // 指向本地目录
            }
        }
        //    配置maven-publishing插件的输出物
        publications {
            mavenJava(MavenPublication) {
                from components.java
                groupId = 'com.zhou'  // 配置你的插件的 Group ID
                artifactId = 'asm_lifecycle'  // 配置你的插件的 Artifact ID
                version = '1.0.0'  // 配置你的插件的版本号
            }
        }

    }
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

此时,setting.gradle中会默认让这个模块生效:

java 复制代码
include ':app'
rootProject.name='SimpleAsmTest'
include ':asm_test'

二、 编写插件脚本

首先要编写的是 继承自 Plugin的类,它将会作为本次插件编写的总入口,当插件被引用到具体的业务模块之后,会先打印下面的 ==== GOTO MY GRADLE PLUGIN ======== 日志,并且把 我们自定义的LifeCycleTransform 加入到打包流程中。

java 复制代码
package com.zhou.asm_lifecycle

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

public class AsmLifeCyclePlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        println "==== GOTO    MY GRADLE PLUGIN ========"

        def android = project.extensions.getByType(AppExtension)
        println "---------- READY TO REGISTER LifeCycleTransform----------"
        LifeCycleTransform transform = new LifeCycleTransform()
        android.registerTransform(transform)

    }
}

然后是:LifeCycleTransform,自定义一个 LifeCycleTransform 继承自 Transform,它就是来自 gradleApi 这个依赖的类。

java 复制代码
package com.zhou.asm_lifecycle

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import com.zhou.lifecycle.LifecycleClassVisitor
import groovy.io.FileType
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.ClassWriter

class LifeCycleTransform extends Transform {

    @Override
    String getName() {
        return "LifeCycleTransform" // 指定任务的名称
    }


    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS  // 设置资源的检索范围为 所有的class文件
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        // 指定class文件的检索范围,当前PROJECT_ONLY的意思是,仅搜索Project中的
        // class文件
        return TransformManager.PROJECT_ONLY 

    }

    @Override
    boolean isIncremental() {
        // gradle编译安卓工程时,存在一个增量编译的概念,就是仅编译有变化的文件
        // 由于我们是要对所有的class文件做遍历,所以我们不能打开增量编译开关,设置false即可
        return false
    }

    // 核心方法
    @Override
    void transform(TransformInvocation transformInvocation)
            throws TransformException, InterruptedException, IOException {

        // 获得文件输入流
        def transformInputs = transformInvocation.inputs
        // 获得输出的文件流
        def outputProvider = transformInvocation.outputProvider

        // 对输入的文件流做遍历
        transformInputs.each { TransformInput input ->

            input.directoryInputs.each { DirectoryInput directoryInput ->
                def dir = directoryInput.file // 这里是所有的目录
                if (dir) { // 如果目录非空
                    // 就深度遍历目录下的所有文件,仅保留 .class文件,并且排除目录
                    dir.traverse(type: FileType.FILES, nameFilter: ~/.*.class/) { File file ->
                        System.out.println("findClass:" + file.name)

                        // 这些就是开始引用ASM对字节码进行重写的代码
                        ClassReader classReader = new ClassReader(file.bytes)
                        ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                        ClassVisitor classVisitor = new LifecycleClassVisitor(classWriter)
                        classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
                        byte[] bytes = classWriter.toByteArray()
                        FileOutputStream outputStream = new FileOutputStream(file.path)
                        outputStream.write(bytes)
                        outputStream.close()
                    }
                }

                // 用ASM对字节码重写之后,必须把字节码输出到原先的位置,否则运行apk会报找不到class的问题
                def dest = outputProvider.getContentLocation(
                        directoryInput.name,
                        directoryInput.contentTypes,
                        directoryInput.scopes, Format.DIRECTORY)

                FileUtils.copyDirectory(directoryInput.file, dest)
            }
        }

        super.transform(transformInvocation)
    }
}

三、 编写ASM操纵字节码的代码

此部分用java代码编写,因为 ASM本身就是java的类库。

在找到要改写的class文件之后,首先要做的就是自定义ClassVisitor,来决定哪些class需要我处理。

java 复制代码
package com.zhou.lifecycle;


import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class LifecycleClassVisitor extends ClassVisitor {

    private String className;
    private String superName;

    public LifecycleClassVisitor(ClassVisitor cv) {
        super(Opcodes.ASM5, cv);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        this.className = name;
        this.superName = superName;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        System.out.println("ClassVisitor visitMethod name---" + name +
                " ,superName----" + superName);
        MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);

        if (superName.equals("android/support/v7/app/AppCompatActivity")
                || superName.equals("androidx/appcompat/app/AppCompatActivity")
        ) {
            if (name.startsWith("onCreate")) {
                return new LifecycleMethodVisitor(mv, className, name);
            }
        }

        return mv;
    }

    @Override
    public void visitEnd() {
        super.visitEnd();
    }
}

以上ClassVisitor,将 所有集成自 AppCompatActivity 的 类,并 锁定了 onCreate方法 ,将这些类中的所有onCreate方法 全部交给 LifecycleMethodVisitor 进行处理,并 。其他不符合条件,全部略过。

LifecycleMethodVisitor 的源代码如下:

java 复制代码
package com.zhou.lifecycle;


import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class LifecycleMethodVisitor extends MethodVisitor {

    private String className;
    private String methodName;

    public LifecycleMethodVisitor(MethodVisitor mv, String className, String methodName) {
        super(Opcodes.ASM5, mv);
        this.className = className;
        this.methodName = methodName;
    }

    @Override
    public void visitCode() {
        super.visitCode(); 
        System.out.println("Method visitor visited code---------");

        mv.visitLdcInsn("TAG"); // 预设方法入参1
        mv.visitLdcInsn(className + "---->" + methodName); // 预设方法入参2
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, // 调用静态方法
                "android/util/Log", // 方法所在的类签名
                "i", // 方法名i
                "(Ljava/lang/String;Ljava/lang/String;)I", // 锁定了 android.util.Log类中的静态方法 int i(String tag,String message)
                false // 不是接口,所以传false
        );
        mv.visitInsn(Opcodes.POP);
    }
}

这段代码可能比较费解,其实这背后有一串理论知识: JVM的方法执行流程,大概描述如下:

  • 每一个方法在执行时,都会生成一个栈帧,这个栈帧会被压入 JVM的虚拟机栈
  • 先压入一个 TAG字符串
  • 再压入一个 值为(className + "---->" + methodName)的字符串
  • 用这两个字符串执行 android.util.Log.i(s1,s2) 方法
  • 由于Log.i 会产生一个int类型的返回值,而我们所篡改的onCreate方法是没有返回值的,所以必须将这个返回值弹出栈,防止onCreate方法执行过程中报错。

四、发布插件

在以上代码顺利编译之后,你将会在androidStudio的右侧面板中看到 publish按钮:

双击它,然后在 插件module的同级位置会出现一个 文件夹:

五、使用插件

全局build.gradle中必须设置仓库地址,并加载插件:

java 复制代码
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        maven {
            url 'asm_lifecycle_repo'  // 设置仓库地址
        }
        google()
        mavenCentral()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.4.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.0"
        classpath 'com.zhou:asm_lifecycle:1.0.0' // 加载插件
    }
}
....

在 app模块的 build.gradle中,使用插件:

java 复制代码
apply plugin: 'com.android.application'
apply plugin: 'com.zhou.asm_lifecycle' // 使用插件
apply plugin: 'kotlin-android'

android {
...
}

欧了!

到这里,我们就能运行 app模块的build任务或者install任务了,它将生成一个apk,这个apk会打印出所有activity 的onCreate方法,但是我们没有在app模块中写任何打印日志,日志打印的代码全都是通过ASM。

但是注意:

注意

如果你删除了原来的插件仓库想重新生成插件,最好是先将引用插件的代码全部注释,重新发布之后再放开,否则可能报出插件找不到的问题。

如果没有删除插件,直接修改插件代码之后重新发布,则可以直接不用注释引用插件的代码。

本文的Demo在:GitHub - 18598925736/asm_demo

总结

字节码插桩,其实从原理上来讲,并不是太难。在阅读了一些他人的插桩博客之后,发现确实门派繁多,写法也有点互不相认。

本文中我多次提到AS, gradle,AGP版本兼容问题,是因为确确实实被坑到了,浪费了很多时间,希望大家认准可用的兼容版本,少走弯路。

下一步如果有时间我将深入插桩的应用场景,并且熟悉其他门派,希望有一天能开放自己的插桩插件,丰富前端开源社区!

相关推荐
GoppViper18 分钟前
uniapp中实现<text>文本内容点击可复制或拨打电话
前端·后端·前端框架·uni-app·前端开发
Sam902927 分钟前
【Webpack--007】处理其他资源--视频音频
前端·webpack·音视频
Code成立28 分钟前
HTML5精粹练习第1章博客
前端·html·博客·html5
架构师ZYL40 分钟前
node.js+Koa框架+MySQL实现注册登录
前端·javascript·数据库·mysql·node.js
一只小白菜~2 小时前
实现实时Web应用,使用AJAX轮询、WebSocket、还是SSE呢??
前端·javascript·websocket·sse·ajax轮询
晓翔仔2 小时前
CORS漏洞及其防御措施:保护Web应用免受攻击
前端·网络安全·渗透测试·cors·漏洞修复·应用安全
GISer_Jing3 小时前
【前后端】大文件切片上传
前端·spring boot
csdn_aspnet3 小时前
npm 安装 与 切换 淘宝镜像
前端·npm·node.js
GHUIJS3 小时前
【Echarts】vue3打开echarts的正确方式
前端·vue.js·echarts·数据可视化
Mr.mjw4 小时前
项目中使用简单的立体3D柱状图,不用引入外部组件纯css也能实现
前端·css·3d