字节码插桩 -- 入门篇

背景

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

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

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

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

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

一、字节码插桩是什么

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

1.1 字节码插桩发生的时机

apk 的打包流程如下:

apk打包流程主要有以下几个步骤:

  • 资源编译:所有的资源文件(如XML、图片、音频等)都会编译成二进制格式。通过aapt打包资源文件、生成R.java、resources.arsc和res文件(二进制非二进制文件如res/raw保持原样)
  • 处理.aidl文件, aidl工具将.aidl文件生成对应的Java接口(aidl用于进程间通信)
  • 代码编译:通过Java Compiler编译R.java、java接口文件、Java源文件,生成.class文件,通过dx/d8工具将.class文件处理生成classes.dex
  • APK打包:编译后的资源文件、Dex文件、清单文件(AndroidManifest.xml)以及其他必要的文件(如证书文件)通过apkBuidler被打包成一个未签名的APK文件。
  • APK对齐:未签名的APK文件被对齐优化(它确保所有未压缩的数据在APK文件中的偏移量都对齐到相应的字节边界),这可以使得APK文件在设备上运行时消耗更少的内容。
  • APK签名:对齐后的APK被签名。签名可以保证APK文件的完整性和来源的可信度。
  • 验证:最后,可以使用apksigner工具来验证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

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配置如下

[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 编写插件代码
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 仓库配置

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

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

2.5.2 发布到远程 maven 仓库

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

1. Nexus 搭建远程 maven 仓库

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

 参考链接:Nexus本地搭建(MacOS)_mac安装nexus-CSDN博客 ​​​​​

2. 配置远程 maven 仓库地址

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

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

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

2.6 使用插件

1. 在 project 的 build.gradle 添加插件依赖

2. 在 app 的 build.gradle 中引入插件

3. 验证,直接编译该工程

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

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

3.1 自定义 MethodTimeAdviceAdapter
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
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)
    }
}
MethodTimeAdviceAdapter.kt 
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)
    }
}
3.3 自定义 MethodTimePlugin
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 注册插件
package com.lx.plugin


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

class AsmPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        val appExtension = target.extensions.getByType(AppExtension::class.java)
        appExtension.registerTransform(MethodTimePlugin())
    }
}
3.5 验证插件

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

 build.gradle配置如下:

plugins {
    id 'java-library'
    // 发布maven仓库所需要的插件
    id 'maven-publish'
    alias(libs.plugins.jetbrainsKotlinJvm)
}

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

dependencies {
    // gradle
    implementation gradleApi()
    implementation("com.android.tools.build:gradle:${libs.versions.agp.get()}")
    implementation libs.common
    implementation libs.commons.compress
    // asm
    implementation libs.asm
    implementation libs.asm.commons
    implementation libs.asm.analysis
    implementation libs.asm.util
    implementation libs.asm.tree
}

publishing {
    publications {
        create(MavenPublication) {
            groupId = "com.lx.plugin"
            artifactId = "asm-plugin"
            version = "1.0.1"
            from components.java
        }
    }
    repositories {
        maven {
            //本地的Maven地址:当前工程下
            url = uri('./asm_plugin')
        }
    }
}

运行之后,查看 logcat 打印

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

Demo.class

MainActivity.class

编译生成的 ActivityMainBinding.class

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

3.6 对 jar 包进行插桩

1. 在 app module 的libs 中加入一个 test.jar 文件

2. 修改自定义的 MethodTimePlugin,完整代码如下:

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)
                    }
                }
            }
        }
    }
}

3. 通过 jadx 查看 apk 中 Test.class 文件

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

代码地址:asm_plugin_demo: 字节码插桩示例

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值