回顾
在上一章讲到创建完buildSrc之后,实现了项目的plugin之后,就可以在plugin注册我们的Transform。这期文章将正式进入重头戏,今天的学习内容有【认识Transform】、【认识AMS】、【插桩实战】
前言
插桩的技巧中,我们要知道
- Transform的作用:是用来替换(或转换)Class
- AMS的作用:是用来修改Class字节码
两者配合起来,利用Transform将旧的class文件取出来,再用AMS修改class的字节码,最后替换成我们新的class文件
认识Transform
android gradle插件自从1.5.0-beta1
版本开始就包含了一个Transform API,允许第三方插件在编译后的类文件转换为dex文件之前做处理操作。从这里可以知道Transform还在混淆之前,所以完全不用担心混淆的问题。在使用Transform API, 开发者完全可以不用去关注相关task的生成与执行流程,我们只聚焦在对输入的类文件进行处理,处理完后输出文件即可。
一、注册Transform
通过android.registerTransform(theTransform)
就可以进行注册。
class PhoenixPlugin : Plugin<Project> {
override fun apply(project: Project) {
val appExtension = project.extensions.getByName("android")
if (appExtension is AppExtension) {
appExtension.registerTransform(CatTransform(project))
}
}
}
二、Transform的使用
自定义的Transform
继承于com.android.build.api.transform.Transform
class CatTransform(val project: Project) : Transform() {
private var SCOPES: MutableSet<QualifiedContent.Scope> = mutableSetOf()
init {
SCOPES.add(QualifiedContent.Scope.PROJECT)
SCOPES.add(QualifiedContent.Scope.SUB_PROJECTS)
SCOPES.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
}
/**
* transform 名字
*/
override fun getName(): String {
return "cat"
}
/**
* 输入文件的类型
*/
override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
return TransformManager.CONTENT_CLASS
}
/**
* 指定作用范围
*/
override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
return SCOPES
}
/**
* 是否支持增量
*/
override fun isIncremental(): Boolean {
return false
}
/**
* transform的执行
*/
override fun transform(transformInvocation: TransformInvocation?) {
transformInvocation?.inputs?.forEach {
// 项目中编写的代码
it.directoryInputs.forEach {directoryInput->
with(directoryInput){
//字节码操作
......
}
}
// 项目中引入第三方Jar包的代码
it.jarInputs.forEach { jarInput->
with(jarInput){
//字节码操作
......
}
}
}
}
}
Transform插桩对于有代码的地方都需要扫描到,Transform分2个情况去插桩
- 项目中编写的代码:在编译过程中,开发者编写的代码编译过后的class文件,直接取出来通过AMS修改class源码,然后放回去
- 项目中引入第三方Jar包的代码:在编译过程中,将旧的jar包解压出来后,取出class文件,通过AMS修改class源码,然后放入新的jar包中,然后放回去
三、字段的解读
1. 作用域
通过Transform#getScopes
指定的作用对象
QualifiedContent.Scope | 作用域 |
---|---|
EXTERNAL_LIBRARIES | 只包含外部库 |
PROJECT | 只作用于project本身内容 |
PROVIDED_ONLY | 支持compileOnly的远程依赖 |
SUB_PROJECTS | 子模块内容 |
TESTED_CODE | 当前变体测试的代码以及包括测试的依赖项 |
2. 作用对象
通过Transform#getInputTypes
指定的作用对象
QualifiedContent.ContentType | 作用对象 |
---|---|
CLASSES | Java代码编译后的内容, 包括文件夹以及Jar包内的编译后的类文件 |
RESOURCES | 基于资源获取到的内容 |
3. TransformInvocation
我们通过实现Transform#transform
方法来处理我们的中间转换过程, 而中间相关信息都是通过TransformInvocation
对象来传递
public interface TransformInvocation {
// transform的上下文
@NonNull
Context getContext();
// 返回transform的输入源
@NonNull
Collection<TransformInput> getInputs();
// 返回引用型输入源
@NonNull Collection<TransformInput> getReferencedInputs();
//额外输入源
@NonNull Collection<SecondaryInput> getSecondaryInputs();
//输出源
@Nullable
TransformOutputProvider getOutputProvider();
//是否增量
boolean isIncremental();
}
关于输入源, 我们可以大致分为消费型和引用型和额外的输入源
- 消费型(getInputs()获取):需要transform操作的类型, 这类对象在处理后我们必须指定输出传给下一级
- 引用型(getReferencedInputs()获取):指我们不进行transform操作, 但可能存在查看时候使用, 所以这类我们也不需要输出给下一级
- 额外的输入源(通过getSecondaryInputs()获取):正常开发中我们很少用到, 不过像是ProGuardTransform中, 就会指定创建mapping.txt传给下一级,这类额外增加的文件就归属到额外输入源
- 输出源(通过getOutputProvider()获取):在消费完文件后,需要指定输出传给下一级的出口
认识AMS
ASM是一个通用的Java字节码操作和分析框架。它可以用于修改现有类或直接以二进制形式动态生成类。ASM的使用需要一定的学习成本,由于是基于汇编语言指令来改写class文件的,很多Api命名都是通过汇编指令命名,阅读起来只对汇编工程师友好。
一、Class文件操作
由于是对Class文件的操作,掌握AMS的文件操作显得尤为必要,其重要的角色为ClassReader
、ClassWriter
、ClassVisitor
- ClassReader:class的输入流,类似文件的输入流,负责《读Class》
- ClassWriter:class的输出流,类似文件的输出流,负责《写Class》
- ClassVisitor:class字节码的访问者,负责《转换Class》
1. ClassVisitor
在AMS中最为重要的角色当然是负责篡改字节码的ClassVisitor,在转换过程中ClassVisitor已经帮我封装好了接口,我们只需实现对应的方法就能访问到字节码对应的数据
- ClassVisitor.visit():访问类自身信息,可以修改当前类、父类、接口的信息
- ClassVisitor.visitField():访问字段本身,可以添加一个新的字段或者删除已有的字段
- ClassVisitor.visitMethod():访问方法,可以添加一个新的方或者删除已有的方法
class CatClassVisitor(private val project: Project, classVisitor: ClassVisitor) :
ClassVisitor(Opcodes.ASM6, classVisitor) {
/**
* 在这里访问类,进行篡改
*/
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)
}
/**
* 在这里访问方法,进行篡改
*/
override fun visitMethod(
access: Int,
name: String?,
desc: String?,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
return super.visitMethod(access, name, desc, signature, exceptions)
}
/**
* 在这里访问字段,进行篡改
*/
override fun visitField(
access: Int,
name: String?,
descriptor: String?,
signature: String?,
value: Any?
): FieldVisitor {
return super.visitField(access, name, descriptor, signature, value)
}
}
2. 方法解读
ClassVisitor.visit(int version, int access, String name, String signature, String superName, String[] interfaces)
- version: 当前Class版本的信息
- access: 当前类的访问修饰符
- name: 当前类的名字
- signature:改当前类的泛型信息
- superName: 当前的父类
- interfaces: 当前的接口信息
ClassVisitor.visitField(int access, String name, String descriptor, String signature, Object value)
- access: 当前字段的访问标识(access flag)信息
- name: 当前字段的名字
- descriptor: 当前字段的描述符
- signature: 当前字段的泛型信息
- value: 当前字段的常量值
ClassVisitor.visitMethod(int access, String name, String descriptor, String signature, String[] exceptions)
- access: 当前方法的访问修饰符
- name: 当前方法的名字
- descriptor: 当前方法的描述符,例如:()I、()V
- signature: 当前方法的泛型信息
- exceptions: 当前方法抛出的异常信息
3. class类完整的读写写法
通过ClassReader->ClassVisitor->ClassWriter(读取字节码->访问并修改字节码->输出字节码),你可以理解类似于文件流操作一样,这就是一种固定的写法
//1、通过ClassReader准备输入流
val reader = ClassReader(file.readBytes())
//2、通过ClassWriter准备输出流
val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
//3、将输出流交给ClassVisitor修改字节码
val visitor = CatClassVisitor(project, writer)
//4、将输入流的内容交给Visitor去处理
reader.accept(visitor, ClassReader.EXPAND_FRAMES)
//5、通过文件流的方式,将字节码输出,并写入原有的class文件
val code = writer.toByteArray()
val classPath = file.parentFile.absolutePath + File.separator + name
val fos = FileOutputStream(classPath)
fos.write(code)
fos.close()
插桩实战——无痕的耗时统计
我们通过一个简单的插桩无痕统计方法耗时的例子来实战一下Transform+AMS,我们先看看最终的结果把
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out: │ [类名] com/dreamer/uidemo/MainActivity
I/System.out: │ [函数] <init>
I/System.out: │ [参数] []
I/System.out: │ [返回] null
I/System.out: │ [耗时] 0.025938
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out: │ [类名] com/dreamer/uidemo/MainActivity
I/System.out: │ [函数] initList
I/System.out: │ [参数] []
I/System.out: │ [返回] null
I/System.out: │ [耗时] 0.067448
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out: │ [类名] com/dreamer/uidemo/MainActivity
I/System.out: │ [函数] initView
I/System.out: │ [参数] []
I/System.out: │ [返回] null
I/System.out: │ [耗时] 6.508906
I/System.out: └───────────────────────────────────------───────────────────────────────────------
项目结构展示
1、定义如何统计耗时的方案
先定义需要统计的耗时实体
data class MethodInfo(
var className: String = "", //类名
var methodName: String = "", //函数
var returnParam: Any? = "", //返回
var time: Float = 0f, //耗时
var params: ArrayList<Any?> = ArrayList() //参数
) {
override fun equals(other: Any?): Boolean {
val m: MethodInfo = other as MethodInfo
return m.methodName == this.methodName
}
override fun toString(): String {
return "MethodInfo(className='$className', methodName='$methodName', returnParam=$returnParam, time=$time, params=$params)"
}
}
定义耗时统计的方法,在函数执行前调用start方法,在函数结束时调用end方法
object MethodManager {
private val methodWareHouse = Vector<MethodInfo>(1024)
@JvmStatic
fun start(): Int {
methodWareHouse.add(MethodInfo())
return methodWareHouse.size - 1
}
@JvmStatic
fun addParams(param: Any?, index: Int) {
val method = methodWareHouse[index]
method.params.add(param)
}
@JvmStatic
fun end(result: Any?, className: String, methodName: String, startTime: Long, index: Int) {
val method = methodWareHouse[index]
method.className = className
method.methodName = methodName
method.returnParam = result
method.time = (System.nanoTime() - startTime) / 1000000f
println("┌───────────────────────────────────------───────────────────────────────────------")
println("│ [类名] ${method.className}")
println("│ [函数] ${method.methodName}")
println("│ [参数] ${method.params}")
println("│ [返回] ${method.returnParam}")
println("│ [耗时] ${method.time}")
println("└───────────────────────────────────------───────────────────────────────────------")
}
}
2、注册Transform
在自定义Gradle插件中,通过android.registerTransform()
将transform注册到app中
class PhoenixPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.afterEvaluate {
println()
println("===================================PhoenixPlugin===============begin==================")
println()
println()
println("===================================PhoenixPlugin===============end==================")
println()
}
registerTransform(project)
}
private fun registerTransform(project: Project) {
val appExtension = project.extensions.getByName("android")
if (appExtension is AppExtension) {
appExtension.registerTransform(CatTransform(project))
}
}
}
3、创建自定义Transform
class CatTransform(val project: Project) : Transform() {
private var SCOPES: MutableSet<QualifiedContent.Scope> = mutableSetOf()
init {
SCOPES.add(QualifiedContent.Scope.PROJECT)
SCOPES.add(QualifiedContent.Scope.SUB_PROJECTS)
SCOPES.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
}
override fun getName(): String {
return "cat"
}
override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
return TransformManager.CONTENT_CLASS
}
override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
return SCOPES
}
override fun isIncremental(): Boolean {
return false
}
override fun transform(transformInvocation: TransformInvocation?) {
super.transform(transformInvocation)
//字节码操作
......
}
}
通过代码可以看到我们直接是全量的扫描整个项目的class文件,包括工程和jar,关键的点在transform
上,在transform中需要解决几个问题:
- 非全量的情况下要清楚已有产物,防止文件错乱
- 由于
MethodManager
和MethodInfo
是放在插件内部,属于编译时的的产物,如果没有打进到包体里面,则会报错类找不到的问题,所以要主动将这两个类打进到包体中 - 把字节码打进包里面(分文件夹和jar包)
override fun transform(transformInvocation: TransformInvocation?) {
super.transform(transformInvocation)
val outputProvider = transformInvocation?.outputProvider
//非增量则全部删除输出文件,全量清空
if (!isIncremental) {
outputProvider?.deleteAll()
}
//知识点一:把类打进进包里面
addClass(transformInvocation)
//知识点二:把字节码打进包里面(分文件夹和jar包)
transformInvocation?.inputs?.forEach { input ->
//1、遍历输入的文件路径Dir
input.directoryInputs.forEach { directoryInput ->
println("--directoryInput = $directoryInput")
if (directoryInput.file.isDirectory) {
//2、遍历输入的所有文件File
FileUtils.getAllFiles(directoryInput.file).forEach { it ->
println("----file = $it")
val file = it
val name = file.name
if (name.endsWith(".class")) {
//3、通过ClassReader->ClassVisitor->ClassWriter(读取字节码->访问并修改字节码->输出字节码)
val reader = ClassReader(file.readBytes())
val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
val visitor = CatClassVisitor(project, writer)
reader.accept(visitor, ClassReader.EXPAND_FRAMES)
//4、将输出的字节码写入原有的class文件
val code = writer.toByteArray()
val classPath = file.parentFile.absolutePath + File.separator + name
val fos = FileOutputStream(classPath)
fos.write(code)
fos.close()
}
}
//5、将class文件返回transform打包路径
val dest = transformInvocation.outputProvider?.getContentLocation(
directoryInput.name,
directoryInput.contentTypes,
directoryInput.scopes,
Format.DIRECTORY
)
println("----src = ${directoryInput.file}")
println("----dest = $dest")
FileUtils.copyDirectoryToDirectory(directoryInput.file, dest)
}
}
//1、遍历输入的jar路径
input.jarInputs.forEach { jarInput ->
println("--jarInput = $jarInput")
val src = jarInput.file
val dest = transformInvocation.outputProvider?.getContentLocation(
jarInput.name,
jarInput.contentTypes,
jarInput.scopes,
Format.JAR
)
//2、创建新的空jar包,用来装改完后的jar包
val temp = src.absolutePath.substring(0, src.absolutePath.length - 4) + "_cat.jar"
val tempFile = File(temp)
if (tempFile.exists()) {
tempFile.delete()
}
val outputStream = JarOutputStream(FileOutputStream(tempFile))
//3、遍历输入的jar文件
val jarFile = JarFile(src)
val entries = jarFile.entries()
while (entries.hasMoreElements()) {
//4、对jar文件加入到临时新的jar包中
val jarEntry = entries.nextElement()
val className = jarEntry.name
val inputStream = jarFile.getInputStream(jarEntry)
val classEntry = ZipEntry(className)
outputStream.putNextEntry(classEntry)
if (className.contains("glide")
&& className.endsWith(".class") && !className.contains("R$")
&& !className.contains("R.class") && !className.contains("BuildConfig.class")
) {
//5、通过ClassReader->ClassVisitor->ClassWriter(读取字节码->访问并修改字节码->输出字节码)
val reader = ClassReader(inputStream)
val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
val visitor = CatClassVisitor(project, writer)
reader.accept(visitor, ClassReader.EXPAND_FRAMES)
val code = writer.toByteArray()
outputStream.write(code)
} else {
//6、不符合条件的直接源文件读取后放入到临时新的jar包中
var len = inputStream.read()
while (len != -1) {
outputStream.write(len)
len = inputStream.read()
}
}
inputStream.close()
}
//7、刷新后,改造后的字节码jar包会返回到transform打包路径
outputStream.flush()
outputStream.close()
FileUtils.copyFile(tempFile, dest)
//8、删除临时新的jar包
tempFile.delete()
println("----tempFile = $tempFile")
println("----dest = $dest")
}
}
}
/**
* 把MethodManager和MethodInfo的class文件直接打进进包里面
* /
private fun addClass(transformInvocation: TransformInvocation?) {
var dir = File(
System.getProperties()
.getProperty("user.dir")
+ File.separator + "buildSrc"
+ File.separator + "build"
+ File.separator + "classes" + File.separator + "kotlin" + File.separator + "main"
+ File.separator + MethodManager.javaClass.`package`.name.replace(
".",
File.separator
)
)
val dest = transformInvocation?.outputProvider?.getContentLocation(
dir.name,
setOf(QualifiedContent.DefaultContentType.CLASSES),
mutableSetOf(QualifiedContent.Scope.PROJECT),
Format.DIRECTORY
)
println("----src = $dir")
println("----dest = $dest")
FileUtils.copyDirectoryToDirectory(dir, dest)
}
可以发现的是,transform是直接通过一些列的文件操作,将class替换掉,最终将文件复制到transformInvocation?.outputProvider?.getContentLocation
提供的输出位置,交给下一个transform去处理。它就犹如是一间工厂,加工完成后,将加工品传送到下一个工厂继续加工。
4、CatClassVisitor
CatClassVisitor里面就是开始对字节码操作的内容,我们通过visit
获取类名信息,通过visitMethod
获取方法,接着交给CatMethodVisitor
开始篡改class字节码,也就是说我们的汇编程序
class CatClassVisitor(private val project: Project, classVisitor: ClassVisitor) :
ClassVisitor(Opcodes.ASM6, classVisitor) {
private lateinit var mClassName: String
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)
mClassName = name
println("------visit = 【className】$mClassName")
}
override fun visitMethod(
access: Int,
name: String?,
desc: String?,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
println("------visitMethod = 【name】$name 【desc】$desc")
var methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
methodVisitor =
CatMethodVisitor(Opcodes.ASM6, methodVisitor, access, mClassName, name, desc)
return methodVisitor
}
}
5、CatMethodVisitor
class字节码的篡改依靠MethodVisitor
对程序写入指令,按照汇编的执行顺序,依次写入对应的汇编指令
- onMethodEnter:在方法开头调用start方法和当前时间戳的方法
- onMethodExit:在方法结束调用end方法
class CatMethodVisitor(
api: Int, mv: MethodVisitor?, access: Int,
private val className: String?,
private val methodName: String?,
private val desc: String?
) : AdviceAdapter(api, mv, access, methodName, desc) {
private var startTimeId = 0
private var methodId = 0
private val isStaticMethod: Boolean = access and Opcodes.ACC_STATIC != 0
private val argumentArrays: Array<Type> = Type.getArgumentTypes(desc)
override fun onMethodEnter() {
println("--------onMethodEnter")
//1、插入Start()
methodId = newLocal(Type.INT_TYPE)
mv.visitMethodInsn(
Opcodes.INVOKESTATIC,
Constants.method_manager,
"start",
"()I",
false
)
mv.visitIntInsn(Opcodes.ISTORE, methodId)
//3、插入时间戳
startTimeId = newLocal(Type.LONG_TYPE)
mv.visitMethodInsn(
Opcodes.INVOKESTATIC,
"java/lang/System",
"nanoTime",
"()J",
false
)
mv.visitIntInsn(Opcodes.LSTORE, startTimeId)
}
override fun onMethodExit(opcode: Int) {
println("--------onMethodExit")
//4、插入end()
if (opcode == Opcodes.RETURN) {
visitInsn(Opcodes.ACONST_NULL)
} else if (opcode == Opcodes.ARETURN || opcode == Opcodes.ATHROW) {
dup()
} else {
if (opcode == Opcodes.LRETURN || opcode == Opcodes.DRETURN) {
dup2()
} else {
dup()
}
box(Type.getReturnType(methodDesc))
}
mv.visitLdcInsn(className)
mv.visitLdcInsn(methodName)
mv.visitVarInsn(Opcodes.LLOAD, startTimeId)
mv.visitVarInsn(Opcodes.ILOAD, methodId)
mv.visitMethodInsn(
Opcodes.INVOKESTATIC,
Constants.method_manager,
"end",
"(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;JI)V",
false
)
}
}
通过图片对比,我们也可以大概知道语法所表示的意思
6、结果
我们通过构建启动app查看下我们的插桩情况
--directoryInput = ImmutableDirectoryInput{name=0f05f04ac88e24be2f45d8d4dbf2f736e46af0f7, file=G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug, contentTypes=CLASSES, scopes=PROJECT, changedFiles={}}
----file = G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug\com\dreamer\uidemo\MainActivity.class
------visit = 【className】com/dreamer/uidemo/MainActivity
------visitMethod = 【name】onCreate 【desc】(Landroid/os/Bundle;)V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initBanner 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initList 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】initView 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】testLog 【desc】()V
--------onMethodEnter
--------onMethodExit
------visitMethod = 【name】<init> 【desc】()V
--------onMethodEnter
--------onMethodExit
----src = G:\yy\UIDemo\app\build\tmp\kotlin-classes\debug
----dest = G:\yy\UIDemo\app\build\intermediates\transforms\cat\debug\48
通过日志可以看到我们的MainActivity遍历了所有方法,然后对所有方法进行耗时埋点,最终将src
篡改过后的源文件位置复制到dest
下transform指定的目标目录
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out: │ [类名] com/dreamer/uidemo/MainActivity
I/System.out: │ [函数] <init>
I/System.out: │ [参数] []
I/System.out: │ [返回] null
I/System.out: │ [耗时] 0.025938
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out: │ [类名] com/dreamer/uidemo/MainActivity
I/System.out: │ [函数] initList
I/System.out: │ [参数] []
I/System.out: │ [返回] null
I/System.out: │ [耗时] 0.067448
I/System.out: └───────────────────────────────────------───────────────────────────────────------
I/System.out: ┌───────────────────────────────────------───────────────────────────────────------
I/System.out: │ [类名] com/dreamer/uidemo/MainActivity
I/System.out: │ [函数] initView
I/System.out: │ [参数] []
I/System.out: │ [返回] null
I/System.out: │ [耗时] 6.508906
I/System.out: └───────────────────────────────────------───────────────────────────────────------
优缺点
无痕插入统计耗时虽然做到了对开发者无感知并且无痕插入统计耗时,但其存在也有缺点,由于过多的插入代码,会带来包体积的增大的隐患,对编译速度也有性能上的损耗,建议可以在此基础上做针对性的埋点,或者重要业务上的埋点,而不是做到全量埋点。