Android动态代码注入之ASM方案

0 阅读7分钟

ASM(全称Abstract Student Method)作为一种高性能的Java字节码操作与分析框架,它允许开发者在编译阶段直接修改class文件,实现动态增强既有功能、代码无侵入,被广泛应用于全埋点统计、性能监测、API拦截等面向切面编程开发场景,是Gradle Transform、AspectJ、ByteX等插桩方案的底层依赖。

ASM字节码插桩作为Android开发中最硬核、也最强大的黑科技之一,它本质上是一个字节码操作工具库,不依赖任何编译工具,在Android开发中通常需要与Gradle Transform API配合结合。ASM框架本身的设计非常精妙,基于访问者模式实现,其工作流程涉及四个核心组件,说明如下。

  • ClassReader:字节码解析器,负责读取解析原始的字节码文件,并将其解析成ASM能够识别的结构。

  • ClassVisitor:字节码访问器,字节码插桩逻辑的调度中心,通过重写其方法来拦截并修改字节码指令,也是插桩的核心入口。

  • MethodVisitor:方法访问器,专门负责处理单个方法的字节码指令,是实现方法插桩的关键组件。

  • ClassWriter:字节码生成器,负责将修改后的字节码事件重新组装成新的字节数组,最终写入文件系统。

通过ASM字节码插桩技术,开发者可以在编译期动态修改类文件,实现对点击事件的无侵入拦截,下面以全埋点拦截点击事件示例。在Android开发中使用ASM实现全埋点事件拦截的核心思路是在编译期自动自动拦截onClick方法,无需手动添加埋点代码即可全局监控用户交互,业务代码全程无感知,是构建高效率、低耦合的Android全埋点架构的核心利器。

ASM字节码插桩工作流程大体分为解析、修改和生成三步。首先是通过字节码解析器读取现有类的字节码,然后通过 字节码访问器拦截类的组成部分,在方法体中插入自定义字节码指令,最后再通过字节码生成器重新构建修改后的字节码。

在Android开发中,集成ASM字节码插桩需要依赖AGP API和ASM等插件。新建一个buildSrc模块,并在此模块中引入AGP API和ASM所需的依赖,并注册插件ID,如下所示。

dependencies {
    implementation("com.android.tools.build:gradle-api:9.1.0")
    implementation("org.ow2.asm:asm: 9.9.1")
    implementation("org.ow2.asm:asm-commons: 9.9.1")
}

gradlePlugin {
    plugins {
        create("autoTrackPlugin") {
            id = "com.example.autotrack"
            implementationClass = "com.example.autotrack.AutoTrackPlugin"
        }
    }
}

其中,gradle-api提供与Gradle构建生命周期相关的接口;asm则是一个高性能的Java字节码操控框架,提供读取、修改和写入编译后字节码文件的能力;asm-commons是asm的扩展,提供更高级的抽象,如InstructionAdapter和GeneratorAdapter,让开发者能更快捷地插入方法调用、修改变量。它们之间相互配合,gradle-api负责将代码挂载到编译生命周期中,asm则负责扫描每一个被编译的字节码文件,asm-commons则负责在方法开头或结尾插入代码,从而实现字节码插桩。 按照ASM字节码插桩的工作流程,首先需要创建Gradle插件骨架,编写插件入口AutoTrackPlugin,代码如下。

class AutoTrackPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val androidComponents = project.extensions
            .getByType(AndroidComponentsExtension::class.java)

        androidComponents.onVariants { variant ->
            variant.instrumentation.transformClassesWith(
                AutoTrackClassVisitorFactory::class.java,
                InstrumentationScope.ALL
            ) {}
            variant.instrumentation.setAsmFramesComputationMode(
                FramesComputationMode.COPY_FRAMES
            )
        }
    }
}

上面代码的核心作用是,通过AGP提供的Instrumentation接口,在编译期将所有字节码文件交给自定义的AutoTrackClassVisitorFactory工厂类批量扫描和修改字节码。

然后就是编写ASM访问器,并编写核心插桩逻辑。在执行字节码注入之前,还需要对扫描后的字节码进行过滤,在本例中过滤器的作用是过滤只实现了OnClickListener事件的类。

abstract class AutoTrackClassVisitorFactory :
    AsmClassVisitorFactory<InstrumentationParameters.None> {

    override fun createClassVisitor(
        classContext: ClassContext,
        nextClassVisitor: ClassVisitor
    ): ClassVisitor {
        return AutoTrackClassVisitor(Opcodes.ASM9, nextClassVisitor)
    }

    override fun isInstrumentable(classData: ClassData): Boolean {
        return classData.interfaces.contains("android.view.View\$OnClickListener")
    }
}

在对OnClickListener事件进行过滤后,还需要编写类访问器,用于遍历类的所有方法并定位目标方法onClick()。

class AutoTrackClassVisitor(api: Int, cVisitor: ClassVisitor) : ClassVisitor(api, cVisitor) {

    private var className: String = ""

    override fun visit(
        version: Int, access: Int, name: String,
        signature: String?, superName: String?, interfaces: Array<out String>?
    ) {
        className = name
        super.visit(version, access, name, signature, superName, interfaces)
    }

    override fun visitMethod(
        access: Int, name: String, descriptor: String,
        signature: String?, exceptions: Array<out String>?
    ): MethodVisitor? {
        val mv = super.visitMethod(access, name, descriptor, signature, exceptions)
            ?: return null
        if (name == "onClick" && descriptor == "(Landroid/view/View;)V") {
            return AutoTrackMethodVisitor(api, mv, className)
        }
        return mv
    }
}

上面的代码实现了一个ASM类访问器,其主要作用是通过精准识别类名并拦截所有名为onClick()点击事件的方法,然后将其交给AutoTrackMethodVisitor进行代码注入,代码如下。

class AutoTrackMethodVisitor(api: Int, mVisitor: MethodVisitor, val className: String) : MethodVisitor(api, methodVisitor) {

    override fun visitCode() {
        super.visitCode()
        mv.visitVarInsn(Opcodes.ALOAD, 1)
        mv.visitLdcInsn(className.replace("/", "."))

        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            "com/example/autotrack/runtime/AutoTrackHelper",
            "trackClick",
            "(Landroid/view/View;Ljava/lang/String;)V",
            false
        )
    }
}

上面的代码实现了一个ASM方法访问器,它通过在onClick()方法的最开头插入几条JVM指令,自动调用AutoTrackHelper的trackClick()方法实现无侵入式的全量点击埋点注入。visitCode()方法是ASM方法访问器的一个生命周期标记方法,用于表示方法体可以开始被访问或写入,如果想在某个方法的最开头插入代码,visitCode()方法就是最佳的切入口。

在整个ASM全量埋点架构中,ASM插件只负责在编译期插入代码,而真正的业务逻辑(如获取View的ID、去重、异步上报)则需要运行在手机端的 Runtime SDK中。对此,需要新建一个autotrack-runtime模块,该模块的核心作用是在运行时接收ASM采集的数据,并对采集到的数据执行去重、补全和安全隔离等处理,如下所示。

public final class AutoTrackHelper {

    private static final String TAG = "AutoTrack";
    
    public static void trackClick(View view, String className) {
        String viewId = getViewId(view);
        String activityName = getActivityName(view);

        Log.i(TAG, "━━━━━━━━ Click Event ━━━━━━━━");
        Log.i(TAG, "  Activity : " + activityName);
        Log.i(TAG, "  Class    : " + className);
        Log.i(TAG, "  ViewId   : " + viewId);
        Log.i(TAG, "  ViewType : " + view.getClass().getSimpleName());
        Log.i(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");

        // TODO: 替换为实际的数据上报逻辑
        // EventReporter.report(new ClickEvent(activityName, className, viewId));
    }

    private static String getViewId(View view) {
        try {
            if (view.getId() != View.NO_ID) {
                return view.getResources().getResourceEntryName(view.getId());
            }
        } catch (Exception ignored) {}
        return "no_id";
    }

    private static String getActivityName(View view) {
        Context context = view.getContext();
        return context != null ? context.getClass().getSimpleName() : "Unknown";
    }
}

通过上面的AutoTrackHelper静态工具类,ASM能够在编译时将trackClick()方法自动注入到所有的onClick回调中,从而实现无侵入式的Android全埋点。其核心思路是,AutoTrackHelper静态工具类通过View对象反向解析出其所在的Activity名称、资源ID以及控件类型,再将收集到的数据进行二次处理,如执行存储或上报服务器。

完成上述工作后,就可以在Android应用项目中集成autotrack-runtime模块并验证ASM的全埋点拦截点击事件功能,对应的项目依赖如下。

plugins {
    id("com.android.application")
    id("com.example.autotrack")   // 应用全埋点插件
}

dependencies {
    implementation(project(":autotrack-runtime"))
}

然后打开Android的Activity页面,并在其中正常编写业务代码并执行点击操作即可,如下所示。

// 方式一:Activity 实现接口
class MainActivity : AppCompatActivity(), View.OnClickListener {
    override fun onClick(v: View) {
       … //只写业务逻辑,埋点由ASM自动注入
    }
}

// 方式二:匿名内部类,同样会被插桩
btn.setOnClickListener(object : View.OnClickListener {
    override fun onClick(v: View) { ... }
})

// 方式三:独立 Listener 类,同样会被插桩
class MyListener : View.OnClickListener {
    override fun onClick(v: View) { ... }
}

运行Android应用,当点击某个按钮执行onClick事件时,ASM自动化埋点插件就会在运行时自动注入预留的插桩信息。等待项目编译成功之后,打开插桩后的字节码文件路径查看被修改的字节码文件,对应的路径如下所示。

// 插桩后的 class 文件路径
app/build/intermediates/asm_instrumented_project_classes/debug/

// 用javap反编译验证
javap -c com/example/autotrackdemo/MainActivity.class

当然,也可以在项目运行时使用“adb logcat -s AutoTrack”命令来过滤 Logcat日志,并可以看到如下图所示的全埋点日志输出。   image.png

总的来说,使用ASM字节码插桩技术实现全埋点的核心思路是,在编译期通过字节码插桩把埋点代码悄悄塞进visitCode()方法的第一行,然后在运行时对采集的数据进行整理并上报,整个过程业务代码完全无感知。

需要注意的是,ASM + AGP作为目前Android全埋点方案里工程成本最低、运行时开销最小的选择,适合以View.OnClickListener为主的传统View体系项目。如果是Compose项目,则需要通过拦截Modifier.clickable进行实现。