注册

为了能够摸鱼,我走上了歧路

前言


每天都是重复的工作,这样可不行,已经严重影响我的日常摸鱼,为了减少自己日常的开发时间,我决定走一条歧路,铤而走险,将项目中的各种手动埋点统计替换成自动化埋点。以后再也不用担心没时间摸鱼了~


作为Android届开发的一员,今天我决定将摸鱼方案分享给大家,希望更多的广大群众能够的加入到摸鱼的行列中~



  1. 声明打点的接口方法
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)
}




  1. 通过动态代理获取StatisticService接口引用
private val mStatisticService = Statistic.instance.create(StatisticService::class.java)




  1. 在合适的埋点位置进行埋点统计,例如Click埋点
  2. 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来实现。类似于ButterKnifeDagger2Room等。之前我也有写过相关的demo与文章。由于不在本篇文章的范围之内,感兴趣的可以自行去了解。


这里我们需要做的是:需要在ProxyActiviy中将2、3步骤的代码转成自动注入。


自动注入就是在现有的类中自动加入我们预期的代码,不需要我们额外的进行编写。


既然已经知道了需要注入的代码,那么接下的问题就是什么时候进行注入这些代码。


这就涉及到Android构建与打包的流程,Android使用Gradle进行构建与打包,


image.png


在打包的过程中将源文件转化成.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


appbuild.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提供了三种打包插件的方式



  1. Build Script: 将插件的源代码直接包含在构建脚本中。这样做的好处是,无需执行任何操作即可自动编译插件并将其包含在构建脚本的类路径中。但缺点是它在构建脚本之外不可见,常用在脚本自动构建中。
  2. buildSrc projectgradle会自动识别buildSrc目录,所以可以将plugin放到buildSrc目录中,这样其它的构建脚本就能自动识别这个plugin, 多用于自身项目,对外不共享。
  3. 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'
}
}
}



Pluginidtrace_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.gradleapply的插件名称。


文件中的内容很简单,只有一行,对应的就是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


TracePluginapply方法中,对项目的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()
}
}
}



代码很简单,只需要实现几个特定的方法。



  1. getName: Transform对外显示的名称
  2. getInputTypes: 扫描的文件类型,CONENT_JARS代表CLASSESRESOURCES
  3. isIncremental: 是否开启增量,开启后会提高构建速度,对应的需要手动处理增量的逻辑
  4. getScopes: 扫描作用范围,SCOPE_FULL_PROJECT代表整个项目
  5. transform: 需要转换的逻辑都在这里处理

transform是我们接下来.class文件的入口,这个方法有个参数TransformInvocation,该参数提供了上面定义范围内扫描到的所用jar文件与directory文件。


transform中我们主要做的就是在这些jardirectory中解析出.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方法主要做的就是分别遍历jardirectory中的文件。在这两大种类中分别解析出.class文件。


例如jar的解析transformJar


如果是增量编译,就分别处理增量的不同操作,主要的是ADDEDCHANGED操作。这个处理逻辑与非增量编译的时候一样,都是去遍历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方法,这个方法的具体实现在TraceTransformtransform方法中

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中使用TraceInjectDelegateinject来处理过滤出来的字节码。最终的处理会来到modifyClassByte方法。



这里的ClassWriterClassFilterVisitorClassReader都是ASM的内容,也是我们接下来实现自动注入代码的重点。


ASM


ASM是操作Java字节码的一个工具。


其实操作字节码的除了ASM还有javassist,但个人觉得ASM更方便,因为它有一系列的辅助工具,能更好的帮助我们实现代码的注入。


在上面我们已经得到了.class的字节码文件。现在我们需要做的就是扫描整个字节码文件,判断是否是我们需要注入的文件。


这里我将这些逻辑封装到了ClassFilterVisitor文件中。


ASM为我们提供了ClassVisitorMethodVisitorFieldVisitorAPI。每当ASM扫描类的字节码时,都会调用它的visitvisitFieldvisitMethodvisitAnnotation等方法。


有了这些方法,我们就可以判断并处理我们需要的字节码文件。

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方法中进行注入代码。


而类的扫描与方法扫描分别可以使用visitvisitMetho


visit方法中,我们保存当前类实现的接口;在visitMethod中再对当前接口进行判断,看它是否有onClick方法。



namedesc分别为onClick方法的方法名称与方法参数描述。这是字节码匹配方法的一种规范。



如果有的话,说明是我们需要插入的方法,这个时候返回AdviceAdapter。它是ASM提供的便捷针对方法注入的类。我们重写它的onMethodEnter方法。代表我们将在方法的开头注入代码。


onMethodEnter方法中的代码就是LogUtils.dASM注入实现。你可能会说这个是什么,完全看不懂,更别说写字节码注入了。


别急,下面就是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的代码,不同的只是注入的时机判断。


有了上面的基础,我们来实现开头的自动埋点。


实现


为了让自动化埋点能够灵活的传递打点数据,我们使用注解的方式来传递具体的埋点数据与类型。



  1. TrackClickData: 点击的数据
  2. TrackScanData: 曝光的数据
  3. TrackScan: 曝光点
  4. TrackClick: 点击点

有了这些注解,剩下我们要做的就很简单了


使用TrackClickDataTrackScanData声明打点的数据;使用TrackScanTrackClick声明打点的类型与自动化插入代码的入口方法。


我们再回到注入代码的类ClassFilterVisitor,来实现具体的埋点代码的注入。


在这里我们需要做的是解析声明的注解,拿到打点的数据,并且声明的TrackScanTrackClick方法中插入埋点的具体代码。

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方法,目的是找到之前TrackClickDataTrackScanData声明的埋点字段。对应的就是mTrackModelmTrackScanData


主要包括字段名称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中,我们找到目标的埋点方法,即之前声明的方法注解TrackScanTrackClick


返回并实现AdviceAdapter,重写它的visitAnnotation方法。


该方法会自动扫描方法的注解,所以可以通过该方法来保存当前方法的注解。


然后在onMethodExit中,即方法的开头处进行注入代码。


在该方法中主要做三件事



  1. 向默认构造方法中,实例化statisticService
  2. 注入TrackClick 点击
  3. 注入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());
}



以上自动化埋点代码就已经完成了。


简单总结一下,所用到的技术有



  1. gradle plugin插件的自定义
  2. gradle transform提供编译中字节码的修改入口
  3. asm提供代码的注入实现


作者:午后一小憩
链接:https://juejin.cn/post/6963252047617458184
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1 个评论

我也想摸鱼

要回复文章请先登录注册