背景
线上问题无法定位?无法复现?无痕埋点技术采用非入侵式对java字节码进行处理,达到对页面事件和应用事件进行追踪。传统埋点方案都是在某个事件发生时调用SDK里面相应的接口发送埋点数据,百度统计、友盟、TalkingData、Sensors Analytics等第三方数据统计服务商大都采用这种方案,这种方案优点是使用者控制精准,可以自由地选择什么时候发送数据。但是缺点也很明显,就是开发及测试代价大;需要等待APP更新。可视化埋点方案是通过可视化工具配置采集节点,在Android端自动解析配置并上报埋点数据,从而实现所谓的自动埋点,代表方案是已经开源的Mixpanel。而无痕埋点它并不是真正的不需要埋点,而是Android端自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用数据,代表方案是国内的GrowingIO。特点如下:
- 跟踪所有页面事件
- 跟踪应用使用情况
- 跟踪列表项position事件
- 跟踪所有点击事件
- 所有事件额外添加运行时数据
- 日志保存本地&上传后台进行数据分析
- 给网络传输和耗电等性能带来更大的负载
架构设计
- 底层采用asm库对字节码进行操作,编写gradle插件。
- 对页面层生命周期处理,多fragment处理,列表项定位,view的唯一path定义。
- 以json输出到本地或者上传至服务器进行统计和分析。
View ID
SDK内部在自动收集控件数据时,需要将界面上的任何一个View与其他View区分开来。这就需要为界面上的每一个控件分配一个唯一的ViewID。此ViewID除了具有区分性,还需要具有一致性,即同一个View无论界面布局如何动态变化,或者说多次进入同一页面,此ViewID理论上保持不变。
View中可以找到的特征信息:
- Id: 静态整数。在编译期,aapt会生成R类,其中包含所有资源ID。
- Resource Id:开发者操作控件的唯一标识。一般由开发者在布局文件中指定android:id,通过findViewById找到View。
- Class Name:View所属的Class,例如TextView、LinearLayout、ListView、ViewPager等。
这些特征信息中的Id如果能够使用,是可以直接用作ViewID的,但是,从aapt生成id的原则来看,不同版本相同的resource Id对应的整数Id 是有可能不一样的,所以没有办法使用Id来唯一标识。Resource Id是开发者定义的View标识,对于有Resource Id 的View可以说具备了唯一标识,那么没有Resource Id的View,我们考虑通过一个index属性来区分,index属性可以取每个控件所属父组件的index(也即每个控件是其父控件的第几个孩子),并逐级向上遍历找到根节点,最后形成一个View Path即可用来唯一地标识这个View。
View Path
打开Android Studio选择Tools=>Layout Inspector工具,可以查看指定应用界面的ViewTree结构。
通过上述分析,我们得到一条View Path:获取每个控件自身的ID、类名、Resource Id以及位于所属父组件的Index等特征信息,并逐级向上遍历找到根节点。并结合该View所在的页面信息,我们得到ViewID的构造形式如下: - page: ActivityName
- path: view在控件树中的全路径,按照如下形式进行拼接,其中index为当前view所属父组件的index,id为编写布局文件时的android:id属性值,有则拼接,且index固定为0,无则不拼接。
1 | parent1[index]#id/parent2[index]#id/.../view[index]#id |
注:从DecorView到ContentFrameLayout这一段的path都是相同的,可以省略掉。
AdapterView
AdapterView的派生类均可通过getPositionForView获取position。1
index = position = ((AdapterView) group).getPositionForView(child);
作为AdapterView的派生类之一,ExpandableListView因为涉及到groupPosition和childPosition,因此需要特殊处理。在构造ViewID时,将能够采集到的position信息都添加到View Path中,具体策略如下:
- 先将ExpandableListView作为普通AdapterView计算position
- 列表Item为header元素,View Path中添加[header:position]
- 列表Item为footer元素,footer的index需要额外计算,计算公式如下,View Path中添加[footer:footerIndex]
1 | footerIndex = position - (expandableListView.getCount() - expandableListView.getFooterViewsCount()); |
- 列表Item为组元素,View Path中添加[group:groupPosition]
- 列表Item为组内元素,View Path中添加[group:groupPosition,child:childPosition]
涉及到的api接口如下:1
2
3
4
5((AdapterView) expandableListView).getPositionForView();
public long getExpandableListPosition(int flatListPosition);
public static int getPackedPositionType(long packedPosition);
public static int getPackedPositionGroup(long packedPosition);
public static int getPackedPositionChild(long packedPosition);
RecyclerView
RecyclerView的情形比较简单,可通过调用getChildPosition和getChildAdapterPosition获取position。1
2
3@Deprecated
public int getChildPosition(View child);
public int getChildAdapterPosition(View child);
ViewPager
ViewPager可通过调用getCurrentItem获取position。1
public int getCurrentItem();
Fragment处理
在ViewID优化中,我们讲到Fragment节点的优化时,提到可通过重写Fragment的几个与生命周期相关的函数监听Fragment生命周期。这个过程除了使用代码埋点,也可借助插件自动完成:扫描class文件,定位Fragment的几个与生命周期相关的函数,自动插入代码。
- 目标函数(方法):
1 | onResume()V |
- 对app中指定包进行扫描,筛选出所有父类为下列其中之一的子类。以下是Fragment及系统内置的几个常见的Fragment派生类。
1 | android/app/Fragment |
View事件
我们在目标View的事件响应函数中插入SDK数据搜集代码,即可实现对该类型View的监控。例如,在Button的点击事件响应函数onClick中插入SDK数据搜集代码后,当Button被点击,便会执行到onClick中的SDK数据搜集代码,从而实现Button点击事件的自动搜集。目标事件响应函数(方法):1
2
3
4
5
6
7
8
9
10onClick(Landroid/view/View;)V
onClick(Landroid/content/DialogInterface;I)V
onItemClick(Landroid/widget/AdapterView;Landroid/view/View;IJ)V
onItemSelected(Landroid/widget/AdapterView;Landroid/view/View;IJ)V
onGroupClick(Landroid/widget/ExpandableListView;Landroid/view/View;IJ)Z
onChildClick(Landroid/widget/ExpandableListView;Landroid/view/View;IIJ)Z
onRatingChanged(Landroid/widget/RatingBar;FZ)V
onStopTrackingTouch(Landroid/widget/SeekBar;)V
onCheckedChanged(Landroid/widget/CompoundButton;Z)V
onCheckedChanged(Landroid/widget/RadioGroup;I)V
对app中指定包进行扫描,筛选出实现了目标接口的类,在目标方法中添加数据采集代码
GradlePlugin
新建APMPlugin继承Transform实现Plugin1
2
3
4
5
6
7
8
9
10@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
def startTime = System.currentTimeMillis()
log("start:" + startTime.toString())
inputs.each { TransformInput input ->
handleDirectoryInputs(input, outputProvider)
handleJarInputs(input, outputProvider)
}
log("end:" + (System.currentTimeMillis() - startTime).toString())
}
ASM API
添加方法调用
新增Activity的onCreate的方法,并添加插件onActivityCreate方法调用,如果onCreate方法存在,直接调用插件方法:APM.onActivityCreate(activity);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17/**
* 添加调用Activity.onCreate方法
* @param classVisitor
* @param superName
*/
static void insertActivityOnCreate(ClassVisitor classVisitor, String superName) {
MethodVisitor mv = classVisitor.visitMethod(Opcodes.ACC_PROTECTED, "onCreate", "(Landroid/os/Bundle;)V", null, null)
mv.visitCode()
mv.visitVarInsn(Opcodes.ALOAD, 0)
mv.visitVarInsn(Opcodes.ALOAD, 1)
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, "onCreate", "(Landroid/os/Bundle;)V", false)
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, APMClassVisitor.APM_PLUGIN_INVOKE_HANDLER, "onActivityCreate", "(Landroid/app/Activity;)V", false);
mv.visitInsn(Opcodes.RETURN)
mv.visitMaxs(2, 2)
mv.visitEnd()
}
点击事件处理
对View的onClck方法进行代码插入,调用库中的onCreate方法:APM.onClick(view)1
2
3
4
5
6
7
8/**
* 添加View点击事件处理
* @param mv
*/
void handleViewOnClickEvent(MethodVisitor mv) {
mv.visitVarInsn(Opcodes.ALOAD, 1)
mv.visitMethodInsn(Opcodes.INVOKESTATIC, APM_PLUGIN_INVOKE_HANDLER, "onClick", "(Landroid/view/View;)V", false);
}