本文基于网易乐得无埋点数据收集 SDK,无埋点数据收集 SDK 用于向大数据平台提供全量,完整,准确的客户端数据. Android 端无埋点数据收集 SDK 实现中涉及到比较关键的技术点有:
关于第一点 Android 端 AOP 的实现,之前的一篇文章 Android AOP 之字节码插桩已经做了详细的阐述。本文接着讲一下关于收集 SDK 内部收集逻辑的一些关键技术(即后面三点).
本部分首先简要介绍一下我们的收集方案目前可以收集到哪些数据,然后对于本文重点介绍的三个技术点进行概述.
目前我们的 SDK 进行数据收集时基本有两个能力:
通用数据指的是与业务无关的用户行为数据,无论是电商应用还是社区应用,接入 SDK 后通用数据的收集上都是无差的,这些通用数据大致有:
事件 | 描述 |
---|---|
冷启动事件 | App 第一次启动时的,版本号、设备 ID、渠道、内存使用情况,磁盘使用情况等信息 |
前后台事件 | App 进入前台或者后台 |
页面事件 | 页面(Activity 或 Fragment)显示(Show)/隐藏(Hide) |
控件点击事件 | 某个控件(包括页面上控件和弹窗中控件)被用户点击 |
列表浏览事件 [可选] | 某个列表的哪些条目被用户浏览了 |
位置事件 [可选] | 上报用户地理位置信息 |
其它事件 | 省略描述 |
除了上述通用数据,与具体业务相关的数据收集。拿网易贵金属的首页举个例子:
假使需要在用户点击上图红框区域时,把 "粤贵银" 这个交易品的 ID(或者下方显示的指数等,只要在内存中存在的数据都可以)一起报上来。 对于此种需求,数据收集 SDK 做到了无需埋点,不依赖开发周期,通过线上下发一些配置信息,即可即时进行数据收集。具体原理第四节叙述。
当我们收集控件数据时碰到的第一个问题就是:如何把界面上的任何一个 View 与其他 View 区分开来.
比如:某个 Button 被点击了 我们在上报数据的时候需要把这个 Button 和其他所有控件(比如另一个 Button,另一个 ImageView 等)区分开来,这样这条上报的数据才能表示"就是那个 Button 被点击了一下".
这就需要为界面上的每一个控件生成一个唯一的 ID. 此 ID 除了具有区分性,还需要用于一致性.一致性是同一个 View 无论界面布局如何动态变化,或者说多次进入同一页面,此 ID 需要保持不变.
除了 Activity 有些 Fragment 也需要看作页面,这就要求:
如前面所述,默认情况下数据收集 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? 如上一篇文章 Android AOP 之字节码插桩所述,当用户点击某个按钮时,我们插入 OnClickListener.OnClick 方法中的如下代码将会被调用:
- Monitor.onViewClick(view);
上面,入参 view 即为当前被点击的 view,获取此 view 的 ViewPath 伪代码如下:
- public staticViewPathgetPath(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]
情景:
在图 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 之外其它任何类型时都保持前后一致。
情景:
在图 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 的定义上难以找到在同类型兄弟节点动态变化前后保持一致的方法,但我们可以分析发生此种界面动态变化的情景:
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 在变化前后保持一致。
情景 以最常使用的 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 区分度不够
因此我们对于 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()
ViewPath 从 ContentView 为起点,而非 DecorView
一个实际中的 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 一般看作一个页面).页面的划分很重要,因为两点: 1. 对于页面,需要获取 Show/Hide 两个时机,在此时机上报页面 Show/Hide 事件, 非页面则不需要 2. 页面的划分关系着用户交互事件的所属,例如,按钮点击事件上报格式如下:
事件名称 | 所属页面 | ViewPath | 其他属性 |
---|---|---|---|
ButtonClicked | MainActivity | XXX | 省略 |
表格中的"所属页面"即表示此次按钮点击事件发生在 MainActivity 中.将交互事件归属于页面这样对后面无论是进行路径分析还是统计控件点击量分布都有很大的好处.
Android 中通常需要看作页面的有 Activity 和 Fragment(对于像全屏 Dialog 或者全屏的 View 暂不考虑).对于 Activity,上节中提到的两点都很容易办到.
- Monitor.onViewClick(view)
入参 view 即为我们点击的 view, 通过 view.getContext() 我们一般就可以得到此 View 所属的 Activity, 伪代码如下:
- //从View中利用context获取所属Activity的名字
- public staticStringgetActivityName(View view) {
- Context context = view.getContext();if(contextinstanceofActivity) {//context本身是Activity的实例
- returncontext.getClass().getSimpleName().;
- }else if(contextinstanceofContextWrapper) {//Activity有可能被系统"装饰",看看context.base是不是ActivityActivity activity = getActivityFromContextWrapper((ContextWrapper) context);if(activity !=null) {returnactivity.getClass().getSimpleName();
- }else{//如果从view.getContext()拿不到Activity的信息(比如view的context是Application),则返回当前栈顶Activity的名字
- returncurrentActivityName;
- }
- }return "";
- }
相对于 Activity,将某些 Fragment 看作页面的逻辑就要稍微复杂一些了.这里面涉及下面几个问题:
- onResume()
- onPause()
- onHiddenChanged(boolean hidden)
- setUserVisibleHint(boolean isVisibleToUser)
使用这几个回调包装成适用于各种情景的 FragmentShow/Hide 事件的伪代码如下:
- //此回调发生,则证明是场景一中使用情景,
- onHiddenChanged(boolean hidden) {
- hidden ==true ------FragmentShowhidden ==false------FragmentHide}
- //场景二中ViewPager页面切换时触发Fragment的此回调,setUserVisibleHint(boolean isVisibleToUser) {if(fragment.isResumed()){//只有resumed状态的fragment适用此情景isVisibleToUser ==true ------FragmentShowisVisibleToUser ==false------FragmentHide}
- }
- //上述使用情景之外的一般场景OnResume/OnPause{//fragment没有被hide,并且UserVisibleHint为可见的情景
- if(!fragment.isHidden() && fragment.getUserVisibleHint()) {
- OnResume------ FragmentShowOnPause------ FragmentHide}
- }
- view.setTag(0xff000001, fragmentName);
注意:View 类有两个名为 setTag 的方法:
- public void setTag(finalObject tag)
此方法,类内部用一 Object 对象存储 tag,protected Object mTag = null;。listAdapter 中常用于设置 holder。我们此处用的不是这个,不会于此用法冲突
- public void setTag(intkey,finalObject tag)
此方法,类内部有一稀疏数组存储 tag,private SparseArray mKeyedTags; tag 的 key 官方推荐资源 id,因此我们可以选用类似 0xff000001 之类的 app 用不到的资源 id 进行 tag 存储以避免冲突。 4. 当需要使用 Fragment 名时,如下调用即可获得:
- view.getTag(0xff000001)
前面讲了将交互事件(比如点击事件)归属到某一个页面的方法是:
在交互事件中设置一个字段,值为页面名称。
页面可以是 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.含义如下:
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), 使用此关键字可减少视图引用中的反射 |
下面用两个例子说明如何从 DataPath 找到目标数据.
示例1:列表数据获取 上图中显示是一个列表,红框中是列表的第一个条目.那么,如果我们想要在列表中条目点击时,将列表展示的交易品 ID(或者合作方 ID) 等不在界面上显示而又存在于内存中的数据跟随点击事件上报.此处 DataPath 该怎么写?
- item.productId
DataPath 解释:
- public class DemoAdapter extends BaseAdapter{
- privateArrayList 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 解释:
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 过程中遭遇过很多比较有意思的技术问题,后续也会陆续进行整理.
来源: http://blog.csdn.net/qq_29647881/article/details/70148992