AOP 面向切面编程, 字节码插桩, 自定义 Gradle Plugin + Transform + ASM「小白操作版」, 基于 AGP 7.2.0,

3,286 阅读8分钟

前文

没基础不要紧, 我基本也是从 0 开始, 慢慢来.
提一句, 刚刚开始学习 ASM 的皮毛, 不要被字节码吓到, 本文只有最后一个小节才有字节码知识点, 先简单入门吧.

大方向:
AOP:「Aspect Oriented Program」, 面向切面编程, 实现你想做的.
OOP:「Object Oriented Program」, 面向对象编程, 继承、封装、多态,职责分配,便于类的重用等.

思想解读:
自己搜索 OOP vs AOP,会发现原来 AOP 就是为了实现这种功能诞生的. 理解之后,会豁然开朗. 其实编译期插桩有好几种方式, 这里只介绍 ASM 的方式, 看下面的图「图是抄的」:

image.png

AOP 应用场景:
全自动埋点「无痕埋点」;
方法耗时监控;
方法替换...

知识点:
自定义 Gradle 插件;
Transform;
ASM;
Android 打包流程「了解下 AOP 是在哪个流程中发挥作用的,也可以读完本文再去看」;
字节码「只有最后一个小节有一些, 这个完全可以结合要实现的功能去学, 先看下去就是了」;

开发环境:
Studio 版本 2022.1.1 Canary 1;
Kotlin 版本 1.6.10;
gradle 版本 7.3;
Android gradle plugin「AGP」 版本 7.2.0;

注意开发环境, gradle 不同版本间差异很大, Studio 不同版本与 Gradle 也有各种兼容问题, 不然我也踩不了那么多坑,下面就骂骂咧咧的开始吧「来 跟我念 涨薪,涨薪,涨薪,cao 卷死了」...

开始自定义 Gradle

自定义 Gradle 插件, 有三种方式, 这里只说用起来最灵活的.
注意的点:
如果plugin 编译成功, 给其他 module 使用后, 再更改Plugin 文件 或者其他相关的 ASM 代码, 直接 publish 会不生效. 需要删除本地 maven 仓库的数据, 再执行 publish.

  1. 新建 Project, 新建 Moudle「aab」, 选择 Library, 选择如下选项:

image.png

  1. 删除多余文件夹, 只留如下文件夹内容:

image.png

  1. 修改 aab 的 build.gradle 文件, 如下:
plugins {
    id 'groovy'
    id 'maven-publish'
}

dependencies {
    implementation gradleApi()
    implementation localGroovy()
 
}

def group = 'com.zly.aab' //组
def versionA = '1.0.0' //版本
def artifactIdA = 'myGradlePlugin' //唯一标示

task sourceJar(type: Jar) {
    archiveClassifier.set('sources')
    from sourceSets.main.java.srcDirs
}

publishing {
    publications {
        debug(MavenPublication) {
            groupId = group
            artifactId = artifactIdA
            version = versionA
            from components.java
            artifact sourceJar
        }
    }
    // 添加仓库地址
    repositories {
        // 本地仓库, 会创建 repos 文件, 位于项目根目录下
        maven { url uri('../repos') }
    }
}
  1. 配置 project 的 build.gradle, 如下:
plugins {
    id 'com.android.application' version '7.2.0-alpha07' apply false
    id 'com.android.library' version '7.2.0-alpha07' apply false
    id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
    id 'org.jetbrains.kotlin.jvm' version '1.6.10' apply false
}

task clean(type: Delete) {
    delete rootProject.buildDir
}
  1. src/main 下新建 groovy 目录, 在 groovy 下创建目录A「eg:com/zly/aab」,创建名为 Bplugin「随便起」 的 groovy 文件, 完成后如图所示:

image.png

//这是 groovy 文件内容
package com.zly.aab

import org.gradle.api.Plugin
import org.gradle.api.Project

class BPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        println("======================================success============================> BPlugin")
    }
}
  1. src/main 下新建 resources 目录, 继续创建 META-INF/gradle-plugins 目录, 然后创建 B.properties 文件, 文件内容如下:
//作用: 申明Gradle插件的具体实现类
implementation-class=com.zly.aab.BPlugin
//com.zly.aab.BPlugin 就是 *.groovy 文件

此时, 目录应如下图:

image.png

  1. 将插件上传到本地仓库 repos 目录中, 步骤如下:

找到 Android Studio 的 Preferences... 选项, 改成下图配置:

image.png

执行 publish 命令, 可借助于刚刚的设置, 打开 Gradle 面板, 如下:

image.png

此时 repos 目录如下, 代表 ok 了:

image.png

  1. 应用插件:

配置 project 的 build.gradle, 如下:

buildscript {
    repositories {
        maven {
            url uri('./repos') //指定本地maven的路径,在项目根目录下
        }
    }
    dependencies {
        classpath 'com.zly.aab:myGradlePlugin:1.0.0'
//classpath指定的路径格式如下:
//classpath '[groupId]:[artifactId]:[version]'
//不清楚可以看 aab 的 build.gradle
    }
}

plugins {
    id 'com.android.application' version '7.2.0-alpha07' apply false
    id 'com.android.library' version '7.2.0-alpha07' apply false
    id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
    id 'org.jetbrains.kotlin.jvm' version '1.6.10' apply false
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

新增配置 app 的 build.gradle 文件如下:

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
//引用完全自定义插件,properties文件名称的方式
    id 'com.zly.aab'
}

执行 build 任务, 可以看到 Build 输出, 细节如图:

image.png

至此,你的自定义 Plugin 肯定是可以用了.

先学一点 ASM, 把 Plugin 配置好

先学一点 ASM, 不然 Transform 也继续不下去... 基于 AGP 7.2.0 版本中, Transform 已被标记为 @Deprecated, 并将在 AGP 8.0 版本中被移除, 所以这里使用 AsmClassVisitorFactory.
另外再提一点, 在这个小节结束之前不会涉及到字节码知识, 只是为了配置好 Plugin.

//自己看 AsmClassVisitorFactory 源码吧, 这里把备注都省略了
interface AsmClassVisitorFactory<ParametersT : InstrumentationParameters> : Serializable {

    @get:Nested
    val parameters: Property<ParametersT>

    @get:Nested
    val instrumentationContext: InstrumentationContext

    //必须实现 createClassVisitor 所以我们先创建 ClassVisitor
    fun createClassVisitor(
        classContext: ClassContext,
        nextClassVisitor: ClassVisitor
    ): ClassVisitor

    //这个方法是判断当前类是否要进行扫描,因为如果所有类都要通过ClassVisitor进行扫描还是太耗时了.
    //我们可以通过这个方法过滤掉很多我们不需要扫描的类
    fun isInstrumentable(classData: ClassData): Boolean
}
  1. 创建 groovy 同级 java 目录:

image.png

  1. 配置 aab 的 build.gradle 如下:
implementation 'org.ow2.asm:asm-commons:9.1'
implementation "com.android.tools.build:gradle-api:7.2"
implementation "com.android.tools.build:gradle:7.2.0"//cao 这是个坑,别用「$agpVersion」,老老实实写版本号,不知道为什么
implementation 'org.jetbrains:annotations:16.0.1'
  1. 在 Java 目录中, 定义 ClassVisitor.kt, 代码:
package com.zly.aabb;

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

class BClassVisitor extends ClassVisitor {

    BClassVisitor(ClassVisitor nextVisitor) {
        super(Opcodes.ASM5, nextVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
        // AdviceAdapter 是 MethodVisitor 的子类,使用 AdviceAdapter 可以更方便的修改方法的字节码。
        // AdviceAdapter其中几个重要方法如下:
        // void visitCode():表示 ASM 开始扫描这个方法
        // void onMethodEnter():进入这个方法
        // void onMethodExit():即将从这个方法出去
        // void onVisitEnd():表示方法扫描完毕
        MethodVisitor newVisitor = new AdviceAdapter(Opcodes.ASM5, visitor, access, name, descriptor) {
            @Override
            protected void onMethodEnter() {
            //这里未做实际修改, 打个log, 在Build 的时候可以输出
                System.out.println("access:" + access + ", name:" + name + " , descriptor:" + descriptor + " ===> onMethodEnter");
                super.onMethodEnter();
            }

            @Override
            protected void onMethodExit(int opcode) {
                System.out.println("access:" + access + ", name:" + name + " , descriptor:" + descriptor + " ===> onMethodExit opcode:$opcode   <===");
                super.onMethodExit(opcode);
            }
        };
        return newVisitor;
    }
}
  1. 在 Java 目录中, 自定义 AsmClassVisitorFactory, 代码如下:
package com.zly.aabb;

import com.android.build.api.instrumentation.AsmClassVisitorFactory;
import com.android.build.api.instrumentation.ClassContext;
import com.android.build.api.instrumentation.ClassData;
import com.android.build.api.instrumentation.InstrumentationParameters;

import org.jetbrains.annotations.NotNull;
import org.objectweb.asm.ClassVisitor;

public abstract class BBFactory implements AsmClassVisitorFactory<InstrumentationParameters.None> {
    @NotNull
    @Override
    public ClassVisitor createClassVisitor(@NotNull ClassContext classContext, @NotNull ClassVisitor classVisitor) {
        return new BClassVisitor(classVisitor);
    }

    @Override
    public boolean isInstrumentable(@NotNull ClassData classData) {
        //先简单实现, 直接就先不过滤了.
        return true;
    }
}
  1. 修改之前定义的 BPlugin.groovy 文件:
package com.zly.aab

import com.android.build.api.instrumentation.FramesComputationMode
import com.android.build.api.instrumentation.InstrumentationScope
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.Variant
import com.zly.aabb.BBFactory
import kotlin.jvm.functions.Function1
import org.gradle.api.Plugin
import org.gradle.api.Project

class BPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        println("================success=================> BPlugin")

        //AppExtension「旧版」 VS AndroidComponentsExtension「新版」
        AndroidComponentsExtension extension = (AndroidComponentsExtension) project.getExtensions().getByType(AndroidComponentsExtension.class);
        extension.onVariants(extension.selector().all(), new Function1<Variant, Variant>() {
            @Override
            Variant invoke(Variant variant) {
                variant.getInstrumentation().transformClassesWith(BBFactory.class, InstrumentationScope.PROJECT, none -> null);
                variant.getInstrumentation().setAsmFramesComputationMode(FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS);
                return variant;
            }
        })
    }
}
  1. 执行 aab 中 publish task. 为使修改生效, 需要删除 repos 文件下的内容, 注释掉 app/Project 的 build.gradle 中 aab 插件的 classpath and plugin. 再执行 publish, 打开注释后 run app, 会看到如下日志: image.png

至此, 外部配置完毕, 可以开始关联相关业务了, 这里就简单写, 比如计算方法耗时.

开始结合业务代码, 修改具体的字节码, 先简单打印个 log

  1. 先安装一个字节码查看插件, 我用的 Kotlin, 所以安装 ASM Bytecode Viewer Support Kotlin, 可以看到编译后的字节, 以及对应的 ASM 代码. 真正写插桩代码的时候, 可以先根据 「show differences」功能查看自己所需的 ASM 代码. image.png

  2. 先输出个 log 体验下插桩成功后的愉悦心情:

//初始状态下 MainActivity 代码
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        lifecycleScope.launch(Dispatchers.IO) {
            getResult()
        }
    }
    suspend fun getResult() {
    }
}

对着 MainActivity 右键执行「ASM ByteCode Viewer」,再修改 MainActivity 的 getResult 代码如下:

suspend fun getResult() {
    Log.e("zly_1111","print a test log")
}
  1. 然后再「ASM ByteCode Viewer」, 点击「show difference」,展示如下: image.png

  2. 现在修改 BClassVisitor 的 onMethodExit 如下:

@Override
protected void onMethodExit(int opcode) {
    visitLdcInsn("zly_1111");
    visitLdcInsn( "test get result");
    visitMethodInsn(INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
    visitInsn(POP);
    super.onMethodExit(opcode);
}
  1. 重新 publish「注意先注释 project and app 中 build.gradle 中此插件相关的配置, 然后删除 repos 中文件, publish 成功后, 再打开注释, 否则修改不生效」, 然后安装 app, 此时在 logcat 中便可看到 Log.

至此,你的插桩肯定是生效了.

修改具体的字节码, 再计算一下方法耗时

  1. getResult 方法修改如下 vs getResult 为空方法:
suspend fun getResult() {
    val start = System.currentTimeMillis()
    Log.e("zly_1111","进入时间: $start")
    val end = System.currentTimeMillis()
    Log.e("zly_1111","离开时间差: ${end - start}")
}

image.png

具体内容看图上, 另外需要注意画圆圈部分, LSTORE 与 LLOAD 中的后面数字需要对应, 原因的话自己去查询吧.

  1. 具体代码实现如下:
MethodVisitor newVisitor = new AdviceAdapter(Opcodes.ASM5, visitor, access, name, descriptor) {
   int slotIndex = 0;

   @Override
   protected void onMethodEnter() {
       slotIndex = newLocal(Type.LONG_TYPE);
       visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
       visitVarInsn(LSTORE, slotIndex);
       visitLdcInsn("zly_1111");
       visitLdcInsn("\u8fdb\u5165\u65f6\u95f4: ");
       visitVarInsn(LLOAD, slotIndex);
       visitMethodInsn(INVOKESTATIC, "java/lang/Long", "valueOf", "(J)Ljava/lang/Long;", false);
       visitMethodInsn(INVOKESTATIC, "kotlin/jvm/internal/Intrinsics", "stringPlus", "(Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;", false);
       visitMethodInsn(INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
       visitInsn(POP);
       super.onMethodEnter();
   }

   @Override
   protected void onMethodExit(int opcode) {
       visitLdcInsn("zly_1111");
       visitLdcInsn( "test get result");
       visitMethodInsn(INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
       visitInsn(POP);

       int slotIndex2 = newLocal(Type.LONG_TYPE);
       visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
       visitVarInsn(LSTORE, slotIndex2);
       visitLdcInsn("zly_1111");
       visitLdcInsn(name + " ----- \u79bb\u5f00\u65f6\u95f4\u5dee: ");
       //name 即当前方法对应的name
       visitVarInsn(LLOAD, slotIndex2);
       visitVarInsn(LLOAD, slotIndex);
       visitInsn(LSUB);
       visitMethodInsn(INVOKESTATIC, "java/lang/Long", "valueOf", "(J)Ljava/lang/Long;", false);
       visitMethodInsn(INVOKESTATIC, "kotlin/jvm/internal/Intrinsics", "stringPlus", "(Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;", false);
       visitMethodInsn(INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
       visitInsn(POP);

       super.onMethodExit(opcode);
   }
};
  1. publish 后 install app, 可看到如下 log:

image.png

Demo Github : Demo Github

至此,文章主题完结.

坑记录

以下文字, 不想看可以掠过, 末尾直接点赞 end.

坑一: Stduio 和 Gradle 版本兼容问题.
本来我的 Studio 版本 2021.2.1 Canary 7, 但是在我配置完 ClassVisitor/AsmClassVisitorFactory 之后, 80% 的情况下都会报错, 导致 Build 能成功, 但是无法安装到手机, 报错如下图:

Collection has more than one element.png

当时很无语, 搜索不到, 直到我看到 Android Developers 开发平台, 如下图:

image.png

虽然写着兼容, 但是我还是打开了之前下载的另一个 Studio 版本「2022.1.1 Canary 1」, 能安装了:)

坑二: Groovy 文件中, 调用 Kotlin 泛型相关方法.

虽说 Groovy 和 Java 完全兼容, Java 和 Kotlin 完全兼容, but 我遇到 Groovy 调用 Kotlin 带有泛型的类, 真真搞破了脑袋也没有把 AsmClassVisitorFactory.kt 的继承类写入 Groovy 文件中, 最后还是新建了 Groovy 文件夹同级别的 Java 文件夹, 用 Kotlin 的写法完成的.
但是这里有个问题就是 Java 文件夹与 Groovy 文件夹路径不能相同, 否则 Groovy 调用 Java 目录下文件时, 会报找不到 class 的bug.

image.png

End :
真的从 0 开始了, 一步一步写的, 真的只适合小白.
里面很多没细讲, 只是搭了一个架子, 消化完之后可看 详细讲解.

参考链接

本文后续
自定义 Gradle 插件的 3 种方式
Android Gradle 插件 API 更新,建议多看看
现在准备好告别Transform了吗? | 拥抱AGP7.0
最通俗易懂的字节码插桩实战
AOP 利器 ASM 基础入门