版权归属于微信公众号文章网易HubbleData之Android无埋点实践
文末有彩蛋哦☺
1 背景
网易HubbleData是一个洞察用户行为的数据分析系统,提供一套完整的数据解决方案。一个典型的数据平台,对于数据的处理,是由如下的5个步骤组成的:
其中,第一个步骤,也即数据采集是最核心的问题。网易HubbleData支持全端数据采集,包括iOS、Android、JS、JAVA等多个平台。本文主要讨论Android平台的数据采集方案。业内各家公司从不同角度,提出了多种技术方案,这些方案大体上可以归为三类:
(1) 代码埋点:在某个事件发生时调用SDK里面相应的接口发送埋点数据,百度统计、友盟、TalkingData、Sensors Analytics等第三方数据统计服务商大都采用这种方案。
- 优点:使用者控制精准,自由地选择什么时候发送数据;使用者控制精准,自由地选择什么时候发送数据。
- 缺点:开发及测试代价大;需要等待APP更新。
(2) 可视化埋点:通过可视化工具配置采集节点,在Android端自动解析配置并上报埋点数据,从而实现所谓的自动埋点,代表方案是已经开源的Mixpanel。
- 优点:解放开发人员,解决了代码埋点代价大的问题;通过服务端配置埋点,解决等待APP更新的问题。
- 缺点:覆盖功能有限,只能配置一些公共属性;埋点只能从当前时刻开始,无法“回溯”。
(3) 无埋点:它并不是真正的不需要埋点,而是Android端自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用数据,代表方案是国内的GrowingIO。
- 优点:解放开发人员,解决了代码埋点代价大的问题;解决了等待APP更新和数据“回溯”的问题;可以自动获取很多启发性的信息。
- 缺点:覆盖的功能有限,不能灵活地自定义属性;给网络传输和耗电等性能带来更大的负载。
网易HubbleData的Android SDK早已有之,公司内部诸如考拉、易信、LOFTER、美学、漫画等多款产品都已接入使用。原有Android SDK采用手动代码埋点的方案,主要关注的是事件模型、埋点接口、上报策略等问题。整体架构如下图所示:
代码埋点虽然使用起来灵活,但是开发成本较高,并且一旦上线就很难修改。参考业界先进方案并结合网易公司内部产品的埋点需求,网易HubbleData的Android SDK在代码埋点整体架构的基础上新增了无埋点功能,本文主要针对网易HubbleData在Android SDK中无埋点实践进行简单分享。
2 无埋点关键技术
2.1 View的唯一ID
2.1.1 如何唯一地标识一个View?
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。
2.1.2 ViewID构造
通过上述分析,我们得到一条View Path:获取每个控件自身的ID、类名、Resource Id以及位于所属父组件的Index等特征信息,并逐级向上遍历找到根节点。
并结合该View所在的页面信息,我们得到ViewID的构造形式如下:
sha-256(page : path)
- page: ActivityName
- path: view在控件树中的全路径,按照如下形式进行拼接,其中index为当前view所属父组件的index,id为编写布局文件时的android:id属性值,有则拼接,且index固定为0,无则不拼接。
parent1[index]#id/parent2[index]#id/.../view[index]#id
简单示例如下:
2.1.3 ViewID优化
考虑到在实际布局中有可能存在一些动态插入、删除的控件,或者说控件被复用,都可能引起View Path的变化,从而导致ViewID不唯一。为了保证ViewID的一致性,我们从以下几个方面着手,对ViewID进行了一定程度地优化。
(1) Index
如上图所示,当页面布局发生动态变化时,比如说删除一个子view,其他子view所属父组件的index也可能会改变,为此,我们对view所属父组件的index进行改造,通过如下算法对index赋值:
每个ViewGroup下的所有View作为一个数组,从0开始;
每个ViewGroup下的所有View先按照Class分类,然后再把每个类型中的数据按照数组的方式,从0开始;
每个ViewGroup下的所有View先按照Class分类,再确认是否有Resource Id,如果存在,则index为0,否则index为所属Class类型数组下的序号。
该优化处理对所有View适用。优化后效果如下:即动态改变一些控件后,只会影响同类型的控件,其他类型控件的index不受影响,也即ViewID不受影响。
(2) 可复用View
先来看一个应用场景:
如图所示,当ListView上滑时,屏幕下方即将显示的<元素6>其实复用了屏幕上方即将滑出的<元素0>,也就是说<元素6>与<元素0>的index均为0,在这种情况下,我们无法通过前述index的定义来区分这两个列表Item。
所幸,针对这种情况,我们可以用position的取值进行区分,也就是令index = position。
通过实践发现,发生上述复用情形的View主要有以下几类:AdapterView、RecyclerView和ViewPager,其api都提供了获取position的接口。
a. AdapterView
AdapterView的派生类均可通过getPositionForView
获取position。
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]
// Calculates the footer index among footers; // For instance, there are five footers, so the footer index ranges from zero to four. // The first footer index is zero. footerIndex = position - (expandableListView.getCount() - expandableListView.getFooterViewsCount());
列表Item为组元素,View Path中添加[group:groupPosition]
列表Item为组内元素,View Path中添加[group:groupPosition,child:childPosition]
涉及到的api接口如下:
((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);
示例如下:
b. V7-RecyclerView
RecyclerView的情形比较简单,可通过调用getChildPosition
和getChildAdapterPosition
获取position。
@Deprecated
public int getChildPosition(View child);
public int getChildAdapterPosition(View child);
c. V4 - ViewPager
V4 - ViewPager可通过调用getCurrentItem
获取position。
public int getCurrentItem();
(3) Fragment节点
主流App的主页均是采用如图所示的Tab切换Fragment的设计。在这种情形下,如果主页内嵌的Fragment采用“懒加载”方案,则底部Tab的点击顺序决定了该Tab对应Fragment的初始化顺序,从而导致Fragment所属父组件的index动态变化。
也就是说,Fragment初始化顺序影响ViewID。而前述Index优化方案并不能解决这一问题。
Fragment节点特殊处理
针对Fragment初始化顺序影响ViewID的问题,我们采用的解决方案是:
如果能够获取到Fragment实例的类名,则使用Fragment实例的类名替换View Path中的Fragment,并设置[index]为特殊标记[-]。例如:使用控件篇Tab对应的Fragment实例ControlSetFragment以及特殊标记[-]替换原View Path中的Fragment[3]
如何获取Fragment实例?
采用代码埋点或后续即将讲到的插件埋点,在Fragment各实例类中重载下面的几个方法,并在各方法中插入SDK提供的方法调用,从而实现Fragment生命周期监听:
@Override
public void onResume() {
super.onResume();
DATracker.getInstance().onFragmentResume(this);
}
@Override
public void onPause() {
super.onPause();
DATracker.getInstance().onFragmentPause(this);
}
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
DATracker.getInstance().setFragmentUserVisibleHint(this, isVisibleToUser);
}
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
DATracker.getInstance().onFragmentHiddenChanged(this, hidden);
}
通过上述调用,当Fragment生命周期变化时,SDK能够记录当前活跃的所有Fragment。当某个活跃的Fragment上的控件被点击了,SDK构造该控件的ViewID时,会自动将该Fragment实例的类名写入View Path。
V4 - ViewPager内嵌Fragment
这里要说明的是,ViewPager内嵌的View不仅是可复用的,同时,由于其“懒加载”、“预加载”机制,其内嵌View的加载顺序也是动态的。特别地,当ViewPager内嵌Fragment时,按照前述对Fragment节点的处理,我们会使用Fragment实例的类名替换View Path中的Fragment,并设置[index]为特殊标记[-]。之所以将[index]设置为特殊标记[-],是因为Fragment动态加载导致index不可靠,而ViewPager中内嵌的Fragment却可以调用ViewPager的getCurrentItem拿到position作为index,这种情况下,是可以将index的值添加到View Path中的。
2.2 无埋点实现
通过前述方案,我们可以使用ViewID唯一地标识屏幕上的控件。那么,比如一个Button,当这个Button被点击了,SDK又是如何捕捉到这一点击事件,并且拿到Button实例的呢,也就是如何实现自动埋点的呢?这里,我们提供了两种方案。
2.2.1 代理监听
原理
在应用程序中,辅助功能事件是用户与可视界面组件交互的消息。这些消息是由辅助功能服务处理。辅助功能服务使用在这些事件中的信息产生附加的反馈和提示。Android 4.0(API14)及更高版本上,辅助功能方法属于View类的一部分,也是View.AccessibilityDelegate的一部分。其中可用于实现无埋点的方法如下:
sendAccessibilityEvent()
当用户在一个视图上操作时调用此方法。事件按照用户操作类型分类,涵盖以下事件类型:
- TYPE_VIEW_CLICKED
- TYPE_VIEW_LONG_CLICKED
- TYPE_VIEW_FOCUSED
- TYPE_VIEW_SELECTED
- TYPE_VIEW_HOVER_ENTER
- TYPE_VIEW_SCROLLED
- TYPE_VIEW_TEXT_CHANGED
- ...
采用辅助功能事件实现无埋点,简单来讲,就是给View设置AccessibilityDelegate,当View产生了click,long_click等事件时,会在响应原有的Listener方法后,发送消息给AccessibilityDelegate,然后在sendAccessibilityEvent方法下搜集自动埋点事件。
private class TrackingAccessibilityDelegate extends View.AccessibilityDelegate {
public TrackingAccessibilityDelegate(ViewNode viewNode, View.AccessibilityDelegate realDelegate) {
mViewNode = viewNode;
mRealDelegate = realDelegate;
}
public View.AccessibilityDelegate getRealDelegate() {
return mRealDelegate;
}
@Override
public void sendAccessibilityEvent(View host, int eventType) {
if (eventType == mEventType && host == mViewNode.getView()) {
...
// 自动埋点
fireEvent(mViewNode, type);// sends tracking data
}
// 响应原AccessibilityDelegate
if (null != mRealDelegate) {
mRealDelegate.sendAccessibilityEvent(host, eventType);
}
}
private View.AccessibilityDelegate mRealDelegate;
private ViewNode mViewNode;
}
设置代理的时机
实现Application.ActivityLifecycleCallbacks,用来监听Activity生命周期,当监听到某个Activity进入onResumed状态时,通过以下方式获取RootView:
mViewRoot = this.mActivity.getWindow().getDecorView().getRootView()
从RootView出发深度优先遍历控件树,为满足特定条件的View设置代理监听。
界面动态变化怎么办?
实现ViewTreeObserver.OnGlobalLayoutListener,用来监听界面变化。当监听到界面变化时,重新遍历控件树,为满足特定条件的View设置代理监听,已经设置过代理的View不再重复设置。
界面的监测操作需要放在界面主线程中,起初我们担心这样会对应用本身的界面交互产生影响,所幸,经过实际测试,这样实现是可行的,界面交互感知不到任何影响。
监控哪些View?
-
AutoCompleteTextView(搜索框)
添加 TextWatcher 监听文本变化,2s 后延时发送文本输入结果
-
AbsListView(列表)
OnItemClickListener 存在 - 对原有OnItemClickListener作一层包装,在响应原有的Listener方法后,搜集自动埋点事件。
-
一般View
hasOnClickListeners 或 isClickable 返回 true - 设置AccessibilityDelegate
2.2.2 gradle插件
原理
试想一下我们代码埋点的过程:首先定位到事件响应函数,例如Button的onClick函数,然后在该事件响应函数中调用SDK数据搜集接口。下面,我们介绍使用gradle插件自动在目标响应函数中插入SDK数据搜集代码,达到自动埋点的目的。
我们的gradle插件采用 Android gradle 插件提供的最新的Transform API,在Apk编译环节中、class打包成dex之前,插入了中间环节,调用 ASM API对class文件的字节码进行扫描,当扫描到目标事件响应函数时,在函数头部或尾部插入SDK数据搜集代码。
监控哪些View?
我们在目标View的事件响应函数中插入SDK数据搜集代码,即可实现对该类型View的监控。例如,在Button的点击事件响应函数onClick中插入SDK数据搜集代码后,当Button被点击,便会执行到onClick中的SDK数据搜集代码,从而实现Button点击事件的自动搜集。
目标事件响应函数(方法):
- onClick(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中指定包进行扫描,筛选出实现了目标接口的类,在目标方法中添加数据采集代码。
例如,筛选出实现了
android/view/View$OnClickListener
接口的类,然后在onClick(Landroid/view/View;)V
方法中注入采集数据的代码。
目标效果:
public class MainActivity extends AppCompatActivity implements OnClickListener,
android.content.DialogInterface.OnClickListener,
OnItemClickListener,
OnItemSelectedListener,
OnRatingBarChangeListener,
OnSeekBarChangeListener,
OnCheckedChangeListener,
android.widget.RadioGroup.OnCheckedChangeListener,
OnGroupClickListener, OnChildClickListener {
public void onClick(View var1) {
PluginAgent.onClick(var1);
}
public void onClick(DialogInterface var1, int var2) {
PluginAgent.onClick(this, var1, var2);
}
public void onItemClick(AdapterView<?> var1, View var2, int var3, long var4) {
PluginAgent.onItemClick(this, var1, var2, var3, var4);
}
...
}
Fragment生命周期追踪
在ViewID优化中,我们讲到Fragment节点的优化时,提到可通过重写Fragment的几个与生命周期相关的函数监听Fragment生命周期。这个过程除了使用代码埋点,也可借助插件自动完成:扫描class文件,定位Fragment的几个与生命周期相关的函数,自动插入代码。
目标函数(方法):
- onResume()V
- onPause()V
- setUserVisibleHint(Z)V
- onHiddenChanged(Z)V
具体实现:
-
对app中指定包进行扫描,筛选出所有父类为下列其中之一的子类。以下是Fragment及系统内置的几个常见的Fragment派生类。
android/app/Fragment android/app/DialogFragment android/app/ListFragment android/support/v4/app/Fragment android/support/v4/app/DialogFragment android/support/v4/app/ListFragment
对这些Fragment子类的
onResumed
,onPaused
,onHiddenChanged
,setFragmentUserVisibleHint
方法的字节码进行修改,添加数据采集代码。
目标效果:
public class BaseFragment extends Fragment {
public BaseFragment() {
}
public void onResume() {
super.onResume();
PluginAgent.onFragmentResume(this);
}
public void onHiddenChanged(boolean var1) {
super.onHiddenChanged(var1);
PluginAgent.onFragmentHiddenChanged(this);
}
public void onPause() {
super.onPause();
PluginAgent.onFragmentPause(this);
}
public void setUserVisibleHint(boolean var1) {
super.setUserVisibleHint(var1);
PluginAgent.setFragmentUserVisibleHint(this, var1);
}
}
2.2.3 代理监听 vs gradle插件
插件埋点方案,发生在编译期,当目标事件响应函数被执行时,才会触发我们插入的代码主动搜集事件。除了消耗一点编译速度,应用运行期间基本不受影响。
代理监听方案,由于事先并不清楚用户会触发哪些交互事件,所以需要为所有可交互的View设置代理,涉及到控件树遍历,因此性能略逊于gradle插件方案。但好在控件树遍历消耗的时间是毫秒级的,不会影响界面交互。
下面总结一下这两种方案的优缺点。
(1) 代理监听方案
缺点:
- 遍历,被动等待被触发
- 拦截弹窗比较困难
- Fragment生命周期需手动拦截
优点:
- 对于可点击但又未设置点击监听器的View,可设置监听器
(2) gradle插件方案
优点:
- 无需遍历,主动触发事件
- 主动拦截弹窗(待扩展)
缺点:
- 目前只支持Gradle1.5+构建工具
3 总结与展望
以上就是网易HubbleData在Android端的无埋点实践中总结的重点难点。还有一些边边角角的点就不一一细述了。
当然,我们的无埋点方案也并不完美,还有一些未解决的问题。例如,ViewID的构造及优化方案并不能适用于所有情况;通过无埋点搜集的数据也仅限控件的一些固有属性,并没有搜集到更有价值的业务数据...
网易HubbleData也将持续跟进业界先进埋点技术,及时升级埋点方案。后续针对比较有意思的技术点,也会继续整理出来分享给大家。
如果对该项目感兴趣,可以联系 zhangdan_only@163.com ,欢迎一起研究。
预知更多,请猛戳⬇️
用于Android客户端无埋点数据采集的Gradle插件
网易HubbleData无埋点SDK在iOS端的设计与实现