01
—
孔雀系统SDK是我们客户端埋点SDK的前身。在最开始设计时,孔雀系统主要是为应用提供各种样式的广告展示及广告的view、click等事件的统计。广告样式包括:开屏广告、提醒弹窗、回退弹窗广告、通用广告等,和现在第三方的广告SDK类似(banner、插屏、开屏、原生等),直接返回广告view,客户端负责展示就行。
后随着业务发展,为满足各APP自定义广告样式,逐渐剔除直接返回广告view的形式,而返回满足相应条件的广告数据,客户端自行解析数据展示广告。其优点:
后台数据模板配置方便,易于扩展;
投放条件多样,便于控制(有效期、渠道、城市、分版本、分应用投放等);
除上述广告业务的投放和拉取外,孔雀系统还负责数据的统计上报,最初只是简单的广告view、click、start、loading等次数统计,后逐渐演进增加活跃上报、基于事件的日志报文、自定义事件上报等多种形式的数据上报。为了更好的数据驱动业务,数据的全面性和正确性格外重要,这就驱使着统计业务的不断迭代和优化。下面从几个方面介绍埋点统计项目的实践和演进。
02
—
概念
埋点是什么?埋哪些点?埋点的形式都有哪些?有哪些区别和优缺点?
代码埋点:是指在某个事件发生时调用数据收集接口进行数据上报。
全埋点:又叫无埋点,指的是将网页或App内产生的所有的、满足某个条件的行为,全部上报到后台服务器。
可视化埋点:是指通过可视化工具配置采集节点,在App或网页解析配置查找节点,监听节点产生的事件并上报。
优缺点
现在已经有多家SDK支持上述埋点方式中的一种或全部,如Mixpanel、Sensorsdata、TalkingData、GrowingIO、Umeng Analytics等,其中Mixpanel和Sensorsdata都已开源。这样我们在封装自己的埋点SDK时可以参考其比较好的解决方案及了解相关实现原理。
03
数据展示:呈现一个可视化的页面,反馈相应的数据供参考。
assert断言字段结构。
另外,数据的传输过程也有一些需要注意的:
容错:数据若是上传失败,要保存于数据库中,避免丢失。
前两步是涉及到客户端,后续一般在服务端处理,不做介绍。
04
我们早期的埋点相对比较简单,一般集成统计SDK(友盟、talkingdata等),按照相应SDK初始化,加入页面的统计埋点。遇到一些复杂的业务,使用SDK的自定义埋点即可(比如友盟的计数和计算事件)。
这种方式的好处是,简单方便,维护成本低,不需要自己定义统计的数据格式、上传报文等,SDK中都封装好了直接用即可。对一些小型的APP,个人觉得已经足够了。
缺点是数据不透明,集成方无法获得上传报文。
peacock中的埋点统计,经历过下面几个阶段:
自定义事件上报
05
我们知道平时统计的DAU(日活)、DNU(日新增)、MAU(月活)、留存等,都是通过设备指纹去统计的,设备指纹最关键的就是要保证设备的唯一性。我们在采取业界普遍做法的基础上,采取了以下方式来实现:
先从SP(SharedPreferences)缓存中获取,若获取到直接使用;若获取不到,则从Sdcard文件中尝试获取,若获取到返回并写到SP文件中;若获取不到,则通过系统原生方法获取,存储到SP和Sdcard文件中。即将值存储于SP和Sdcard文件中,获取优先级SP、Sdcard及原生方法,最大限度保证数据的唯一性。
ETADLayout用于统计条目,在需要统计的条目最外层加上,不影响内部布局结构。内部提供多个public方法,方便开发者添加需要统计的信息,其中 setAdEventData 方法是必须要调用的。
ETADUtils是一个工具类,viewAllETADLayouts方法会循环获取ViewGroup里所有在统计区间内的ETADLayout对象,并且调用ETADLayout对象的统计方法。使用者在适当时机调用该方法即可。
例如listview在滑动停止时统计当前可见条目。
当ListView滑动停止时,调用ETADUtils的viewAllETADLayouts方法
listview、recyleview在滑动停止时统计,快速滑动时不统计;统计顶部及底部位置传入。
全埋点需要自动采集,因此针对页面、控件需要生成对应ID,该ID需具备【唯一】且【稳定】,即不会随意改变。
这个就较为简单,一般类名就能够满足(除非主动修改相应类名)。在 Android 中,页面有两种类型 Activity 和 Fragment,Fragment 可以镶嵌在不同的 Activity 内,因此两者的 ID 定义规则有些不同:
Fragment,ID 规则为 ActivityClassName[FragmentClassName]|额外参数。
相较于页面ID,控件ID的定义就相对比较复杂些。首先控件的R.id无法满足,因为编译原因这个id不是固定不变的,在资源发生变化时此id可能不同,不满足稳定的条件,此方案不可行。以下两种方案:
使用控件的id名称
一般控件的id名称和类型很少会改变,除非页面布局重构。使用控件的id名称能最大限度的保证其唯一和稳定。但不同页面控件id可能一样,所以还需用页面id做区分。
根据上述路径生成规则,对于Button而言,其路径为:FrameLayout[0]/LinearLayout[1]/Button[0]。同样不同的页面此路径可能一样,需用页面id做区分。
规则:页面id+控件布局路径
对于控件的自动化埋点,在有了控件ID之后,只要在其点击或长按的地方进行统计即可。而在Android中,控件的点击和长按都有比较标准的回调函数,在其回调处调用SDK中封装的相关方法,传入view的相关参数即可。而其难点在于如何将此方法插入到相应回调中,下面介绍一种实现方式。
AOP
AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。 AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。 利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。 简而言之,AOP是可以通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。 |
AspectJ
* JPoint:代码切点(就是我们要插入代码的地方) * Aspect:代码切点的描述 Pointcut:描述切点具体是什么样的点,如函数被调用的地方( Call(MethodSignature) )、函数执行的内部( execution(MethodSignature) ) Advice:描述在切点的什么位置插入代码,如在Pointcut前面( @Before )还是后面( @After ),还是环绕整个Pointcut( @Around ) |
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
/**
* android.view.View.OnClickListener.onClick(android.view.View)
*/
@Aspect
public class ViewOnClickListenerAspectj {
/**
* 埋点的具体实现
*/
private void doAOP(final JoinPoint joinPoint) {
}
/**
* 支持 butterknife.OnClick 注解
*/
@Pointcut("execution(@butterknife.OnClick * *(..))")
public void methodAnnotatedWithButterknifeClick() {
}
@After("methodAnnotatedWithButterknifeClick()")
public void onButterknifeClickAOP(final JoinPoint joinPoint) throws Throwable {
try {
if (AnalyticsDataAPI.sharedInstance().isButterknifeOnClickEnabled()) {
doAOP(joinPoint);
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* android.view.View.OnClickListener.onClick(android.view.View)
*
* @param joinPoint JoinPoint
* @throws Throwable Exception
*/
@After("execution(* android.view.View.OnClickListener.onClick(android.view.View))")
public void onViewClickAOP(final JoinPoint joinPoint) throws Throwable {
doAOP(joinPoint);
}
/**
* android.view.View.OnLongClickListener.onLongClick(android.view.View)
*
* @param joinPoint JoinPoint
* @throws Throwable Exception
*/
@After("execution(* android.view.View.OnLongClickListener.onLongClick(android.view.View))")
public void onViewLongClickAOP(JoinPoint joinPoint) throws Throwable {
}
}
这段Aspect代码定义:在view的onClick方法后插入doAOP(joinPoint);代码进行埋点上报。其中上述也支持onClick的Butterknife依赖注入方式。
2、其次使用ajc编译器向源代码中“织入”Aspect代码
这段Aspect代码定义:在view的onClick方法后插入doAOP(joinPoint);代码进行埋点上报。其中上述也支持onClick的Butterknife依赖注入方式。
2、其次使用ajc编译器向源代码中“织入”Aspect代码
在 project 级别的 build.gradle 文件中添加依赖:
//aop全埋点需要
implementation 'org.aspectj:aspectjrt:1.8.9'
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}
JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)
MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}
public void onClick(View v) {
JoinPoint var2 = Factory.makeJP(ajc$tjp_0, this, this, v);
try {
if (v.getId() == 2131165324) {
Log.i("MainActivity", "tv_test onClick");
} else if (v.getId() == 2131165226) {
Log.i("MainActivity", "btn_test onClick");
this.startActivity(new Intent(this, TestActivity.class));
}
} catch (Throwable var5) {
ViewOnClickListenerAspectj.aspectOf().onViewClickAOP(var2);
throw var5;
}
ViewOnClickListenerAspectj.aspectOf().onViewClickAOP(var2);
}
AspectJ的基本用法就是这样,理论上是可以对任何方法进行替换的,比如TabHost、RadioGroup等控件。
目前我们最新版本的统计埋点SDK就是基于此方式实现的,在编译时对字节码进行修改,插入事件上报代码。
网易HubbleData之Android无埋点实践
06
从最早的手动埋点到现在的部分自动埋点SDK,埋点统计在慢慢迭代优化,使埋点的工作能更方便更全面。但即使这样,手动埋点的工作还是无法完全被替换,我们应该根据业务特点相结合使用,在一些相对稳定的页面控件,使用自动埋点;对一些业务变动频繁的则可以使用手动埋点。
未来还可以搭建可视化埋点平台,这样能够实现动态的按需埋点,使埋点平台更完善。
作者 | 李恒