简介
插桩,简单来说就是在编译期间插入代码。但是编译的时候不管是kt还是java都会被编译成字节码文件.class,那么插入的代码肯定不能是原版的java或者kt代码,而应该是字节码,这篇文章就简要介绍一下插桩技术。(建议顺着APT注解开发那篇文章继续看)
插桩技术
大体流程
插桩是在编译期执行的一个流程,那这个流程大概是怎样的呢?可以看下这张图
简单来说就是在编译期不断遍历当前编译模块的编译产物,找到满足条件的类和方法,在对应的方法里插入字节码
构建方式
使用asm是以插件的方式集成,这边就从头介绍一下插件的构建与ASM的使用
先搂一眼代码结构:
插件jar包
注解开发那篇文章有提到jar和aar 的创建区别,这里不在赘述,注意创建的是jar包。
gradle
gradle的必要配置包括
- 防止插件重复
tasks.withType<Copy>() {
filesMatching("**/*.properties"){
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
}
- dependencies
dependencies{
implementation(gradleApi())
implementation("com.android.tools.build:gradle:7.2.2")
implementation("org.ow2.asm:asm:9.3")
implementation("org.ow2.asm:asm-commons:9.3")
implementation("org.ow2.asm:asm-analysis:9.3")
implementation("org.ow2.asm:asm-util:9.3")
implementation("org.ow2.asm:asm-tree:9.3")
implementation("commons-io:commons-io:2.8.0")
implementation("org.javassist:javassist:3.29.0-GA")
}
其他的就是些上传maven的脚本
Transform
插件的必要类之一,决定了编译期有哪些类需要过滤
class BridgeTransform: Transform(), Plugin<Project> {
private var properties = mutableMapOf<String, Any?>()
override fun getName(): String {
return "KotlinBridgeTransform"
}
override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
return TransformManager.CONTENT_CLASS
}
override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
return mutableSetOf(QualifiedContent.Scope.PROJECT)
}
override fun isIncremental(): Boolean {
return false
}
override fun apply(target: Project) {
println(">>>>>> 1.1.1 this is a log just from DemoTransform")
properties.putAll(target.properties)
val appExtension = target.extensions.getByType(BaseExtension::class.java)
appExtension.registerTransform(this)
}
override fun transform(transformInvocation: TransformInvocation?) {
super.transform(transformInvocation)
val inputs = transformInvocation?.inputs
val outputProvider = transformInvocation?.outputProvider
if (!isIncremental) {
outputProvider?.deleteAll()
}
inputs?.forEach { it ->
it.directoryInputs.forEach {
if (it.file.isDirectory) {
getAllFiles(it.file).forEach {
println(">>>>>> file check :${it.absolutePath}")
val file = it
val name = file.name
if (name.endsWith(".class") && name != ("R.class")
&& !name.startsWith("R\$") && name != ("BuildConfig.class")
) {
try {
val classPath = file.absolutePath
println(">>>>>> classPath :$classPath")
val cr = ClassReader(file.readBytes())
val cw = ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
val visitor = BridgeClassVisitor(cw)
cr.accept(visitor, ClassReader.EXPAND_FRAMES)
val bytes = cw.toByteArray()
val fos = FileOutputStream(classPath)
fos.write(bytes)
fos.close()
}catch (e: Throwable) {
e.printStackTrace()
}
}
}
}
try {
val dest = outputProvider?.getContentLocation(
it.name,
it.contentTypes,
it.scopes,
Format.DIRECTORY
)
println("========== file ::: ${it.file.name}, dest::: $dest ==========, isDir ::: ${it.file.isDirectory}")
FileUtils.copyDirectory(it.file, dest)
}catch (e: Throwable){
e.printStackTrace()
}
}
// !!!!!!!!!! !!!!!!!!!! !!!!!!!!!! !!!!!!!!!! !!!!!!!!!!
//使用androidx的项目一定也注意jar也需要处理,否则所有的jar都不会最终编译到apk中,千万注意
//导致出现ClassNotFoundException的崩溃信息,当然主要是因为找不到父类,因为父类AppCompatActivity在jar中
it.jarInputs.forEach {
val dest = outputProvider?.getContentLocation(
it.name,
it.contentTypes,
it.scopes,
Format.JAR
)
FileUtils.copyFile(it.file, dest)
}
}
}
private fun getAllFiles(folder: File): List<File> {
val fileList: MutableList<File> = ArrayList<File>()
val files: Array<out File>? = folder.listFiles()
if (files != null) {
println(">>>>>> files : files != null, files.size = ${files.size}")
for (file in files) {
if (file.isDirectory()) {
fileList.addAll(getAllFiles(file))
} else {
fileList.add(file)
}
}
}else {
println(">>>>>> files : files = null")
}
return fileList
}
}
大致模版就是这样,关注几个核心点点:
- name.endsWith(“.class”) && name != (“R.class”) && !name.startsWith(“R$”) && name != (“BuildConfig.class”)
需要遍历插桩的文件,以.class结尾,不是R文件,不以R$打头,不是BuildConfig
保证遍历的文件是正常要遍历的类文件 - BridgeClassVisitor这个是你的类访问器,访问方法时会用
ClassVisitor
类访问器,插件的必要类之一,决定了命中类时的操作和命中类后要过滤的方法
class MainClassVisitor (classVisitor: ClassVisitor, private val properties: Map<String, Any?>): ClassVisitor(Opcodes.ASM9, 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)
println("==== className ::: $className, name ::: $name")
if (className == properties["ENTER_CLASS_NAME"]){ // 全类名
if (name == properties["ENTER_METHOD_NAME"]){ // 方法名
println("====方法执行,${properties["ENTER_CLASS_NAME"]}.${properties["ENTER_METHOD_NAME"]}")
return MainMethodVisitor(methodVisitor)
}
}
return methodVisitor
}
}
其中,visit方法表明命中类时的动作,visitMethod里的条件语句,表明了满足类名和方法名时需要走的方法访问器。
MethodVisitor
方法访问器,决定了命中方法后,插桩的具体逻辑
class MainMethodVisitor(methodVisitor: MethodVisitor): MethodVisitor(Opcodes.ASM9, methodVisitor) {
override fun visitCode() {
super.visitCode()
}
}
这里有个关键的点,插桩的代码是字节码,常规的kt代码和java代码写这指定白扯,可是字节码要如何写呢?
AS安装ASM插件工具
到插件市场找ASM Bytecode Outline Rebooted插件,安装
java转字节码
首先,能够直接转字节码的代码只能是java,因此把你要插桩的代码写到一个java类中,然后右击这个文件,选择这个:
AS就会给出你这个java文件转成字节码后的东西。
注意:有些类由于引用关系可能会生成失败,保证要插桩的代码本身不复杂,找一些简单的java文件写一下代码进行转换。
然后把字节码粘贴到visitCode方法中,可能需要简单调整。
插件使用
这个jar包是要供外部以插件的形式使用,所以需要配置META-INF,在resources目录下创建META-INF.gradle-plugins目录,创建一个properties文件(例如hahaha.properties)
文件里写:implementation-class=transform的全类名
在打成二进制包后,使用方式如下:
- 根目录build.gradle下配置classpath指向这个jar包
- 需要插桩的module的build.gradle中引用插件,比如apply plugin:‘hahaha’
这里的hahaha就对应了properties文件
编译结果检查
上述流程可以完成插桩,那怎么检查插桩是否成功呢?这就需要看一下.class文件了。
针对java,产物位于:
针对kt,产物位于:
如果class被反编译了,就可以直接看,如果没有被反编译,那就下一个GUI反编译工具,看一下结果,检查代码是否插桩成功。
总结
ok,到这里插桩技术基本就到这里了,如果是研究过arouter或者想研究Arouter的小伙伴,看完估计也能够对Arouter的底层实现原理会有更深的了解