字节码插桩 -- 入门篇

背景

我们先了解下什么情况下会用到字节码插桩。学技术并不是为了秀技术,而是为了解决业务问题。

我们先想象一个业务场景--- 我们需要统计耗时方法,这时,我们会怎么做?

在每个方法开头和结尾处分别记录开始时间与结束时间?在自己写的代码上用还好,但是第三方库类怎么办?

这时就可以用上字节码插桩了!因为 Java 文件编译成 class 后,这时可以获取全部的 class 文件,包含自己写的代码和其它库类的。拿到 class 文件后,就可以进行批量修改,并且对于 Java 文件是无感的,因为我们只针对 class 文件。

在使用字节码插桩之前,我们需要获取到每个 class 文件,这时,需要使用到自定义 Transform,而自定义Transform 需要在自定义 Gradle Plugin 时进行注册,所以,我们需要先学习下如何自定义一个 Gradle Plugin。

一、字节码插桩是什么

字节码插桩是一种在程序的字节码级别进行修改的技术。它通常用于在程序运行过程中动态地修改、分析或监控代码的行为,而无需修改源代码。

1.1 字节码插桩发生的时机

apk 的打包流程如下:

字节码插桩就发生在 .class 文件变成 .dex 文件之前。正是在这样的一个时机,字节码插桩才拥有修改全局 .class 文件的能力。

1.2 字节码插桩的应用场景

通过字节码插桩,我们可以全局替换目标方法的实现、增加目标方法的逻辑,这种处理方式更加通用彻底且具有兼容性,基于这样的能力,字节码插桩具备很大的想象空间:

二、自定义 Gradle 插件流程

2.1 创建插件 Module

Android Studio --> File --> New --> New Module --> Java or Kotlin Library --> plugin(名字自取)

2.2 配置插件 build.gradle

arduino 复制代码
plugins {
    id 'java-library'
    alias(libs.plugins.jetbrainsKotlinJvm)
}

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

dependencies {
    // gradle
    implementation gradleApi()
    // asm
    implementation libs.asm
    implementation libs.asm.commons
    implementation libs.asm.analysis
    implementation libs.asm.util
    implementation libs.asm.tree
}

libs.version.toml配置如下

ini 复制代码
[versions]
agp = "7.4.0"
kotlin = "1.9.0"
asm = "9.7"
...

[libraries]
...
# asm相关依赖
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
asm-analysis = { module = "org.ow2.asm:asm-analysis", version.ref = "asm" }
asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" }
asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "asm" }
asm-util = { module = "org.ow2.asm:asm-util", version.ref = "asm" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }

2.3 编写插件代码

kotlin 复制代码
package com.lx.plugin

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

class AsmPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        println("asm plugin apply")
    }
}

2.4 配置插件

lx-plugin.properties 文件名称可以自取,后面会用到

2.5 发布到 maven 仓库

2.5.1 发布本地 maven 仓库

  1. 在 plugin 的 build.gradle 中添加本地 maven 仓库配置
  1. 双击 publish 将插件发布到本地 maven 仓库

可以看到在 plugin 目录下有以下文件生成:

2.5.2 发布到远程 maven 仓库

我们将插件发布到远程 maven 仓库后,就可以提供所有人使用了。

  1. Nexus 搭建远程 maven 仓库

为了演示效果,本文通过在本机搭建远程 maven 仓库。

参考链接:blog.csdn.net/qq_22904065...

  1. 配置远程 maven 仓库地址

修改 plugin 的 build.gradle 中 maven 仓库配置

  1. 双击 publish 将插件发布到本地 maven 仓库

在 Sonatype Nexus Repository 中可以看到我们发布的插件了。

2.6 使用插件

  1. 在 project 的 build.gradle 添加插件依赖
  1. 在 app 的 build.gradle 中引入插件
  1. 验证,直接编译该工程

在 Build Output 中可以看到正常的输出语句

三、自定义Gradle 插件实现方法耗时统计

3.1 自定义 MethodTimeAdviceAdapter

kotlin 复制代码
package com.lx.plugin

import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Type
import org.objectweb.asm.commons.AdviceAdapter

/**
 * Created by lixiong on 2024/4/29.
 */
class MethodTimeAdviceAdapter(
    api: Int,
    methodVisitor: MethodVisitor,
    access: Int,
    name: String?,
    descriptor: String?,
    private val className: String?
) : AdviceAdapter(api, methodVisitor, access, name, descriptor) {
    private val slotIndex = newLocal(Type.LONG_TYPE)

    /**
     * 方法开始执行
     */
    override fun onMethodEnter() {
        super.onMethodEnter()
        mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
        mv.visitVarInsn(LSTORE, slotIndex)
    }

    /**
     * 方法执行结束
     */
    override fun onMethodExit(opcode: Int) {
        mv.visitLdcInsn("MethodTime")
        mv.visitTypeInsn(NEW, "java/lang/StringBuilder")
        mv.visitInsn(DUP)
        mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false)
        mv.visitLdcInsn("${className}.${name} time cost:")
        mv.visitMethodInsn(
            INVOKEVIRTUAL,
            "java/lang/StringBuilder",
            "append",
            "(Ljava/lang/String;)Ljava/lang/StringBuilder;",
            false
        )
        mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
        mv.visitVarInsn(LLOAD, slotIndex)
        mv.visitInsn(LSUB)
        mv.visitMethodInsn(
            INVOKEVIRTUAL,
            "java/lang/StringBuilder",
            "append",
            "(J)Ljava/lang/StringBuilder;",
            false
        )
        mv.visitMethodInsn(
            INVOKEVIRTUAL,
            "java/lang/StringBuilder",
            "toString",
            "()Ljava/lang/String;",
            false
        )
        mv.visitMethodInsn(
            INVOKESTATIC,
            "android/util/Log",
            "d",
            "(Ljava/lang/String;Ljava/lang/String;)I",
            false
        )
        mv.visitInsn(POP)
        super.onMethodExit(opcode)
    }
}

除了字节码部分其他的代码没什么好说的,都好理解,这部分代码也不需要自己写,可以在 Android Studio 中搜索 ASM bytecode viewer 插件。

3.1.1 使用ASM bytecode Viewer 生成相应的字节码

新建一个Demo.java 文件,编译后,在 Demo.class 右键代码区,点击 ASM Bytecode Viewer

然后选择 ASMified

记录下这里的代码,后面要用。

然后在 Demo.java 的 test 方法中编写想插入的代码,然后在通过 ASM Bytecode Viewer 查看 ASMified 代码

对比插入代码前后的 ASMified 代码的差异,就可以知道如何通过 MethodVisitor 插入字节码了。

3.2 自定义 MethodTimeClassVisitor

kotlin 复制代码
package com.lx.plugin

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

/**
 * Created by lixiong on 2024/4/29.
 */
class MethodTimeClassVisitor(classVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM7, classVisitor) {
    private var className: String? = null

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

    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
        return MethodTimeAdviceAdapter(api, methodVisitor, access, name, descriptor, className)
    }
}

3.3 自定义 MethodTimePlugin

kotlin 复制代码
package com.lx.plugin

import com.android.build.api.transform.Format
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import java.io.FileOutputStream


/**
 * Created by lixiong on 2024/4/28.
 */
class MethodTimePlugin : Transform() {
    override fun getName(): String {
        return "MethodTimePlugin"
    }

    /**
     * 用于指明Transform的输入类型
     */
    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 用于指明Transform的作用域
     */
    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 指明该Transform是否支持增量编译
     */
    override fun isIncremental(): Boolean {
        return true
    }

    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)
        val inputs = transformInvocation?.inputs
        val outputProvider = transformInvocation?.outputProvider
        inputs?.forEach { transformInput ->
            // 遍历项目目录
            transformInput.directoryInputs.forEach { directoryInput ->
                if (directoryInput.file.isDirectory) {
                    FileUtils.getAllFiles(directoryInput.file).forEach { file ->
                        val name = file.name
                        // 过滤class文件, 排除R.class, BuildConfig.class
                        if (name.endsWith(".class") && !name.startsWith("R$") &&
                            name != "R.class" && name != "BuildConfig.class"
                        ) {
                            // 找到需要的class文件,进行插桩
                            val path = file.absolutePath
                            val cr = ClassReader(file.readBytes())
                            val cw = ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
                            val visitor = MethodTimeClassVisitor(cw)
                            cr.accept(visitor, ClassReader.EXPAND_FRAMES)

                            val bytes = cw.toByteArray()
                            var fos: FileOutputStream? = null
                            try {
                                fos = FileOutputStream(path)
                                fos.write(bytes)
                            } catch (e: Exception) {
                                e.printStackTrace()
                            } finally {
                                runCatching { fos?.close() }
                            }
                        }
                    }
                }
                val dest = outputProvider?.getContentLocation(
                    directoryInput.name,
                    directoryInput.contentTypes,
                    directoryInput.scopes,
                    Format.DIRECTORY
                )
                FileUtils.copyDirectoryToDirectory(directoryInput.file, dest)
            }

            // 遍历jar包
            transformInput.jarInputs.forEach { jarInput ->
                val dest = outputProvider?.getContentLocation(
                    jarInput.name,
                    jarInput.contentTypes,
                    jarInput.scopes,
                    Format.JAR
                )
                FileUtils.copyFile(jarInput.file, dest)
            }
        }
    }
}

3.4 注册插件

3.5 验证插件

首先需要发布插件,然后依赖插件,这一步可以看上一章的内容。

运行之后,查看 logcat 打印

完美,通过 jadx 工具查看下生成的 .class 文件是否插入成功

Demo.class

MainActivity.class

编译生成的 ActivityMainBinding.class

插入成功,至此简单的Asm字节码插桩就完成了。

3.6 对 jar 包进行插桩

  1. 在 app module 的libs 中加入一个 test.jar 文件
  1. 修改自定义的 MethodTimePlugin,完整代码如下:
kotlin 复制代码
package com.lx.plugin

import com.android.build.api.transform.Format
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import org.apache.commons.compress.utils.IOUtils
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import java.io.FileOutputStream
import java.nio.file.attribute.FileTime
import java.util.Enumeration
import java.util.jar.JarEntry
import java.util.jar.JarFile
import java.util.jar.JarOutputStream
import java.util.zip.CRC32
import java.util.zip.ZipEntry


/**
 * Created by lixiong on 2024/4/28.
 */
class MethodTimePlugin : Transform() {

    private val fileTime = FileTime.fromMillis(0)

    override fun getName(): String {
        return "MethodTimePlugin"
    }

    /**
     * 用于指明Transform的输入类型
     */
    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 用于指明Transform的作用域
     */
    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 指明该Transform是否支持增量编译
     */
    override fun isIncremental(): Boolean {
        return true
    }

    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)
        val inputs = transformInvocation?.inputs
        val outputProvider = transformInvocation?.outputProvider
        inputs?.forEach { transformInput ->
            // 遍历项目目录
            transformInput.directoryInputs.forEach { directoryInput ->
                if (directoryInput.file.isDirectory) {
                    FileUtils.getAllFiles(directoryInput.file).forEach { file ->
                        val name = file.name
                        // 过滤class文件, 排除R.class, BuildConfig.class
                        if (name.endsWith(".class") && !name.startsWith("R$") &&
                            name != "R.class" && name != "BuildConfig.class"
                        ) {
                            // 找到需要的class文件,进行插桩
                            val path = file.absolutePath
                            val cr = ClassReader(file.readBytes())
                            val cw = ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
                            val visitor = MethodTimeClassVisitor(cw)
                            cr.accept(visitor, ClassReader.EXPAND_FRAMES)

                            val bytes = cw.toByteArray()
                            var fos: FileOutputStream? = null
                            try {
                                fos = FileOutputStream(path)
                                fos.write(bytes)
                            } catch (e: Exception) {
                                e.printStackTrace()
                            } finally {
                                runCatching { fos?.close() }
                            }
                        }
                    }
                }
                val dest = outputProvider?.getContentLocation(
                    directoryInput.name,
                    directoryInput.contentTypes,
                    directoryInput.scopes,
                    Format.DIRECTORY
                )
                FileUtils.copyDirectoryToDirectory(directoryInput.file, dest)
            }

            // 遍历jar包
            transformInput.jarInputs.forEach { jarInput ->
                val dest = outputProvider?.getContentLocation(
                    jarInput.name,
                    jarInput.contentTypes,
                    jarInput.scopes,
                    Format.JAR
                )
                if (dest != null) {
                    FileUtils.mkdirs(dest.parentFile)
                    // 只对 test.jar 进行插桩
                    if (jarInput.file.name.endsWith("test.jar")) {
                        var jos: JarOutputStream? = null
                        try {
                            val jarFile = JarFile(jarInput.file)
                            jos = JarOutputStream(FileOutputStream(dest))
                            val entries: Enumeration<JarEntry> = jarFile.entries()
                            while (entries.hasMoreElements()) {
                                val entry: JarEntry = entries.nextElement()
                                val name: String = entry.name
                                val outEntry = JarEntry(name)
                                val inputStream = jarFile.getInputStream(entry)
                                // 过滤class文件, 排除R.class, BuildConfig.class
                                val newEntryContent = if (name.endsWith(".class") && !name.startsWith("R$") &&
                                    name != "R.class" && name != "BuildConfig.class"
                                ) {
                                    // 找到需要的class文件,进行插桩
                                    val cr = ClassReader(inputStream)
                                    val cw = ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
                                    val visitor = MethodTimeClassVisitor(cw)
                                    cr.accept(visitor, ClassReader.EXPAND_FRAMES)
                                    cw.toByteArray()
                                } else {
                                    IOUtils.toByteArray(inputStream)
                                }
                                // 将处理后的类文件写入 JAR 包
                                val crc32 = CRC32()
                                crc32.update(newEntryContent)
                                outEntry.crc = crc32.value
                                outEntry.method = ZipEntry.STORED
                                outEntry.size = newEntryContent.size.toLong()
                                outEntry.compressedSize = newEntryContent.size.toLong()
                                outEntry.setLastAccessTime(fileTime)
                                outEntry.setLastModifiedTime(fileTime)
                                outEntry.setCreationTime(fileTime)
                                jos.putNextEntry(outEntry)
                                jos.write(newEntryContent)
                                jos.closeEntry()
                            }
                        } catch (e: Exception) {
                            e.printStackTrace()
                        } finally {
                            runCatching {
                                jos?.flush()
                                jos?.close()
                            }
                        }
                    } else {
                        FileUtils.copyFile(jarInput.file, dest)
                    }
                }
            }
        }
    }
}
  1. 通过 jadx 查看 apk 中 Test.class 文件

到此,jar 包中的方法也插桩成功。

代码地址

相关推荐
踏雪羽翼6 小时前
android TextView实现文字字符不同方向显示
android·自定义view·textview方向·文字方向·textview文字显示方向·文字旋转·textview文字旋转
lxysbly6 小时前
安卓玩MRP冒泡游戏:模拟器下载与使用方法
android·游戏
夏沫琅琊8 小时前
Android 各类日志全面解析(含特点、分析方法、实战案例)
android
程序员JerrySUN9 小时前
OP-TEE + YOLOv8:从“加密权重”到“内存中解密并推理”的完整实战记录
android·java·开发语言·redis·yolo·架构
TeleostNaCl10 小时前
Android | 启用 TextView 跑马灯效果的方法
android·经验分享·android runtime
TheNextByte111 小时前
Android USB文件传输无法使用?5种解决方法
android
quanyechacsdn12 小时前
Android Studio创建库文件用jitpack构建后使用implementation方式引用
android·ide·kotlin·android studio·implementation·android 库文件·使用jitpack
程序员陆业聪13 小时前
聊聊2026年Android开发会是什么样
android
编程大师哥13 小时前
Android分层
android
极客小云14 小时前
【深入理解 Android 中的 build.gradle 文件】
android·安卓·安全架构·安全性测试