前言
在起初的手动埋点的时候,每次版本大更新,很多埋点都要进行修改,删除。这个时候之前嵌在源码里面的一行行埋点代码要进行修改,删除。删了又找,找了又改,很麻烦。如果遇到有代码洁癖的,“产品你竟然要在我代码里加这么多埋点,很影响我代码美观,晓得不!”心里敢想不敢说。于是我就想能不能利用字节码插桩把埋点信息插进去呢...
在去年突发奇想,想利用Gradle插件,Transform+ASM实现字节码插桩,将需要手动埋点的地方通过操作字节码进行埋点。
先是在网上搜索相关无痕埋点框架,搜到不少,都并不符合我的预期,我预期是对源代码不要进行任何的手动操作。而很多框架是利用了注解,需要在埋点的地方标记注解,emmm.....似乎都是这么解决了。
还有一种无痕埋点呢,像didi的无痕埋点,是记录所有View的id。框架太复杂了。。。。望而却步。
就像这样一个埋点,记录新手用户点击领取新用户礼包这样一个事件信息,"new_user_receive_gift" 就这么一个信息。我怎么才能在不修改源代码的情况下,把这个加入到对应的领取按钮触发的方法里面呢。
我起初想到的是用AspectJ,但是对于使用者来说,还是比较麻烦的,有一定的学习成本,如果不使用注解进行aop的话,那操作起来是相当麻烦的。
于是我就放弃了AspectJ,转而了解了一下Transform和ASM,我发现这个可行。
我设想的实际操作流程:
- 我通过一个配置文件将埋点信息记录。(实际使用)
- 编写一个接收埋点信息事件的接收类,将接收到的埋点信息通过埋点统计框架上传。(实际使用)
- 通过Transform执行的时候读取埋点信息。(框架封装)
- 利用ASM将埋点字节码写入原文件。(框架封装)
使用文件配置进行无痕埋点,在添加埋点的时候,我就可以不手动修改源代码了,只需要在配置文件中增加一个埋点信息就行了。
我的Slotting无痕埋点完成了
实在是不知道起什么名字好了。随便搜了个 “开槽(Slotting)”。给代码开个槽吧。
经过一周的学习和一周的代码编写大约两周的时间。从对Transform,ASM,Gradle Plugin一窍不通到一壳要秃。
基于我的无痕埋点设想首先我定义了一个埋点信息接收接口:
interface Slotting {
/**
* 接收一个数组消息
* - send("msg") ; send("abc",19,this.name)
*/
fun send(vararg msg: Any?)
/**
* 接收一个Map消息
* - val map = mutableMap<String,Any?>()
* - map .put(key1,"value1")
* - map.put(key2 ,this.value2 )
* - send(map)
*/
fun send(map: Map<String, Any?>)
}
说是接收器,其实就是直接调用这个类方法。
1.实现Slotting.kt接口
kotlin:
object SimpleSlotting : Slotting{
override fun send(vararg msg: Any?) {
}
override fun send(map: Map<String, Any?>) {
}
}
java
public class JavaSlotting implements Slotting {
public static JavaSlotting INSTANCE = new JavaSlotting();
@Override
public void send(@NonNull Object... objects) {
}
@Override
public void send(@NonNull Map<String, ?> map) {
}
}
注意:
Kotlin中使用object
实现接口。
Java中实现接口之后需要再创建一个静态对象INSTANCE
便于调用。这个调用不需要手动调用。是插桩框架生成字节码调用。
2.创建埋点配置文件
在app目录下创建slotting.json
文件.
app/
|-libs/
|-src/
|-slotting.json <----
可以放在其他目录中:
app/
|-libs/
|-src/
|-simpleDir/
|-slotting.json <----
3.添加脚本配置
在app的build.gradle
中引入插件,并修改插件配置信息。
plugins {
id 'com.dboy.slotting'
}
slotting{
//配置文件名,要包含扩展名.json , 默认名称:slotting.json
fileName "slotting.json"
//配置文件路径, 默认位置是app模块根目录
//simple: filePath = "simpleDir/"
filePath ""
//消息接收实现类,实现接口 [com.dboy.slotting.api.Slotting]
implementedClass "com.example.SimpleSlotting"
}
4.编写slotting.json
文件
这个文件是json格式的。
[
{
"classPath": "com.xxx.MainActivity",
"entryPoints": [
{
"methodName": "simpleEventMethod",
"event": "event,from,${name}"
},
{
"methodName": "simpleEventMapMethod",
"eventMap": {
"msg": "is msg 1",
"msg2": "${this.msg2}"
}
}
]
},
{
"classPath": "com.xxx.MainActivity"
}
]
Json字段说明:
此json根节点是一个List表,实体对象内容为:
classPath
: 指明需要埋点的class文件全量名称,排除.class
后缀。entryPonts
: 切入点/埋点位置。这是个list列表,内部包含了当前classPath
所有需要触发埋点的方法信息。methodName
: 需要埋点的方法名字。event
: 埋点触发事件,可以是单个字符串,也可以多个埋点事件,通过英文“,”
逗号进行分割。接收此事件方法fun send(vararg msg: Any?)
.eventMap
: 具有Key->value映射的事件。接收此事件方法fun send(map: Map<String, Any?>)
isFirstLine
: 这个是一个boolean
数据表明这个埋点事件是插入method
第一行,还是method
的return
时的位置。默认是false
event
事件和eventMap
事件的Value值可以使用占位符来获取全局变量和局部变量。
使用
${...}
来进行占位标识,${this.xxx}
表示获取全局变量xxx。${xxx}
表示获取方法的局部变量xxx
例如:
event :"全局变量:,${this.globalName},局部变量:,${localName}"
eventMap :{
"全局变量":"${this.globalName}",
"方法局部变量":"${localName}"
}
event
和eventMap
两种类型的事件只取其一,如果两者都有数据,优先使用event
数据
配置完成之后即可进行项目构建(Build)。
注意:修改class文件不需要clean项目,如果修改了
slotting{}
脚本配置,或者修改了slotting.json
文件,需要clean整个项目重新build or rebuild。由于Transform
的增量编译,不会通过slotting.json
文件的变化而修改对应class,所以当配置修改后,检测字节码的时候原class没有变更是不会二次插桩修改的。
字节码插桩生成演示
编写自己的事件接收文件SimpleSlotting.kt
object SimpleSlotting : Slotting {
override fun send(vararg msg: Any?) {
//使用统计平台对msg进行处理发送例如:Umeng
val str = StringBuilder()
msg.forEach {
if (it == null) {
str.append("null")
} else {
str.append(it.toString())
}
}
MobclickAgent.onEventObject(Utils.getApp(), str)
}
override fun send(map: Map<String, Any?>) {
//使用统计平台对map进行处理发送例如:Umeng
val event = map["event"]
MobclickAgent.onEventObject(Utils.getApp(), event, map)
}
}
原始SimpleClass.kt
class SimpleClass {
fun testEvent1(){
}
fun testEvent2(){
}
fun testEvent3(userName: String) {
}
}
slotting.json
配置文件
[
{
"classPath": "com.simple.SimpleClass",
"entryPonts": [
{
"methodName": "testEvent1",
"event": "testEvent1"
},
{
"methodName": "testEvent2",
"event": "testEvent2,two"
},
{
"methodName": "testEvent3",
"eventMap": {
"event": "testEvet3",
"name": "${userName}"
}
}
]
}
]
字节码插桩后的SimpleClass.kt
class SimpleClass {
fun testEvent1(){
SimpleSlotting.send("testEvent1")
}
fun testEvent2(){
SimpleSlotting.send("testEvent2","two")
}
fun testEvent3(userName: String) {
val map = HashMap<String,Any>()
map["event"] = "testEvent3"
map["name"] = userName
SimpleSlotting.send(map)
}
}
当你需要在最后一行插入代码的时候需要注意:
当埋点需要在方法最后一行插入的时候,所有return的位置都有可能是方法结束时的最后一行。所以所有return位置都会被插入同样的埋点信息。
如果你携带了局部变量。当局部变量不在可索引范围内的时候,埋点事件框架不会将无法索引的局部变量添加到事件中。
例如埋点:上传检查后的a
和b
的值
fun check(){
var a = ""
//...
if(a==null){
//...在这里只能访问到变量a,变量b无法访问 , 最后会插入Slotting.send(a)
return
}
var b = ...
if(b==null){
//....在这里,a和b变量都可以被访问到,最后会插入Slotting.send(a,b)
return
}
//...Slotting.send(a,b)
}
上面的做法显然有点问题,数据检查和数据的使用应该分开,这样就更有利于代码插装,和业务上的明细。
不如模拟一个正经的场景:用户登录。
埋点描述:用户登录失败,上传失败原因user_login_error_xxx
(xxx是哪一步错了),成功上传user_login_success
//不对这个方法插码
fun checkUserInfo(){
//检查用户名是否输入正确
var name = ...
if(name==null){
showLoginErrorToast("userName")
return
}
//检查密码格式是否输入正确
var password = ...
if(password == null){
showLoginErrorToast("userPassword")
return
}
//提交信息
commit(name,password)
}
//对这个方法插码
fun showLoginErrorToast(errorMsg:String){
toast(errorMsg)
//...json配置这个方法发送错误 event:"user_login_error_,${errorMsg}"
//这里将会插入Slotting.send("user_login_error_",errorMsg)
//在接收处做拼接,上传事件
}
//对这个方法插码
fun commit(name:Any,paddword:Any){
//做点什么...
//...
//...在json配置这个方法发送登录成功event:"user_login_success"
}
向这样的,在编写代码的时候,尽量做到,方法的职责单一。
当然如果埋点比较简单。你可以直接配置埋点在方法的第一行插入。
之后的改进和计划
虽然告别了手动修改源码进行埋点,但是编辑这个slotting.json
文件也是让人很棘手的事。
我计划着在之后学习一下编写IDEA插件。实现一个图形化修改slotting.json
的工具。这样埋点就更方便了。
当找到需要埋点的位置,在对应方法上鼠标右键,在菜单中增加一个slotting code
选项。点击之后读取slotting.json
配置
如果配置了信息,将会显示配置信息内容,并且代码左侧栏会有一个小tag标记这个方法记录了埋点。点击小tag跳转到对应slotting.json
配置信息的位置。
对于现阶段实现的功能,有一些埋点业务场景还不匹配。缺少对于逻辑埋点的配置。之后会想一下如何针对逻辑埋点进行设计。
还有就是对通用埋点,所有类似的方法或者父类方法,android sdk和第三方sdk中进行埋点的优化适配。
总的来说,我是比较喜欢使用我这种,通过一个文件进行无痕埋点。主要是对源代码0入侵,后期如果不需要这个统计平台了,也方便移除埋点。不用再一个一个class文件中去删除了。
添加依赖:
在第一版1.0.0完成之后,对插件的依赖做了调整。重新封装了Transform。注释说明和Log也做了调整,发布了1.0.1的优化。
project 的 build.gradle
buildscript {
repositories {
mavenCentral()
}
dependencies {
//添加插件
classpath 'io.github.dboy233:slotting-plugin:1.0.1'
}
}
有的项目可能是在setting.gradle
中设置
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
以前版本还是在allprojects
allprojects {
repositories {
mavenCentral()
}
}
app模块下的build.gradle
plugins {
id 'com.dboy.slotting'
}
dependencies {
//引入Api
implementation 'io.github.dboy233:slotting-api:1.0.1'
}
作者:年小个大