为了能够摸鱼,我走上了歧路
前言
每天都是重复的工作,这样可不行,已经严重影响我的日常摸鱼,为了减少自己日常的开发时间,我决定走一条歧路,铤而走险,将项目中的各种手动埋点统计替换成自动化埋点。以后再也不用担心没时间摸鱼了~
作为Android
届开发的一员,今天我决定将摸鱼方案分享给大家,希望更多的广大群众能够的加入到摸鱼的行列中~
- 声明打点的接口方法
interface StatisticService {
@Scan(ProxyActivity.PAGE_NAME)
fun buttonScan(@Content(StatisticTrack.Parameter.NAME) name: String)
@Click(ProxyActivity.PAGE_NAME)
fun buttonClick(@Content(StatisticTrack.Parameter.NAME) name: String, @Content(StatisticTrack.Parameter.TIME) clickTime: Long)
@Scan(ProxyActivity.PAGE_NAME)
fun textScan(@Content(StatisticTrack.Parameter.NAME) name: String)
@Click(ProxyActivity.PAGE_NAME)
fun textClick(@Content(StatisticTrack.Parameter.NAME) name: String, @Content(StatisticTrack.Parameter.TIME) clickTime: Long)
}
- 通过动态代理获取
StatisticService
接口引用
private val mStatisticService = Statistic.instance.create(StatisticService::class.java)
- 在合适的埋点位置进行埋点统计,例如
Click
埋点 fun onClick(view: View) {
if (view.id == R.id.button) {
mStatisticService.buttonClick(BUTTON, System.currentTimeMillis() / 1000)
} else if (view.id == R.id.text) {
mStatisticService.textClick(TEXT, System.currentTimeMillis() / 1000)
}
}
其中2、3步骤都是在对应埋点的类中使用,这里对应的是ProxyActivity
class ProxyActivity : AppCompatActivity() {
// 步骤2
private val mStatisticService = Statistic.instance.create(StatisticService::class.java)
companion object {
private const val BUTTON = "statistic_button"
private const val TEXT = "statistic_text"
const val PAGE_NAME = "ProxyActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
//...
title = extraData.title
// 步骤3 => 曝光点
mStatisticService.buttonScan(BUTTON)
mStatisticService.textScan(TEXT)
}
private fun getExtraData(): MainModel =
intent?.extras?.getParcelable(ActivityUtils.EXTRA_DATA)
?: throw NullPointerException("intent or extras is null")
// 步骤3 => 点击点
fun onClick(view: View) {
if (view.id == R.id.button) {
mStatisticService.buttonClick(BUTTON, System.currentTimeMillis() / 1000)
} else if (view.id == R.id.text) {
mStatisticService.textClick(TEXT, System.currentTimeMillis() / 1000)
}
}
}
步骤1是创建新的类,不在代码注入的范围之内。自动生成类可以使用注解+process+JavaPoet
来实现。类似于ButterKnife
、Dagger2
、Room
等。之前我也有写过相关的demo
与文章。由于不在本篇文章的范围之内,感兴趣的可以自行去了解。
这里我们需要做的是:需要在ProxyActiviy
中将2、3步骤的代码转成自动注入。
自动注入就是在现有的类中自动加入我们预期的代码,不需要我们额外的进行编写。
既然已经知道了需要注入的代码,那么接下的问题就是什么时候进行注入这些代码。
这就涉及到Android
构建与打包的流程,Android
使用Gradle
进行构建与打包,
在打包的过程中将源文件转化成.class
文件,然后再将.class
文件转成Android
能识别的.dex
文件,最终将所有的.dex
文件组合成一个.apk
文件,提供用户下载与安装。
而在将源文件转化成.class
文件之后,Google
提供了一种Transform
机制,允许我们在打包之前对.class
文件进行修改。
这个修改时机就是我们代码自动注入的时机。
transform
是由gradle
提供,在我们日常的构建过程中也会看到系统自身的transform
身影,gradle
由各种task
组成,transform
就穿插在这些task
中。
图中高亮的部分就是本次自定义的TraceTransform
, 它会在.class
转化成.dex
之前进行执行,目的就是修改目标.class
文件内容。
Transform
的实现需要结合Gradle Plugin
一起使用。所以接下来我们需要创建一个Plugin
。
创建Plugin
在app
的build.gradle
中,我们能够看到以下类似的插件引用方式
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply plugin: 'trace_plugin'
这里的插件包括系统自带、第三方的与自定义的。其中trace_plugin
就是本次自定义的插件。为了能够让项目使用自定义的插件,Gradle
提供了三种打包插件的方式
Build Script
: 将插件的源代码直接包含在构建脚本中。这样做的好处是,无需执行任何操作即可自动编译插件并将其包含在构建脚本的类路径中。但缺点是它在构建脚本之外不可见,常用在脚本自动构建中。buildSrc project
:gradle
会自动识别buildSrc
目录,所以可以将plugin
放到buildSrc
目录中,这样其它的构建脚本就能自动识别这个plugin
, 多用于自身项目,对外不共享。Standalone project
: 创建一个独立的plugin
项目,通过对外发布Jar
与外部共享使用。
这里使用第三种方式来创建Plugin
。所以创建完之后的目录结构大概是这样的
为了让别的项目能够引用这个Plugin
,我们需要对外声明,可以发布到maven
中,也可以本地声明,为了简便这里使用本地声明。
apply plugin: 'java-gradle-plugin'
dependencies {
implementation gradleApi()
implementation localGroovy()
}
gradlePlugin {
plugins {
version {
// 在 app 模块需要通过 id 引用这个插件
id = 'trace_plugin'
// 实现这个插件的类的路径
implementationClass = 'com.rousetime.trace_plugin.TracePlugin'
}
}
}
该Plugin
的id
为trace_plugin
,实现入口为com.rousetime.trace_plugin.TracePlugin
。
声明完之后,就可以直接在项目的根目录下的build.gradle
中引入该id
plugins {
id "trace_plugin" apply false
}
为了能在app
项目中apply
这个plugin
,还需要创建一个META-INF.gradle-plugins
目录,对应的位置如下
注意这里的trace_plugin.properties
文件名非常重要,前面的trace_plugin
就代表你在build.gradle
中apply
的插件名称。
文件中的内容很简单,只有一行,对应的就是TracePlugin
的实现入口
implementation-class=com.rousetime.trace_plugin.TracePlugin
上面都准备就绪之后,就可以在build.gradle
进行apply plugin
apply plugin: 'trace_plugin'
这个时候我们自定义的plugin
就引入到项目中了。
再回到刚刚的Plugin
入口TracePlugin
,来看下它的具体实现
class TracePlugin : Plugin {
override fun apply(target: Project) {
if (target.plugins.hasPlugin(AppPlugin::class.java)) {
val appExtension = target.extensions.getByType(AppExtension::class.java)
appExtension.registerTransform(TraceTransform())
}
val methodVisitorConfig = target.extensions.create("methodVisitor", MethodVisitorConfig::class.java)
LocalConfig.methodVisitorConfig = methodVisitorConfig
target.afterEvaluate {
}
}
}
只有一个方法apply
,在该方法中我们打印一行文本,然后重新构建项目,在build
输出窗口就能看到这行文本
....
> Configure project :app
Trace Plugin start to apply
mehtodVisitorConfig
Deprecated Gradle features were used in this build, making it incompatible with Gradle 6.0.
Use '--warning-mode all' to show the individual deprecation warnings.
...
到这里我们自定义的plugin
已经创建成功,并且已经集成到我们的项目中。
第一步已经完成。下面进入第二步。
实现Transform
在TracePlugin
的apply
方法中,对项目的appExtension
注册了一个TraceTransform
。重点来了,这个TraceTransform
就是我们在gradle
构建的过程中插入的Transform
,也就是注入代码的入口。来看下它的具体实现
class TraceTransform : Transform() {
override fun getName(): String = this::class.java.simpleName
override fun getInputTypes(): MutableSet = TransformManager.CONTENT_JARS
override fun isIncremental(): Boolean = true
override fun getScopes(): MutableSet = TransformManager.SCOPE_FULL_PROJECT
override fun transform(transformInvocation: TransformInvocation?) {
TransformProxy(transformInvocation, object : TransformProcess {
override fun process(entryName: String, sourceClassByte: ByteArray): ByteArray? {
// use ams to inject
return if (ClassUtils.checkClassName(entryName)) {
TraceInjectDelegate().inject(sourceClassByte)
} else {
null
}
}
}).apply {
transform()
}
}
}
代码很简单,只需要实现几个特定的方法。
getName
:Transform
对外显示的名称getInputTypes
: 扫描的文件类型,CONENT_JARS
代表CLASSES
与RESOURCES
isIncremental
: 是否开启增量,开启后会提高构建速度,对应的需要手动处理增量的逻辑getScopes
: 扫描作用范围,SCOPE_FULL_PROJECT
代表整个项目transform
: 需要转换的逻辑都在这里处理
transform
是我们接下来.class
文件的入口,这个方法有个参数TransformInvocation
,该参数提供了上面定义范围内扫描到的所用jar
文件与directory
文件。
在transform
中我们主要做的就是在这些jar
与directory
中解析出.class
文件,这是找到目标.class
的第一步。只有解析出了所有的.class
文件,我们才能进一步过滤出我们需要注入代码的.class
文件。
而transform
的工作流程是:解析.class
文件,然后我们过滤出需要处理的.class
文件,写入对应的逻辑,然后再将处理过的.class
文件重新拷贝到之前的jar
或者directory
中。
通过这种解析、处理与拷贝的方式,实现偷天换日的效果。
既然有一套固定的流程,那么自然有对应的一套固定是实现。在这三个步骤中,真正需要实现的是处理逻辑,不同的项目有不同的处理逻辑,
对于解析与拷贝操作,已经有相对完整的一套通用实现方案。如果你的项目中有多个这种类型的Transform
,就可以将其抽离出来单个module
,增加复用性。
解析与拷贝
下面我们来看一下它的核心实现步骤。
fun transform() {
if (!isIncremental) {
// 不是增量编译,将之前的输出目录中的内容全部删除
outputProvider?.deleteAll()
}
inputs?.forEach {
// jar
it.jarInputs.forEach { jarInput ->
transformJar(jarInput)
}
// directory
it.directoryInputs.forEach { directoryInput ->
transformDirectory(directoryInput)
}
}
executor?.invokeAll(tasks)
}
transform
方法主要做的就是分别遍历jar
与directory
中的文件。在这两大种类中分别解析出.class
文件。
例如jar
的解析transformJar
如果是增量编译,就分别处理增量的不同操作,主要的是ADDED
与CHANGED
操作。这个处理逻辑与非增量编译的时候一样,都是去遍历jar
,从中解析出对应的.class
文件。
遍历的核心代码如下
while (enumeration.hasMoreElements()) {
val jarEntry = enumeration.nextElement()
val inputStream = originalFile.getInputStream(jarEntry)
val entryName = jarEntry.name
// 构建zipEntry
val zipEntry = ZipEntry(entryName)
jarOutputStream.putNextEntry(zipEntry)
var modifyClassByte: ByteArray? = null
val sourceClassByte = IOUtils.toByteArray(inputStream)
if (entryName.endsWith(".class")) {
modifyClassByte = transformProcess.process(entryName, sourceClassByte)
}
if (modifyClassByte == null) {
jarOutputStream.write(sourceClassByte)
} else {
jarOutputStream.write(modifyClassByte)
}
inputStream.close()
jarOutputStream.closeEntry()
}
如果entryName
的后缀是.class
说明当前是.class
文件,我们需要单独拿出来进行后续的处理。
后续的处理逻辑交给了transformProcess.process
。具体处理先放一放。
处理完之后,再将处理后的字节码拷贝保存到之前的jar
中。
对应的directory
也是类似 同样是过滤出.class
文件,然后交给process
方法进行统一处理。最后将处理完的字节码拷贝保存到原路径中。
以上就是Transform
的解析与拷贝的核心处理。
处理
上面提到.class
的处理都转交给process
方法,这个方法的具体实现在TraceTransform
的transform
方法中
class TraceAsmInject : Inject {
override fun modifyClassByte(byteArray: ByteArray): ByteArray {
val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS)
val classFilterVisitor = ClassFilterVisitor(classWriter)
val classReader = ClassReader(byteArray)
classReader.accept(classFilterVisitor, ClassReader.EXPAND_FRAMES)
return classWriter.toByteArray()
}
}
在process
中使用TraceInjectDelegate
的inject
来处理过滤出来的字节码。最终的处理会来到modifyClassByte
方法。
这里的ClassWriter
、ClassFilterVisitor
、ClassReader
都是ASM
的内容,也是我们接下来实现自动注入代码的重点。
ASM
ASM
是操作Java
字节码的一个工具。
其实操作字节码的除了ASM
还有javassist
,但个人觉得ASM
更方便,因为它有一系列的辅助工具,能更好的帮助我们实现代码的注入。
在上面我们已经得到了.class
的字节码文件。现在我们需要做的就是扫描整个字节码文件,判断是否是我们需要注入的文件。
这里我将这些逻辑封装到了ClassFilterVisitor
文件中。
ASM
为我们提供了ClassVisitor
、MethodVisitor
、FieldVisitor
等API
。每当ASM
扫描类的字节码时,都会调用它的visit
、visitField
、visitMethod
与visitAnnotation
等方法。
有了这些方法,我们就可以判断并处理我们需要的字节码文件。
class ClassFilterVisitor(cv: ClassVisitor?) : ClassVisitor(Opcodes.ASM5, cv) {
override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array?) {
super.visit(version, access, name, signature, superName, interfaces)
// 扫描当前类的信息
}
override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?, exceptions: Array?): MethodVisitor {
// 扫描类中的方法
}
override fun visitField(access: Int, name: String?, desc: String?, signature: String?, value: Any?): FieldVisitor {
// 扫描类中的字段
}
}
这是几个主要的方法,也是接下来我们需要重点用到的方法。
首先我们来看个简单的,这个明白了其它的都是一样的。
fun bindData(value: MainModel, position: Int) {
itemView.content.apply {
text = value.content
setOnClickListener {
// 自动注入这行代码
LogUtils.d("inject success.")
if (position == 0) {
requestPermission(context, value)
} else {
navigationPage(context, value)
}
}
}
}
假设我们需要在onClickListener
中注入LogUtils.d
这个行代码,本质就是在点击的时候输出一行日志。
首先我们需要明白,setOnClickListener
本质是实现了一个OnClickListener
接口的匿名内部类。
所以可以在扫描类的时候判断是否实现了OnClickListener
这个接口,如果实现了,我们再去匹配它的onClick
方法,并且在它的onClick
方法中进行注入代码。
而类的扫描与方法扫描分别可以使用visit
与visitMetho
在visit
方法中,我们保存当前类实现的接口;在visitMethod
中再对当前接口进行判断,看它是否有onClick
方法。
name
与desc
分别为onClick
方法的方法名称与方法参数描述。这是字节码匹配方法的一种规范。
如果有的话,说明是我们需要插入的方法,这个时候返回AdviceAdapter
。它是ASM
提供的便捷针对方法注入的类。我们重写它的onMethodEnter
方法。代表我们将在方法的开头注入代码。
onMethodEnter
方法中的代码就是LogUtils.d
的ASM
注入实现。你可能会说这个是什么,完全看不懂,更别说写字节码注入了。
别急,下面就是ASM
的方便之处,我们只需在Android Studio
中下载ASM Bytecode Viewer Support Kotlin
插件。
该插件可以帮助我们查看kotlin
字节码,只需右键弹窗中选择ASM Bytecode Viewer
。稍后就会弹出转化后的字节码弹窗。
在弹窗中找到需要注入的代码,具体就是下面这几行
methodVisitor.visitFieldInsn(GETSTATIC, "com/idisfkj/androidapianalysis/utils/LogUtils", "INSTANCE", "Lcom/idisfkj/androidapianalysis/utils/LogUtils;");
methodVisitor.visitLdcInsn("inject success.");
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "com/idisfkj/androidapianalysis/utils/LogUtils", "d", "(Ljava/lang/String;)V", false);
这就是LogUtils.d
的注入代码,直接copy
到上面提到的onMethodEnter
方法中。这样注入的代码就已经完成。
如果你想查看是否注入成功,除了运行项目,查看效果之外,还可以直接查看注入的源码。
在项目的build/intermediates/transforms
目录下,找到自定义的TraceTransform
,再找到对应的注入文件,就可以查看注入源码。
其实到这来核心内容基本已经结束了,不管是注入什么代码都可以通过这种方法来获取注入的ASM
的代码,不同的只是注入的时机判断。
有了上面的基础,我们来实现开头的自动埋点。
实现
为了让自动化埋点能够灵活的传递打点数据,我们使用注解的方式来传递具体的埋点数据与类型。
- TrackClickData: 点击的数据
- TrackScanData: 曝光的数据
- TrackScan: 曝光点
- TrackClick: 点击点
有了这些注解,剩下我们要做的就很简单了
使用TrackClickData
与TrackScanData
声明打点的数据;使用TrackScan
与TrackClick
声明打点的类型与自动化插入代码的入口方法。
我们再回到注入代码的类ClassFilterVisitor
,来实现具体的埋点代码的注入。
在这里我们需要做的是解析声明的注解,拿到打点的数据,并且声明的TrackScan
与TrackClick
方法中插入埋点的具体代码。
override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array?) {
super.visit(version, access, name, signature, superName, interfaces)
mInterface = interfaces
mClassName = name
}
通过visit
方法来扫描具体的类文件,在这里保存当前扫描的类的信息,为之后注入代码做准备
override fun visitField(access: Int, name: String?, desc: String?, signature: String?, value: Any?): FieldVisitor {
val filterVisitor = super.visitField(access, name, desc, signature, value)
return object : FieldVisitor(Opcodes.ASM5, filterVisitor) {
override fun visitAnnotation(annotationDesc: String?, visible: Boolean): AnnotationVisitor {
if (annotationDesc == TRACK_CLICK_DATA_DESC) { // TrackClickData 注解
mTrackDataName = name
mTrackDataValue = value
mTrackDataDesc = desc
createFiled()
} else if (annotationDesc == TRACK_SCAN_DATA_DESC) { // TrackScanData注解
mTrackScanDataName = name
mTrackScanDataDesc = desc
createFiled()
}
return super.visitAnnotation(annotationDesc, visible)
}
}
}
visitFiled
方法用来扫描类文件中声明的字段。在该方法中,我们返回并实现FieldVisitor
,并重新它的visitAnnotation
方法,目的是找到之前TrackClickData
与TrackScanData
声明的埋点字段。对应的就是mTrackModel
与mTrackScanData
。
主要包括字段名称name
与字段的描述desc
,为我们之后注入埋点数据做准备。
另外一旦匹配到埋点数据的注解,说明该类中需要进行自动化埋点,所以还需要自动创建StatisticService
。这是打点的接口方法,具体打点的都是通过StatisticService
来实现。
在visitField
中,通过createFiled
方法来创建StatisticService
类型的字段
private fun createFiled() {
if (!mFieldPresent) {
mFieldPresent = true
// 注入:statisticService 字段
val fieldVisitor = cv.visitField(ACC_PRIVATE or ACC_FINAL, statisticServiceField.name, statisticServiceField.desc, null, null)
fieldVisitor.visitEnd()
}
}
其中statisticServiceField
是封装好的StatisticService
字段信息。
companion object {
const val OWNER = "com/idisfkj/androidapianalysis/proxy/StatisticService"
const val DESC = "Lcom/idisfkj/androidapianalysis/proxy/StatisticService;"
val INSTANCE = StatisticService()
}
val statisticService = FieldConfig(
Opcodes.PUTFIELD,
"",
"mStatisticService",
DESC
)
创建的字段名为mStatisticService
,它的类型是StatisticService
到这里我们已经拿到了埋点的数据字段,并创建了埋点的调用字段mStatisticService
;接下来要做的就是注入埋点代码。
核心注入代码在visitMethod
方法中,该方法用来扫描类中的方法。所以类中声明的方法都会在这个方法中进行扫描回调。
在visitMethod
中,我们找到目标的埋点方法,即之前声明的方法注解TrackScan
与TrackClick
。
返回并实现AdviceAdapter
,重写它的visitAnnotation
方法。
该方法会自动扫描方法的注解,所以可以通过该方法来保存当前方法的注解。
然后在onMethodExit
中,即方法的开头处进行注入代码。
在该方法中主要做三件事
- 向默认构造方法中,实例化
statisticService
- 注入
TrackClick
点击 - 注入
TrackScan
曝光
具体的ASM
注入代码可以通过之前说的SM Bytecode Viewer Support Kotlin
插件获取。
有了上面的实现,再来运行运行主项目,你就会发现埋点代码已经自动注入成功。
我们反编译一下.class
文件,来看下注入后的java
代码
StatisticService初始化
public ProxyActivity() {
boolean var2 = false;
List var3 = (List)(new ArrayList());
this.mTrackScanData = var3;
// 以下是注入代码
this.mStatisticService = (StatisticService)Statistic.Companion.getInstance().create(StatisticService.class);
}
曝光埋点
@TrackScan
public final void onScan() {
this.mTrackScanData.add(new TrackModel("statistic_button", 0L, 2, (DefaultConstructorMarker)null));
this.mTrackScanData.add(new TrackModel("statistic_text", 0L, 2, (DefaultConstructorMarker)null));
// 以下是注入代码
LogUtils.INSTANCE.d("inject track scan success.");
Iterator var2 = this.mTrackScanData.iterator();
while(var2.hasNext()) {
TrackModel var1 = (TrackModel)var2.next();
this.mStatisticService.trackScan(var1.getName());
}
}
点击埋点
@TrackClick
public final void onClick(@NotNull View view) {
Intrinsics.checkParameterIsNotNull(view, "view");
this.mTrackModel.setTime(System.currentTimeMillis() / (long)1000);
this.mTrackModel.setName(view.getId() == 2131230792 ? "statistic_button" : "statistic_text");
// 以下是注入代码
LogUtils.INSTANCE.d("inject track click success.");
this.mStatisticService.trackClick(this.mTrackModel.getName(), this.mTrackModel.getTime());
}
以上自动化埋点代码就已经完成了。
简单总结一下,所用到的技术有
gradle plugin
插件的自定义gradle transform
提供编译中字节码的修改入口asm
提供代码的注入实现
作者:午后一小憩
链接:https://juejin.cn/post/6963252047617458184
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。