Android 数据埋点的重新思考
已经有很久没有更新文章了,我想做过客户端开发的应该都有干过数据埋点的事吧,其实我之前一直在思考怎么让数据埋点更优雅,好在最近有了新的想法,所以分享出来给大家一起参考参考。
有人说我的之前文章很些难懂,需要一些知识基础,所以这次我把涉及的知识点先说明一下:
- 了解 Gradle 自定义插件的 Transform
- 了解 AOP 概念
- 最好有使用过 Mocktio 或类似的其他测试框架
本文目录
1. 目前大部分数据埋点的实现方式
2. 它们各自的优点和缺点
3. 新的思考与探索
4. 着手实现
目前大部分数据埋点的实现方式
首先,先分享一下目前我所知道的,绝大多数的数据埋点的实现方式,无外乎两种:
其一,是直接写在源代码中,也许这些代码经过了封装,可能只有一行,但总归是要写在源代码中的
其二,使用 AOP,利用 AspectJ 工具将数据埋点的字节码插入到源代码中,我本身是非常看好这种办法,但真正使用起来,却的确有诸多不方便的地方。
它们各自的优点和缺点
先说传统的直接写入源码的方式,这的确是最容易最省力的实现方法了,直接在源码中我们可以非常轻松的拿到上下文信息,包括内部对象等等,但同时缺点也很明显,这样实现的埋点代码分布分散,不易统一管理,试想一下万一想移除或者更换埋点的实现,工作量不可谓不小。
所以很快就有了第二种的实现了,利用AOP,我们可以把代码动态的插入的想要插入的地方,这样我们可以把所有的数据埋点代码写在统一的一个地方,待到编译时自动的插入到它们本该存在的位置。但你真的这样做了,你就会发现,有时候数据埋点是需要上下文信息的,比如当前类中的一个私有变量的值,比如某个函数的参数是什么等等。对于这个问题,AspectJ 也有其解决方案,它提供了某些语法来获取之:
@AfterReturning(
pointcut="execution(* com.abc.service.*.access*(..)) && args(time, name)",
returning="returnValue")
在我看来,这虽然能解决问题,但是明显对于没有接触过 AspectJ 的人语法来说,这无法需要增加一些前期学习的成本,在团队开发中,这种成本愈发显得格外的大。而且,不仅如此,我们还可以注意到,这些语法最终需要以字符串的形式来编写,这就是一个很大的问题了,首先我们怎么来确保写出的语法表达式是正确的呢?难!其次,如果对应的参数名,方法名,甚至类名有重命名或包位置移动了,谁来确保该代码还能正常工作呢?更难! 而且错误的表达式并不会导致整个项目的编译失败,所以非常容易发生这样的囧境。再三考虑下,这样的方案并不能算完美,甚至从某些方面来说,是不稳定的。
新的思考与探索
现在我们该好好想想我们要的是什么了:
- 数据埋点代码可以跟源代码隔离
- 数据埋点代码可以访问到方法参数或类的内部成员变量
- 方法参数或类的内部成员变量的重命名不会影响到数据埋点代码
前面说到的 AspectJ 能做的第一点和第二点,但满足不了第三点。究其原因,还是因为其使用了字符串类型的语法匹配规则,这样建立起来的关系是软并且薄弱的。那用什么来替换字符串呢?这个问题我想了很久...
直到一天我看到一段 Unit Test 代码时,才突然有了灵感
class TestClass {
int getNumber() {
return 0;
}
}
// ...
void test() {
TestClass mockClass = Mockito.mock(TestClass.class);
doAnswer(invocation -> {
System.out.println("测试");
return -1;
}).when(mockClass).getNumber();
}
UT 的框架其实是个很好的实现,它既和源代码直接有隔离,又清晰的就定位到了 TestClass.getNumber() 方法,试想一下,如果我们的数据埋点代码可以这么写:
void doTrackForMainActivity() {
insertToFirst(invocation -> {
System.out.println("数据埋点代码");
}).when(MainActivity.class).onCreate();
}
这么看着确实不错,但很快我就发现,这样的话,只能访问到 MainActivity 的公有方法,这想在私有方法中埋点的需求就完成不了。
所以尽管我很喜欢这个方案,但最终还是不得不放弃它。
看起来要想在私有方法中插入数据埋点,还是只能通过字符串来指明方法了,那就应该想办法自动生成这些字符串,像这样:
- Segment.kt
fun doTrackForMainActivity() {
insertToFirst(object : TrackTask {
override fun doTrack(target: Any, methodArgs: Array<out Any>?) {
System.out.println("数据埋点代码");
}
}).with(MainActivitySt.`fun$onCreate$Bundle`)
}
- MainActivity.kt
class MainActivity : Activity() {
private val field = "I'm private field"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
- MainActivitySt.kt
// Auto-Generated by Segment-plugin.
// method num: 1
public final class MainActivitySt {
public static final TargetMethod fun$onCreate$Bundle = new TargetMethod("com.segment.demo.MainActivity", "onCreate", Arrays.asList(android.os.Bundle.class));
}
像这样的曲线救国好像也不错...还有最后一个问题,在数据埋点中对类中私有内部成员的使用。
对于这个问题,我们也可以同样使用自动生成的对象来处理:
- 改造后的 MainActivitySt.kt
// Auto-Generated by Segment-plugin.
// method num: 1
// field num: 1
public final class MainActivitySt {
public static final TargetMethod fun$onCreate$Bundle = new TargetMethod(MainActivity.class, "onCreate", Arrays.asList(android.os.Bundle.class));
public static final TargetClassField<java.lang.String> field$field = new TargetClassField<>(MainActivity.class, java.lang.String.class, "field");
}
之后,我们的数据埋点就可以这样写了:
fun doTrackForMainActivity() {
insertToFirst(object : TrackTask {
override fun doTrack(target: Any, methodArgs: Array<out Any>?) {
println("数据埋点代码")
println(MainActivitySt.`field$field`.get())
}
}).with(MainActivitySt.`fun$onCreate$Bundle`)
}
到这里应该算是完成了我们整个数据埋点框架的最初想法和大概思路了。
着手实现
在刚刚重新的思考与设计中,我们用到的几个比较关键的地方分别有:
-
需要在编译期对将数据埋点的代码插入到源代码的字节码中
-> 可以通过自定义 Gradle 插件的 Transform 实现 -
需要找到我们的数据埋点代码
-> 可以通过使用注解标记我们的数据埋点代码 -
对目标类生成一个含有其所有方法和私有成员的帮助类
-> 有很多这样的工具可以实现,比如 Javapoet -
字节码编辑/插入
-> 同样有很多工具可以使用,笔者喜欢使用 Javassist
到这里,剩下的就只有耐心的码代码以及部分细节的处理了。
结语
写这篇文章只是想跟大家分享下这个思路,有兴趣的同学可以按照自己的设计来实现下,我也会把我自己实现的代码整理出来,之后会开源出来供大家参考。