字节码插桩 -- 入门篇

背景

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

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

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

这时就可以用上字节码插桩了!因为 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 包中的方法也插桩成功。

代码地址

相关推荐
枯骨成佛1 小时前
Android中Crash Debug技巧
android
kim56596 小时前
android studio 更改gradle版本方法(备忘)
android·ide·gradle·android studio
咸芝麻鱼6 小时前
Android Studio | 最新版本配置要求高,JDK运行环境不适配,导致无法启动App
android·ide·android studio
无所谓จุ๊บ6 小时前
Android Studio使用c++编写
android·c++
csucoderlee7 小时前
Android Studio的新界面New UI,怎么切换回老界面
android·ui·android studio
kim56597 小时前
各版本android studio下载地址
android·ide·android studio
饮啦冰美式7 小时前
Android Studio 将项目打包成apk文件
android·ide·android studio
夜色。7 小时前
Unity6 + Android Studio 开发环境搭建【备忘】
android·unity·android studio
ROCKY_8178 小时前
AndroidStudio-滚动视图ScrollView
android
趴菜小玩家9 小时前
使用 Gradle 插件优化 Flutter Android 插件开发中的 Flutter 依赖缺失问题
android·flutter·gradle