鉴于日益强烈的精细化运营需求,网易乐得从去年开始构建大数据平台,<<无埋点数据收集SDK>>所以立项,用于向大数据平台提供全量,完整,准确的客户端数据.
<<无埋点数据收集SDK>>Android端从着手,到经历重构,逐步完善到如今已经有快一年的时间了.期间从开源社区以及同行中获得了一些颇有意义的技术参考,所以在这个SDK趋于完善的今天,咱们也考虑将这一路在技术上的探索经历和收获分享出来.javascript
以前关于Android端的<<无埋点数据收集SDK>>使用的技术,写了一篇文章Android AOP之字节码插桩,这个是Android端进行一切收集的起点,咱们就是用这个方法轻松拿到各类"Hook"点的.
本篇文章则接着讲一下关于收集SDK内部收集逻辑的一些关键技术.java
1、概述
1.1 SDK数据收集能力现状
1.2 关键技术点概述
2、View的惟一标识(ID)
2.1 调研
2.2 利用ViewTree构建ViewID
2.3 ViewPath的生成
2.4 ViewPath的优化
3、页面的划分
3.1 合理划分页面的重要性
3.2 Android中的页面
3.3 页面名组成
4、无需埋点轻松收集定制的业务数据
4.1 配置示例
4.2 无埋点收集流程
4.3 数据路径(DataPath)
5、结语android
本部分首先简要介绍一下咱们的收集方案目前能够收集到哪些数据,而后对于本文重点介绍的三个技术点进行概述.程序员
目前咱们的SDK进行数据收集时基本有两个能力:数组
a. 通用数据全量收集
通用数据指的是与业务无关的用户行为数据,不管是电商应用仍是社区应用,接入SDK后通用数据的收集上都是无差的,这些通用数据大体有:缓存
事件 | 描述 |
---|---|
冷启动事件 | App第一次启动时的,版本号、设备ID、渠道、内存使用状况,磁盘使用状况等信息 |
先后台事件 | App进入前台或者后台 |
页面事件 | 页面(Activity或Fragment)显示(Show)/隐藏(Hide) |
控件点击事件 | 某个控件(包括页面上控件和弹窗中控件)被用户点击 |
列表浏览事件[可选] | 某个列表的哪些条目被用户浏览了 |
位置事件[可选] | 上报用户地理位置信息 |
其它事件 | 省略描述 |
b. 业务相关数据需求经过下发配置进行无埋点定制收集
除了上述通用数据,与具体业务相关的数据收集。拿网易贵金属的首页举个例子:架构
假使须要在用户点击上图红框区域时,把“粤贵银”这个交易品的ID(或者下方显示的指数等,只要在内存中存在的数据均可以)一块儿报上来。
对于此种需求,数据收集SDK作到了无需埋点,不依赖开发周期,经过线上下发一些配置信息,便可即时进行数据收集。具体原理第四节叙述。app
a. View的惟一标识(ID),(详见本文第二节)
当咱们收集控件数据时碰到的第一个问题就是:如何把界面上的任何一个View与其余View区分开来.iphone
好比:某个Button被点击了
咱们在上报数据的时候须要把这个Button和其余全部控件(好比另外一个Button,另外一个ImageView等)区分开来,这样这条上报的数据才能表示"就是那个Button被点击了一下".ide
这就须要为界面上的每个控件生成一个惟一的ID. 此ID除了具备区分性,还须要用于一致性.一致性是同一个View不管界面布局如何动态变化,或者说屡次进入同一页面,此ID须要保持不变.
b. 页面的划分,(详见本文第三节)
除了Activity有些Fragment也须要看做页面,这就要求:
c. 无需埋点轻松收集定制的业务数据,(详见本文第四节)
如前面所述,默认状况下数据收集SDK会收集全量的用户交互数据,对于定制的业务收集需求,数据收集SDK也作到了无需代码埋点,经过线上下发一些配置进行即时收集.
用于区分界面上每一个View的ID? Android系统是否提供给了咱们这个ID?
确实,Android系统提供了一个ID,view.getId()便可得到一个int型的id用于区分View,可是这个ID由于如下两个缘由却并不能知足咱们的须要.
所以,咱们只能本身动手构建咱们的ID喽,怎么构建?答案是利用所属Page+ViewTree构建ViewID.
在Android的概念里,每一个Window(ActivityWindow/DialogWindow/PopupWindow等)上面都生长着一棵ViewTree.而屏幕中看到的各类控件(ImageView/Button等)都是这棵ViewTree上的节点.
有Android开发环境的同窗只须要打开AndroidDeviceMonitor-dump view hierarchy 就能够看到ViewTree的模样,以下图:
所以,咱们萌生出一个想法:
利用Page+ViewTree中的位置构建ViewID.
View在ViewTree中的位置主要用两点来肯定:
考虑这两个因素后,咱们定义一个ViewPath:
ViewPath:当前view到ViewTree根节点的一条路径,用于在ViewTree中惟必定位当前view。路径中的每一个节点包含两部分信息,即节点View类型信息,以及节点View在兄弟中的index。
以下图,是一个简单的ViewTree模型(简单到深度只有两层,每层只有两三个控件)
按照以前给的定义,上图中控件1,2,3,4的ViewPath以下
控件1ViewPath: RootView/LinearLayout[0] index为1表示此节点是兄弟节点中第一个控件
控件4ViewPath: RootView/LinearLayout[0]/ChildView1[0]
控件2ViewPath: RootView/RelativeLayout[1]
控件3ViewPath: RootView/LinearLayout[2]复制代码
上述给出的ViewPath中,每一个节点(除了首节点)有两部份内容:
这是最初的ViewPath,用ViewPath定位view,有两点特别重要:
按照这个最初的ViewPath定义在实践中还不能在一致性和区分度上知足咱们的需求,后面会对ViewPath进行优化。
上面咱们由构建ViewID的需求引出了ViewPath的定义,那么当交互事件(例如:按钮点击)发生时,咱们如何生成此控件的ViewPath?
如上一篇文章<
Monitor.onViewClick(view);复制代码
上面,入参view即为当前被点击的view,获取此view的ViewPath伪代码以下:
public static ViewPath getPath(View view) {
do {
//1. 构造ViewPath中于view对应的节点:ViewType[index]
ViewType=view.getClass().getSimpleName();
index=view在兄弟节点中的index;
ViewPath节点=ViewType[index];
}while ((view=view.getParent())instanceof View);//2. 将view指向上一级的节点
}复制代码
构造出来的ViewPath以下面例子所示:
DecorView/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]复制代码
a. 一致性优化1
情景:
在图2-2 ViewTree模型图中,若是像下面图中所示,在控件2和3中动态插入一个FrameLayout呢?
此时按照原始ViewPath的定义,咱们来看看控件3的ViewPath发生了哪些变化?
ViewTree动态变化前: RootView/LinearLayout[2]
ViewTree动态变化后: RootView/LinearLayout[3]复制代码
优化:
ViewPath节点中index的含义从“兄弟节点的第几个”优化为:“相同类型兄弟节点的第几个”
优化后,发生图2-3所示界面布局动态变化时,控件3的ViewPath变化为:
ViewTree动态变化前: RootView/LinearLayout[1] index为1表示此节点是兄弟节点中第二个LinearLayout
ViewTree动态变化后: RootView/LinearLayout[1]复制代码
能够看出,此处优化使控件3的ViewPath在ViewTree动态插入除了LinearLayout以外其它任何类型时都保持先后一致。
b. 一致性优化2
情景:
在图2-2 ViewTree模型图中,若是像下面图中所示,在控件2和3中动态插入一个LinearLayout时,控件3的ViewPath可否继续保持先后一致?
按照上述情景,控件3ViewPath的变化以下:
ViewTree动态变化前: RootView/LinearLayout[1] index为1表示此节点是兄弟节点中第二个LinearLayout
ViewTree动态变化后: RootView/LinearLayout[2] 前面插入一个LinearLayout致使此节点变为兄弟节点中第三个LinearLayout了复制代码
问题
上述情景指的实际上是一个问题:ViewTree中同类型兄弟节点动态变化(插入/移除/移位)影响ViewPath的一致性
- ViewPath节点中的index,在同类型(ViewType相同,例如都是LinearLayout)兄弟节点动态加入/删除时,当前节点的index没法在变化先后保持一致。
- “一致性优化1”中的优化能够抵御不一样类型兄弟节点的影响,却对同类型兄弟节点的影响迫不得已。
从ViewPath的定义上难以找到在同类型兄弟节点动态变化先后保持一致的方法,但咱们能够分析发生此种界面动态变化的情景:
2中所说“ListView等可复用View”形成的问题后面会有优化,此处针对1中的情景讨论。1中情景发生时以下图:
上图中FragmentA,FragmentB,FragmentC的顶层视图控件所有是LinearLayout(同类型),此时这三个Fragment加入的顺序将形成ViewPath在此处各类不一致,从而致使ViewPath在动态变化先后不能保持一致(如前面:ViewTree动态变化先后控件3ViewPath的变化所示)。
优化:
在ViewPath节点中,使用Fragment的名字替换ViewType
优化后,发生图2-4所示界面布局动态变化时,控件3的ViewPath变化为:
ViewTree动态变化前: RootView/FragmentB[0] index为0表示此节点是兄弟节点中第一个FragmentB
ViewTree动态变化后: RootView/FragmentB[0]复制代码
如上,这次优化使得,在顶层视图ViewType相同的Fragment动态添加/删除到ViewTree时,ViewPath在变化先后保持一致。
c. 针对可复用View的优化
情景
以最常使用的ListView为例,假设有一ListView满屏只显示3个条目,那么此ListView可能只有3个子控件(ItemView),而此ListView上滑以后能够显示100项内容。
这3个ItemView与100项内容是一对多的对应关系,并且映射并没有可靠规律。
此时,咱们但愿ViewPath能够区分这100项显示的内容条目,而非仅仅区分3个ItemView。
上面情景中的问题可用下图表达:
如上图中,内容条目1和4都是用itemView1来呈现的,按照以前的ViewPath定义,图2-5中各个内容条目的ViewPath以下:
内容条目1: ListView/ItemView[0] index为0表示此节点是兄弟节点中第一个ItemView
内容条目4: ListView/ItemView[0]
内容条目2: ListView/ItemView[1]
内容条目3: ListView/ItemView[2]复制代码
能够看出内容条目1和4的ViewPath区分不开。此种问题能够总结为:
显示内容与ViewTree中的控件不是一一对应的状况形成基于ViewTree的ViewPath区分度不够
- 可复用View,好比:ListView,RecyclerView,Spinner等,呈现出来子View的数目和实际子View的数目未必一致
- ViewPager设置缓存页面数为1,第二页显示时,第二个页面顶级View实际上是ViewPager的第一个ChildView。此种状况也会形成显示内容(第二页)与ViewTree中的控件(第一个ChildView)不对应的状况。
所以咱们对于ViewPath做以下优化:
ViewPath节点的index取内容的第几项,而非第几个ItemView。
优化:
优化后图2-5中各个内容条目的ViewPath以下:
内容条目1: ListView/ItemView[0] index为0表示此节点是ListView显示的第一个内容条目
内容条目4: ListView/ItemView[3]
内容条目2: ListView/ItemView[1]
内容条目3: ListView/ItemView[2]复制代码
可见,以前ViewPath没法区分的内容条目1和4如今能够区分开了。各类可复用View取内容的第几项的代码方法以下:
ListView,Spinner等AdapterView------------ListView.getPositionForView(itemView)
RecyclerView------------------------------------RecyclerView.getChildAdapterPosition(itemView)
ViewPager----------------------------------------ViewPager.getCurrentItem()复制代码
d. ViewPath起点优化
ViewPath从ContentView为起点,而非DecorView
- DecorView : Window上的根视图,ViewTree中的根,最顶层视图
- ContentView: 客户端程序员定义的全部视图的父节点,如Actvity中常见的setContentView(view)
一个实际中的ViewPath以下:
DecorView/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]复制代码
上面的“ContentFrameLayout[0]”这个节点表明的就是ContentView,程序员在xml或者代码里面构建的View都在ContentView中。
从DecorView到“ContentFrameLayout[0]”的这一段Path是Android系统Framework层决定的,理论上应该是一致的,可是因为碎片化等缘由可能ViewPath的这一段发生变化.在实践中,咱们也发现确实有一些Rom发生了此类状况,可是比率很小.
为了屏蔽这种可能形成同一个View在不一样设备上产生ViewPath不一样的状况,ViewPath的起点定义在ContentView比较好.如上面的ViewPath可优化为:
ContentView/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]#mybutton复制代码
作法:
构造每个ViewPath节点时能够取view.getId(),看看id的packageId部分是否是系统的(系统资源id以16进制的0x01,0x00开头),若是是,生成ViewPath时屏蔽这段便可.
页面在Android中对应于Activity和部分Fragment(好比不少app首页多tab的设计,若每一个tab是使用Fragment实现的,那么这种tab通常看做一个页面).页面的划分很重要,由于两点:
事件名称 | 所属页面 | ViewPath | 其余属性 |
---|---|---|---|
ButtonClicked | MainActivity | XXX | 省略 |
表格中的"所属页面"即表示这次按钮点击事件发生在MainActivity中.将交互事件归属于页面这样对后面不管是进行路径分析仍是统计控件点击量分布都有很大的好处.
Android中一般须要看做页面的有Activity和Fragment(对于像全屏Dialog或者全屏的View暂不考虑).对于Activity,上节中提到的两点都很容易办到.
a. Activity页面
Monitor.onViewClick(view)复制代码
入参view即为咱们点击的view,经过view.getContext()咱们通常就能够获得此View所属的Activity,伪代码以下://从View中利用context获取所属Activity的名字
public static String getActivityName(View view) {
Context context = view.getContext();
if (context instanceof Activity) {
//context自己是Activity的实例
return context.getClass().getSimpleName().;
} else if (context instanceof ContextWrapper) {
//Activity有可能被系统"装饰",看看context.base是否是Activity
Activity activity = getActivityFromContextWrapper((ContextWrapper) context);
if (activity != null) {
return activity.getClass().getSimpleName();
} else {
//若是从view.getContext()拿不到Activity的信息(好比view的context是Application),则返回当前栈顶Activity的名字
return currentActivityName;
}
}
return "";
}复制代码
b. fragment页面
相对于Activity,将某些Fragment看做页面的逻辑就要稍微复杂一些了.这里面涉及下面几个问题:
onResume()
onPause()
onHiddenChanged(boolean hidden)
setUserVisibleHint(boolean isVisibleToUser)复制代码
使用这几个回调包装成适用于各类情景的FragmentShow/Hide事件的伪代码以下://此回调发生,则证实是场景一中使用情景,
onHiddenChanged(boolean hidden) {
hidden == true ------FragmentShow
hidden == false------FragmentHide
}
//场景二中ViewPager页面切换时触发Fragment的此回调,
setUserVisibleHint(boolean isVisibleToUser) {
if (fragment.isResumed()) {//只有resumed状态的fragment适用此情景
isVisibleToUser == true ------FragmentShow
isVisibleToUser == false------FragmentHide
}
}
//上述使用情景以外的通常场景
OnResume/OnPause{
//fragment没有被hide,而且UserVisibleHint为可见的情景
if (!fragment.isHidden() && fragment.getUserVisibleHint()) {
OnResume ------ FragmentShow
OnPause ------ FragmentHide
}
}复制代码
view.setTag(0xff000001, fragmentName);复制代码
注意:View类有两个名为setTag的方法:public void setTag(final Object tag)复制代码
此方法,类内部用一Object对象存储tag,protected Object mTag = null;。listAdapter中经常使用于设置holder。咱们此处用的不是这个,不会于此用法冲突public void setTag(int key, final Object tag)复制代码
此方法,类内部有一稀疏数组存储tag,private SparseArray前面讲了将交互事件(好比点击事件)归属到某一个页面的方法是:
在交互事件中设置一个字段,值为页面名称。
页面能够是Activity或者Activity承载的Fragment,咱们的页面名称组成以下:
Activity类名[Activity别名][Fragment类名][Fragment别名]复制代码
说明以下:
商品详情页#iphone
商品详情页#电视复制代码
对于别名的设置,须要程序员在业务代码里面(如Activity.OnCreate,Fragment.onCreate等)显式设置. 以前提到过,数据收集SDK能够经过配置下发即时收集定制的数据,那么在Android端这个是怎么作到的呢?
首先,看一下下发的配置样例:
//第一部分:描述
PageName:MainActivity
ViewPath:DecorView/.../ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]
EventType:ViewClick
//第二部分:数据路径(当描述符合时,按照此路径取数据)
DataPath:this.context.demoList[5]复制代码
上面例子翻译成数据需求就是:
1. 当页面(MainActivity)
2. 中的控件(DecorView/.../ViewPager[0]/ButtonFragment[0]/AppCompatButton[0])
3. 发生点击事件(ViewClick)时
4. 按照路径(this.context.demoList[5])取出数据
5. 并附加到点击事件上面一块儿上报复制代码
按照这个描述,咱们还能够描述以下等等各类数据需求:
当(某页面)发生事件(Show)时,按照路径(xxx)取出数据,并附加到页面Show事件上面一块儿上报复制代码
总结下描述的组成部分,以下:
第一层 | 第二层 | 含义 |
---|---|---|
描述部分 | 页面 | 限定页面 |
ViewPath | 限定按钮 | |
EventType | 限定时机(点击/前台/PageShow) | |
数据路径 | 一种DSL,指示目标数据在内存中的位置(可理解为“引用路径”) |
上节展现了用于无埋点定制业务数据收集的配置,那么SDK收到这样的一份配置如何最终把想要的数据收集上来呢?
Monitor.onViewClick(view)复制代码
上述步骤三进行数据收集主要是按照DataPath的描述进行(例如示例中提到的"this.context.demoList[5]"),DataPath是一种咱们用于收集定制数据而定义的一种DSL.含义以下:
a. 含义
DataPath: 指向要收集的目标数据的一条引用路径,解析此路径并逐级反射最终拿到目标数据.
DataPath写法中的一些关键字(符):
关键字(符) | 含义 |
---|---|
. | 表示对象所属关系,如:a.b 表示实例a中的字段b |
.() | 表示公有方法调用,如:a.b() 表示调用实例a中的方法b.注意:方法入参能够是DataPath指向的Object |
[] | 数组/线性表的index. 注意:此index能够是常量数字,也能够是一个DataPath指向的数字 |
this | DataPath字符串的起点,表示起点为当前实例(当前View) |
item | DataPath字符串的起点,表示起点为当前View父节点中AdapterView adapter中当前条目. 经常使用于列表中的数据获取 |
parent | DataPath节点中的关键字,用于表示当前view的parentView.效果同view.getParent(),使用此关键字可减小视图引用中的反射 |
childAt(x) | DataPath节点中的关键字,用于表示当前view的第x个childView.效果同view.getChildAt(x),使用此关键字可减小视图引用中的反射 |
b. 应用示例
下面用两个例子说明如何从DataPath找到目标数据.
示例1:列表数据获取
上图中显示是一个列表,红框中是列表的第一个条目.那么,若是咱们想要在列表中条目点击时,将列表展现的交易品ID(或者合做方ID)等不在界面上显示而又存在于内存中的数据跟随点击事件上报.此处DataPath该怎么写?
item.productId复制代码
DataPath解释:
public class DemoAdapter extends BaseAdapter {
private ArrayList<DataItem> mDataItems;
......
}复制代码
则此处"item"表明的就是mDataItems[x] (x表示当前被点击条目的itemId)2."productId"是model类DataItem中表示"交易品ID"的字段名称.
经过DataPath获取数据:
实例2:界面数据获取
一样时图4-1所示,加入咱们想在列表中条目点击时,将条目中展现的"最新价"跟随点击事件上报.此处DataPath该怎么写?
红框所示ViewTree子树以下:
如上图,选中部分是列表的ItemView(RelativeLayout),可见"最新价"是由index为2的TextView所展现,由此可得,列表中条目点击获取"最新价"数据的DataPath以下:
this.childAt(2).mText复制代码
DataPath解释:
c. DataPath注意事项:
1.混淆.
因为DataPath本质上描述的时内存中的"引用路径",而且按照DataPath取数据时用了反射的方法,所以DataPath应该描述的是混淆以后的"引用路径".
虽然DataPath可能受到混淆的影响,可是
* 用于存储数据的model类一般是不被混淆的.如咱们以前的item关键字直接将起点设置为列表条目的model类对象,不受混淆影响.
* 经过关键字parent/childAt(x)能够在视图的引用中不受混淆影响
* 接口的方法一般不受混淆影响.所以在DataPath中多用接口方法调用复制代码
所以开发在配置DataPath时应尽可能用上述不被混淆影响的字段及方法.可是,若是真的用到了混淆过的字段怎么办.咱们的方案是:
数据报警
好比版本1上配置的DataPath "a.b",在升级新版本2后再也不适用,则新版本2按照"a.b"收集时将收集不到,产生报警信息到后台.后台收到大量此种信息会提醒开发为新版本配置适用新版本的DataPath.
2.代码变化致使引用路径变化,从而导致以前配置的DataPath失效.
与代码中埋点相比,线上配置进行收集数据与代码的变化是并行的,无关的.这就有可能形成原有代码修改致使DataPath失效.其实若是客户端架构设计合理,功能迭代更可能是在进行代码的扩展,而非修改,这种致使DataPath失效的状况应该会大大下降的.
可是不管如何:
配置的DataPath摆脱不了与版本的相关性
对于此种问题咱们依然是经过前面提到的"数据报警"进行监控及避免的.
综上,本文介绍了数据收集逻辑中3个比较关键的点(ViewID/Page/DataPath),结合上一篇文章的(AOP原理),Android端无埋点数据收集技术上比较关键的点皆以总结完毕. 固然实现SDK过程当中遭遇过不少比较有意思的技术问题,后续也会陆续进行整理.