Android无埋点数据收集SDK关键技术

作者: 陶菜菜 | 来源:发表于2017-04-08 14:06 被阅读18275次

前言

鉴于日益强烈的精细化运营需求,网易乐得从去年开始构建大数据平台,<<无埋点数据收集SDK>>因此立项,用于向大数据平台提供全量,完整,准确的客户端数据.
  <<无埋点数据收集SDK>>Android端从着手,到经历重构,逐步完善到现在已经有快一年的时间了.期间从开源社区以及同行中得到了一些很有意义的技术参考,因此在这个SDK趋于完善的今天,我们也考虑将这一路在技术上的探索经历和收获分享出来.

  1. 4月16-18日,QCon北京2017全球软件开发大会上有同事代表Android/IOS两端进行统一的技术分享,欢迎大家前去交流
  2. 我们会逐渐整理一些技术文章到这个简书账号“移动端数据收集和分析

之前关于Android端的<<无埋点数据收集SDK>>使用的技术,写了一篇文章<<Android AOP之字节码插桩>>,这个是Android端进行一切收集的起点,我们就是用这个方法轻松拿到各种"Hook"点的.
  本篇文章则接着讲一下关于收集SDK内部收集逻辑的一些关键技术.


目录

一、概述
1.1 SDK数据收集能力现状
1.2 关键技术点概述
二、View的唯一标识(ID)
2.1 调研
2.2 利用ViewTree构建ViewID
2.3 ViewPath的生成
2.4 ViewPath的优化
三、页面的划分
3.1 合理划分页面的重要性
3.2 Android中的页面
3.3 页面名组成
四、无需埋点轻松收集定制的业务数据
4.1 配置示例
4.2 无埋点收集流程
4.3 数据路径(DataPath)
五、结语


一、概述

本部分首先简要介绍一下我们的收集方案目前可以收集到哪些数据,然后对于本文重点介绍的三个技术点进行概述.

1.1 SDK数据收集能力现状

目前我们的SDK进行数据收集时基本有两个能力:

a. 通用数据全量收集
  通用数据指的是与业务无关的用户行为数据,无论是电商应用还是社区应用,接入SDK后通用数据的收集上都是无差的,这些通用数据大致有:

事件 描述
冷启动事件 App第一次启动时的,版本号、设备ID、渠道、内存使用情况,磁盘使用情况等信息
前后台事件 App进入前台或者后台
页面事件 页面(Activity或Fragment)显示(Show)/隐藏(Hide)
控件点击事件 某个控件(包括页面上控件和弹窗中控件)被用户点击
列表浏览事件[可选] 某个列表的哪些条目被用户浏览了
位置事件[可选] 上报用户地理位置信息
其它事件 省略描述

b. 业务相关数据需求通过下发配置进行无埋点定制收集
  除了上述通用数据,与具体业务相关的数据收集。拿网易贵金属的首页举个例子:

图1-1 无埋点收集业务数据示例

假使需要在用户点击上图红框区域时,把“粤贵银”这个交易品的ID(或者下方显示的指数等,只要在内存中存在的数据都可以)一起报上来。
  对于此种需求,数据收集SDK做到了无需埋点不依赖开发周期,通过线上下发一些配置信息,即可即时进行数据收集。具体原理第四节叙述。

1.2关键技术点概述

a. View的唯一标识(ID),(详见本文第二节)
  当我们收集控件数据时碰到的第一个问题就是:如何把界面上的任何一个View与其他View区分开来.

比如:某个Button被点击了
我们在上报数据的时候需要把这个Button和其他所有控件(比如另一个Button,另一个ImageView等)区分开来,这样这条上报的数据才能表示"就是那个Button被点击了一下".

这就需要为界面上的每一个控件生成一个唯一的ID. 此ID除了具有区分性,还需要用于一致性一致性是同一个View无论界面布局如何动态变化,或者说多次进入同一页面,此ID需要保持不变.

b. 页面的划分,(详见本文第三节)
  除了Activity有些Fragment也需要看作页面,这就要求:

  • 在Fragment show/hide时上报相关页面事件.
  • 页面Fragment中发生的用户交互事件也需要归于此Fragment页面,即点击某个View需要上报页面Fragment的信息(从View中怎么获取Fragment信息?)

c. 无需埋点轻松收集定制的业务数据,(详见本文第四节)
  如前面所述,默认情况下数据收集SDK会收集全量的用户交互数据,对于定制的业务收集需求,数据收集SDK也做到了无需代码埋点,通过线上下发一些配置进行即时收集


二、View的唯一标识(ID)

2.1 调研

用于区分界面上每个View的ID? Android系统是否提供给了我们这个ID?

确实,Android系统提供了一个ID,view.getId()即可获得一个int型的id用于区分View,但是这个ID因为以下两个原因却并不能满足我们的需要.

  1. 有相当一部分view是NO_ID,比如在布局文件中未指定id,或者直接在代码里面new出来view,view.getId()返回的全部都是NO_ID
  2. 这个ID是不稳定的,由于这个ID其实就是每次编译产生的R文件中的int常量,因此同一个按钮,两个版本编译出来的ID很可能时不一样的.

因此,我们只能自己动手构建我们的ID喽,怎么构建?答案是利用所属Page+ViewTree构建ViewID.

2.2 利用ViewTree构建ViewID

在Android的概念里,每个Window(ActivityWindow/DialogWindow/PopupWindow等)上面都生长着一棵ViewTree.而屏幕中看到的各种控件(ImageView/Button等)都是这棵ViewTree上的节点.
  有Android开发环境的同学只需要打开AndroidDeviceMonitor-dump view hierarchy 就可以看到ViewTree的模样,如下图:

图2-1 ViewTree概念图

因此,我们萌生出一个想法:

利用Page+ViewTree中的位置构建ViewID.

View在ViewTree中的位置主要用两点来确定:

  • 纵向的深度
  • 横向的index

考虑这两个因素后,我们定义一个ViewPath:

ViewPath:当前view到ViewTree根节点的一条路径,用于在ViewTree中唯一定位当前view。路径中的每个节点包含两部分信息,即节点View类型信息,以及节点View在兄弟中的index。

如下图,是一个简单的ViewTree模型(简单到深度只有两层,每层只有两三个控件)

图2-2 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中,每个节点(除了首节点)有两部分内容:

  • LinearLayout,RelativeLayout,ChildView1等ViewType信息(节点View的类型
  • "[]"内的index信息,此index指示此节点是兄弟节点的第几个

这是最初的ViewPath,用ViewPath定位view,有两点特别重要:

  • 一致性: 同一个view的ViewPath在ViewTree的动态变化中应保持不变
  • 区分度: 不同view的ViewPath应该不同

按照这个最初的ViewPath定义在实践中还不能在一致性和区分度上满足我们的需求,后面会对ViewPath进行优化。

2.3 ViewPath的生成

上面我们由构建ViewID的需求引出了ViewPath的定义,那么当交互事件(例如:按钮点击)发生时,我们如何生成此控件的ViewPath?
  如上一篇文章<<Android AOP之字节码插桩>>所述,当用户点击某个按钮时,我们插入OnClickListener.OnClick方法中的如下代码将会被调用:

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]

2.4 ViewPath的优化

a. 一致性优化1
情景:

在图2-2 ViewTree模型图中,如果像下面图中所示,在控件2和3中动态插入一个FrameLayout呢?

图2-3 Android界面动态性变化情景1

此时按照原始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的定义上难以找到在同类型兄弟节点动态变化前后保持一致的方法,但我们可以分析发生此种界面动态变化的情景:

  1. 使用Fragment的动态布局
      Android界面的动态布局发生情景中,使用Fragment实现界面动态变化的频率和影响控件数量还是比较大的(相对于直接addView())
  2. ListView(等可复用View)中同类型的itemViews。
      此种情况虽然没有发生在一个itemView前动态插入一个itemView,但是由于itemView的复用,导致itemView展示的内容和在父节点listView内的index的对应关系动态变化,因此也归于此类。

2中所说“ListView等可复用View”造成的问题后面会有优化,此处针对1中的情景讨论。1中情景发生时如下图:

图2-4 使用Fragment造成界面动态性的情景

上图中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

上面情景中的问题可用下图表达:

图2-5 可复用View的ViewPath区分性优化

如上图中,内容条目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时屏蔽这段即可.


三、页面的划分

3.1 合理划分页面的重要性

页面在Android中对应于Activity和部分Fragment(比如很多app首页多tab的设计,若每个tab是使用Fragment实现的,那么这种tab一般看作一个页面).页面的划分很重要,因为两点:

  1. 对于页面,需要获取Show/Hide两个时机,在此时机上报页面Show/Hide事件,非页面则不需要
  2. 页面的划分关系着用户交互事件的所属,例如,按钮点击事件上报格式如下:
事件名称 所属页面 ViewPath 其他属性
ButtonClicked MainActivity XXX 省略

表格中的"所属页面"表示此次按钮点击事件发生在MainActivity中.将交互事件归属于页面这样对后面无论是进行路径分析还是统计控件点击量分布都有很大的好处.

3.2 Android中的页面

Android中通常需要看作页面的有Activity和Fragment(对于像全屏Dialog或者全屏的View暂不考虑).对于Activity,上节中提到的两点都很容易办到.

a. Activity页面

  1. 从Application.ActivityLifecycleCallbacks的onActivityResumed/onActivityPaused这两个回调方法就可以分别得到Activity页面Show/Hide的时机,并在此时机上报相应页面事件
  2. 交互归属的Activity页面可以通过Context轻松获得,例如上篇文章<<Android AOP之字节码插桩>>提到,当按钮点击时,会触发我们插桩的代码:
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看作页面的逻辑就要稍微复杂一些了.这里面涉及下面几个问题:

  • 哪些Fragment可以需要看作页面?
      这是需要人工决策的,机器做不了这个决定.
      目前我们这个人工干预是交给用户研究团队,所有Fragment截图等信息均展示在平台上,由用研同事选择需要看作页面的那些,用研选择的结果将自动化配置到SDK中
  • 如何得到Fragment页面的Show/Hide页面事件?
      由于fragment使用场景比较多样,单单依靠OnResume/OnPause两个回调表示fragment Show/Hide是不准确的,比如:
    场景一
      首页一个Activity承载多个Fragment Tab的情况,此时tab间切换并不会触发Fragment的OnResume/OnPause.触发的回调函数是onHiddenChanged(boolean hidden)
    场景二:
      一个ViewPager承载多个页面的Fragment时
        a.当第一个Fragment1显示时,虽然第二个Fragment2此时尚未显示,但是Fragment2的OnResume却以及执行,处于resumed的状态.
        b.ViewPager页面切换OnResume/OnPause/onHiddenChanged均未触发,触发的回调是setUserVisibleHint
      此时判断Fragment Show/Hide应该用setUserVisibleHint,而非OnResume/OnPause
      如前一篇文章XXX,所述,我们通过插桩的方式Hook到了fragment的如下生命周期函数用于包装成为Show/Hide事件:
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
    }
  }
  • 如何将Fragment内部的交互归属到Fragment页面,也就是说如何在交互发生时从view实例拿到Fragment页面的名字(像之前拿到Activity页面名字一样)?
      view可以通过context拿到Activity的信息,但是却没有途径拿到fragment的引用。那么,当某个View交互发生,我们又需要获取Fragment页面名字的情况下,我们只能事先将Fragment页面名写入此View的属性中。
      做法大致如下:
        a. 按照前一篇文章xxx里面的方法,在Fragment.OnCreateView方法的结尾插桩,拿到return的view(即为此Fragment的顶层视图)
        b. 判断此Fragment是否被指定为Fragment页面,如果是,下一步
        c.遍历以Fragment的顶层视图为根节点的ViewTree, 将Fragment名设置到此ViewTree的每一个view上。设置方法如下所示:
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<Object> mKeyedTags;
  tag的key官方推荐资源id,因此我们可以选用类似0xff000001之类的app用不到的资源id进行tag存储以避免冲突
    d. 当需要使用Fragment名时,如下调用即可获得:

view.getTag(0xff000001)

3.3 页面名组成

前面讲了将交互事件(比如点击事件)归属到某一个页面的方法是:

在交互事件中设置一个字段,值为页面名称。

页面可以是Activity或者Activity承载的Fragment,我们的页面名称组成如下:

Activity类名[Activity别名][Fragment类名][Fragment别名]

说明如下:

  1. “[]”内的组成部分是可选的,可能有可能没有。另外,各个组成部分之间有分隔符分割。
  2. 页面名组成中,Activity的描述(类名/别名)是第一层,Fragment的描述(类名/别名)是第二层
  3. 别名的出现是为了解决单纯依赖类名无法精确区分页面的某些情况,比如:
    在某个电商应用中,“商品详情页”(同一个Activity)用于展示各种商品(iphone,电视等),如果需要把“不同商品的商品详情页“区分成不同页面来统计pv等指标的话,需要设置别名,如:
商品详情页#iphone
商品详情页#电视

对于别名的设置,需要程序员在业务代码里面(如Activity.OnCreate,Fragment.onCreate等)显式设置.


四、无需埋点轻松收集定制的业务数据

4.1 配置示例

之前提到过,数据收集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,指示目标数据在内存中的位置(可理解为“引用路径”)

4.2 无埋点收集流程

上节展示了用于无埋点定制业务数据收集的配置,那么SDK收到这样的一份配置如何最终把想要的数据收集上来呢?

  • 步骤一:产生原始事件。比如点击时收集,当点击时会触发我们插桩的代码,并生成原始的点击事件
Monitor.onViewClick(view)
  • 步骤二:匹配配置
    在onViewClick方法中匹配下发的配置信息,看看Page,ViewPath是否与当前view匹配,EventType是否与当前事件类型匹配,若匹配则进行下一步
    注:ViewPath的匹配可以有精确匹配和模糊匹配,精确匹配时一个ViewPath精确匹配唯一一个控件.模糊匹配时一个ViewPath可匹配多个控件,例如可以用用一个ViewPath模糊匹配一个列表中的所有条目.
  • 步骤三:按照数据路径(DataPath)逐级反射拿到目标数据,并将找到的数据附在原始的点击事件上进行上报。

4.3 数据路径(DataPath)

上述步骤三进行数据收集主要是按照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找到目标数据.

图4-1 DataPath示例

示例1:列表数据获取
  上图中显示是一个列表,红框中是列表的第一个条目.那么,如果我们想要在列表中条目点击时,将列表展示的交易品ID(或者合作方ID)等不在界面上显示而又存在于内存中的数据跟随点击事件上报.此处DataPath该怎么写?

item.productId

DataPath解释:

  1. 起点定为"item",则表示从此ListView(或者RecylerView)绑定的Adapter中当前数据item为起点取数据.
    假设此ListView绑定的Adapter如下:
public class DemoAdapter extends BaseAdapter {
  private ArrayList<DataItem> mDataItems;
  ......
}

则此处"item"代表的就是mDataItems[x] (x表示当前被点击条目的itemId)

2."productId"是model类DataItem中表示"交易品ID"的字段名称.

通过DataPath获取数据:

  1. 当第x条目被点击时,如果发现有匹配的配置,对于起点为"item"的DataPath,先通过view.getParent找到上层ListView实例,然后通过listView.getAdapter()获得绑定的Adapter实例,最后通过Adapter.getItem(ListView.getPositionForView(itemView))得到数据中第x个item,即mDataItems[x]
  2. 反射获取mDataItems[x]中的productId字段,即可得到第x个条目的"交易品ID",将此ID跟随第x条目的点击事件进行上报即可.

实例2:界面数据获取
  同样时图4-1所示,加入我们想在列表中条目点击时,将条目中展示的"最新价"跟随点击事件上报.此处DataPath该怎么写?
  红框所示ViewTree子树如下:

图4-2 列表Item ViewTree子树结构

如上图,选中部分是列表的ItemView(RelativeLayout),可见"最新价"是由index为2的TextView所展示,由此可得,列表中条目点击获取"最新价"数据的DataPath如下:

this.childAt(2).mText

DataPath解释:

  1. 起点为"this",表示当前被点击的view实例(图4-2中被选中的RelativeLayout)
  2. "childAt(2)"表示RelativeLayout.getChildAt(2),得到图4-2中index为2的TextView
  3. "mText" 表示取出步骤2中得到TextView实例的mText字段(TextView控件显示的文字内容存储在mText字段内)
  4. 将取出的界面上显示的"最新价"数据添加到原始点击事件中,一起上报.

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过程中遭遇过很多比较有意思的技术问题,后续也会陆续进行整理.

相关文章

  • iOS无埋点数据SDK的整体设计与技术实现

    iOS无埋点数据 SDK 实践之路 iOS无埋点SDK 之 RN页面的数据收集 本篇文章是讲述 iOS 无埋点数...

  • Android无埋点数据收集SDK关键技术

    前言 鉴于日益强烈的精细化运营需求,网易乐得从去年开始构建大数据平台,<<无埋点数据收集SDK>>因此立项,用于向...

  • iOS无埋点数据SDK实践之路

    本篇文章是基于 网易乐得无埋点数据SDK 总结而成。负责无埋点数据收集 SDK 的开发已经有半年多了,期间在组内进...

  • 微信小程序无埋点数据收集方案

    前言 目前趋势下,不管是客户端SDK还是小程序SDK,都在趋向于无埋点的数据收集方式。本文和大家分享下微信小程序无...

  • 面向过程/对象/切面编程

    面向过程编程,面向对象编程和面向切面编程理解 iOS无埋点数据 SDK 实践之路iOS无埋点SDK 之 RN页面的...

  • Android全埋点-页面浏览事件

    全埋点 全埋点也叫无埋点,自动埋点。是指预先自动收集用户的所有行为数据。然后就可以根据收集的数据从中筛选出所需的行...

  • 大厂Android端可视化埋点的实现

    Android端可视化埋点的实现 ***导语 ***客户端埋点是数据收集的最基本手段,对于一款APP来说,代码埋点...

  • iOS开发见闻-第21期

    欢迎加QQ群讨论:157672725 文章 1.iOS无埋点数据SDK实践之路 : 本篇文章是基于 网易乐得无埋点...

  • iOS无埋点SDK 之 RN页面的数据收集

    本篇文章是讲述 iOS 无埋点数据收集 SDK 系列的第二篇。在第一篇 中主要介绍了 SDK 整体实现思路以及...

  • Android全埋点

    什么是全埋点? 也叫做无埋点,预先收集用户的所有行为数据,然后根据实际需求,从中提取行为数据。 采集数据的点: $...

网友评论

  • aad089bfd751:webview也可以取到viewId?
  • 迈希:请问怎么保证安卓、iOS、Web的同一个按钮的事件标识ViewPath都一致呢?安卓、iOS的类名,web的html标签名称都可能不一样
    陶菜菜:@迈希 没有保证, 分平台的
  • kehan_3a7c:你好作者,你有对mixpanle源码分析过么,期盼您的指教
  • stevenMvp:不错,无埋点现在是各个大公司重点开发的项目了
  • aa3a627bebb3:请问dialog的viewPath需要特殊处理吗? 对于属于dialog的view,viewTree是不是应该取dialog.getWindow().getDecorView()才对呢
  • 码农朱同学:在用growingio,感觉也还行
  • b025e3a3d0c8:我最近也写完了公司的埋点统计功能,有个问题 就是动态生成的view怎么获取到path呢?我的解决办法比较low是动态生成的时候动态打tag。不带着key的tag不会和viewHolder冲突? 保留意见
    陶菜菜:@极地之光_a62b 1. 我们的viewpath是在交互发生时取的viewtree中的位置,只要被添加到viewtree中,怎么生成的有什么影响么? 如果需要对view做标记,对于动态生成的view,我们是在view被加入viewtree这个时间节点进行处理的

    2. tag的话,我们用的带key的那个,而且key是正常资源id之外的值(0xffxxxxx之外),虽然不绝对,但基本不会冲突

    我们没用不带key的tag, 还“保留意见”么 😝
  • happyyy2017:databing 如何自动化收集事件?
  • hasonguo:您好,楼主,如何统计应用里所有的view的viewpath,然后上报给服务器呢,进行服务器配置,并且让他们知道是哪个页面的哪个控件
  • f9378570affa:“对于别名的设置,需要程序员在业务代码里面(如Activity.OnCreate,Fragment.onCreate等)显式设置”.请问你们的activity和fragment的统计是自己有埋点代码添加的吗。也就是你们无埋点统计sdk只是针对统计点击事件,对话框,adapter。fragment的切换等?
    陶菜菜: @江南_d4df 基本页面事件都不需要埋点。
    “别名”处理这样的需求,如: 同一个商品详情页(类名相同)需要把卖不同商品的区分成不同的页面。
    简言之,就是同类名页面按照内容这个更细的粒度再区分页面。这种需要手动设置
  • f9378570affa:写的很好,请问一下这个事件名称的收集是如何获取的呢?
    f9378570affa:@陶菜菜 就是说譬如你的:“viewClick”事件,这是你本地写死的?那下次别的事件你是不是也是自己写死的一个名称还是从哪获取的事件名称
    陶菜菜: @江南_d4df 什么意思?
  • hasonguo:这个能确定View的唯一标识吗?
    陶菜菜: @hasonguo 基本确定
  • JohnnyShieh::+1: 多谢作者分享
  • 游侠_6fb7:还有几点我没搞清楚
    1、反射获取mDataItems[x]中的productId字段,即可得到第x个条目的"交易品ID",将此ID跟随第x条目的点击事件进行上报;
    这个地方是否需要业务写一些代码?如果不需业务写代码,如何知道反射的字段名叫productId,假如其它地方叫news_id?还是在服务端配置?
    2、界面数据获取
    是怎么知道界面数据是再index为几,比如this.childAt(2).mText,是怎么知道index为2,是提前分析布局得到?还是需要配置?配置的话,如何进行自动化配置?
    3、viewPath,如何涉及到公共布局变化,viewPath全变了,是否要重新录入配置打点?
    陶菜菜: @游侠_6fb7 1.不用写代码,定义dsl,下发配置
    2.下发配置进行收集是对全量行为数据的补充,是程序员的工作,需要view到数据的引用路径,不能自动化。这个配置只要有引用路径就可以,不局限于视图层的引用关系。如果用视图层的引用的话可以用as dump下viewtree确定index还是比较方便的
    3. viewpath变了就识别成不同的控件了,一般用版本进行区分。
    如果想要变化前后的viewpath识别统计成同一个,可以在服务端配置,原理类似正则匹配
  • happyyy2017:有两个场景
    1、一个activity有多个tab标签页,每个tab页点击加载一个fragment。
    问题:Activity oncreate是上报了pv,产品点击每个tab页时fragment也上报pv。进到这个页面默认会加载一个fragment。那会导致activity上报了一次pv,fragment也上报了一次pv。这种情况怎么处理?
    2、一个activity中,没有tab标签切换,但也由多个fragment组成。
    问题:因为目前对所有fragment的生命周期进行代码注入,上报pv。但这种情况就只需要上报activity一个pv,不需要上报fragment pv了。这种情况怎么处理?
    多谢!
    陶菜菜: @江南_d4df 所有activity页面事件全部上报不用埋点
    f9378570affa:请问一下,楼主这个activity的上报你理解的是自己无埋点上报还是自己手动代码的上报
    陶菜菜:对于fragment ,虽然都进行了注入,但是未必都需要上报页面事件(即pv等指标)。我们的处理是只有显式指定当作页面的fragment才会上报页面事件。

    对于第一种场景activity多tab的方式,主要的ui操作都在承载的fragment上,因此我们主要关注各个tab上fragment的pv等指标
  • aa3a627bebb3:请问仅仅进行点击事件数据采集的话,圈选模式是否有必要?全量采集数据,不进行圈选会有性能及其他的问题吗?
    不懂的世界:@陶菜菜 这个是否可详细说说
    陶菜菜:点击事件的采集是既定的,不需要什么配置,所有采集这一步不需要圈选模式。

    但是要把采集的数据放在后台进行分析处理展示的话,可能需要一些对应关系的配置,这些就需要圈选模式进行配置了
  • aa3a627bebb3:请问,针对可复用View的优化,index应该在什么时候取呀?比如viewPager,我点击的时候触发的函数里面貌似取不到viewPager的引用。。。还有ListView也不是很懂:anguished:
    陶菜菜:viewpath取的时候都是从叶子节点向decorview一个节点一个节点取的(循环view.getParent).viewtree上这条叶子节点到根(decorview)的所有view实例的引用都可以拿到。

    viewpager的话取getCurrentItem()
    listview的话 parentListView.getPositionForView(childview)
  • aa3a627bebb3:请问圈选模式是如何实现的呢?怎样能够和普通的用户使用区分开来,在圈选模式下唤醒应用,出现一个按钮就可以截图并且识别控件?这一点有点疑惑,这样按钮岂不是要集成在应用里
    陶菜菜:@Sibyllalala 我没有详细看他的圈选的实现,通过intent应该只是一个唤醒的功能吧,然后在目标app内开启某种标志,然后后面的逻辑应该都是在目标app集成的sdk内。

    我觉得所谓“渲染一个红色的按钮”应该是悬浮在视图顶层添加的一个悬浮按钮吧(例如windowManager.addView(xxx)),然后拖拽按钮时捕获坐标,将viewtree中相应view的背景着色。我觉得应该是这么搞得吧,你要是有空可以反编译看下他的内部实现
    aa3a627bebb3:@陶菜菜 研究了一下growingio的圈选,在AndroidManifest.xml中配置了intentfilter之后就可以在其他应用中通过指定scheme的intent来唤醒app,打开app的圈选模式。我只知道唤醒app的原理,不清楚在静态配置intentfilter的情况下,怎样在传递intent之后还能做进一步的操作,比如把圈选模式打开,渲染一个红色的按钮,那个按钮还不属于页面的viewTree的任何一个节点:joy:
    陶菜菜:我采用的是条件编译的方式(BuildConfig),带有圈选功能的sdk和线上使用的sdk打出来就是不一样的,线上sdk没有圈选触发逻辑的代码

    当然你也可以采用运行时标志位来控制,比如点击发生时标志置位的话就block住点击逻辑然后弹出圈选界面,两种方式都可以~
  • aa3a627bebb3:一致性优化2:在ViewPath节点中,使用Fragment的名字替换ViewType,请问怎么知道当前Layout包含的Fragment名字呢?
    aa3a627bebb3:@陶菜菜 有些情况在fragment创建view完毕后,并不代表整个视图渲染完成了。比如Fragment中用到了ListView的情况,可能还需要改写BaseAdapter的getView方法,那个方法调用时才会真正渲染列表项的视图。
    陶菜菜:@Sibyllalala 在fragment 创建view完毕时,将fragment名字写进view中。后面取path时优先取这个
    aa3a627bebb3:以及如何判断这个Layout是否会是动态变化的layout
  • e0cdb39ac23f: Android studio中在执行build apk时会报如下错误(直接运行Instrant run无问题),有遇到过吗?
    AGPBI: {"kind":"error","text":"Uncaught translation error: com.android.dx.cf.code.SimException: stack: overflow","sources":[{}]}
    AGPBI: {"kind":"error","text":"1 error; aborting","sources":[{}]}

    FAILED

    FAILURE: Build failed with an exception.

    * What went wrong:
    Execution failed for task ':app:transformClassesWithDexForDebug'.
    > com.android.build.api.transform.TransformException: java.lang.RuntimeException: com.android.ide.common.process.ProcessException: java.util.concurrent.ExecutionException: com.android.ide.common.process.ProcessException: Return code 1 for dex process
    陶菜菜: @Cloud_568d 我没有遇到过。

    如果觉得是build系统的问题,你可以换个机器试下,或者更改下build的vm参数配置等

    如果觉得是插件逻辑的问题,可以通过在关键节点断点或者log输出等方式,分段定位问题。
    e0cdb39ac23f:--stacktrace定位到的并不是自身插件代码的问题。我试过如果asm注入少量的类的话就可以正常build。但是一旦需要注入的类较多(超过30+)就会出现这个问题。你那边是否遇到过类似的问题呢?
    陶菜菜:你可以调试下你的gradle插件(https://segmentfault.com/a/1190000008266525)

    也可以通过--stacktrace 等拿到更多的信息然后定位问题
  • happyyy2017:群主 及正在做自动化埋点的同学,我创建了一个自动化埋点技术交流的群,大家可以加群,一起研究一下 。qq群号:514657959
    happyyy2017:群主 看到一定要加群呀
  • 8d2afcd4fc30:能否发个demo 谢谢了 372362624@qq.com
    陶菜菜: @baby浪里个浪 抱歉,没有demo
  • e849eeee044c:做法:
      构造每一个ViewPath节点时可以取view.getId(),看看id的packageId部分是不是系统的(系统资源id以16进制的0x01,0x00开头),如果是,生成ViewPath时屏蔽这段即可.
    这段没有看懂,博主可以解释一下吗?
    陶菜菜:我的意思是:
    1.viewpath中 可以考虑去掉contentview(例如activity.setcontentview所设置的)之前的部分(这部分是fragmwork定义而非我们自己app所有的view)
    2. 如何分辨是android framwork的view还是我们app自己的view呢,可以考虑用资源id的package部分进行分辨。比如0x7f开头的是app的 0x01,0x00等是系统保留的等等
  • e849eeee044c:非常感谢博主的分享,我最近也在做android的自动化埋点,发现有很多问题要解决。如果可以的话,可不可加个qq=931273756,方便一起沟通。
  • w4lle:有两个问题,首先要拿到 View 的唯一Id去生成配置文件,怎么拿到每个view的id呢?每个界面点进去一一点击吗? 另外,假如升级版本布局改变了,那么View 的唯一id也就变了,也就是说要还要跟用户版本绑定,并且要去维护这个配置文件,这也是很麻烦的事情,你们是怎么做的呢?
    w4lle:@陶菜菜 感谢解答:+1:
    陶菜菜:补充一点文中没有提到的:
    对于一些重要的交互控件,(程序员设定了id的控件R.id.xxx),viewpath后面还加了id name(xxx),这种措施也增强了这些控件viewid的稳定性(避免布局轻微改动造成的viewid波动)

    总之,对于布局改动可能造成的viewid波动,我们可以想方设法通过增强viewid的稳定性来避免,但是做到完全杜绝在我看来是不太可能的。。
    陶菜菜:刚看到,回答一下:
    第一个问题:sdk有圈选模式(手机端生成配置项上传配置服务器),因为这个模式内藏在sdk中,所以取viewId的方式和线上采集过程中是同一个过程。ps:我们的配置几乎都是在手机端靠圈选模式做的,包括收集业务数据配置,为imagebutton等起名字等等
    第二个问题:文中也提到了viewid的组成部分:PageName+ViewPath.viewpath在设计之初就通过文中说的一些措施增强稳定性,避免布局轻微改动造成的viewid波动。但是,同一个页面(activity/fragment)大改版,并且页面跟之前的一样(不是新建activity/fragment而是在原有页面中大改)。这种情况是需要跟版本重新配置的,这也是避免不了的,因为收集代码和业务代码是并行的。
    关于维护起来很麻烦倒不见得,通过自动化的方式可以减少这些麻烦,比如数据报警等
  • w4lle:赞!多谢分享!
  • a731318293c7:学到很多,我想问下,文中提到的“相同类型兄弟节点的第几个”是去遍历parent的childView和当前view类型相比吗?那不是每一层都要去比一次,会有速度性能吗?能说下怎么处理的吗
    a731318293c7:@陶菜菜 学到了,谢谢,赞
    陶菜菜: @哞哞哞哇
    陶菜菜:肯定是不能这样实现的~,这是文中未提及的一个实现细节,我是这么做的:
    1. index缓存,用setTag(int key, final Object tag)将得到的index写到view上,一次获取,多次读取
    2. activity.onstart/OnHierarchyChangeListener.onChildViewAdded时写缓存,后续基本上所有的index都可以从view.getTag(int key)中直接获得。(而且写是把parent的child统一按类型分类确定index,若一个层级中parent有n个child,则写操作时间复杂度仅为O(n))
    3. 对于缓存中没有的则会进行比较,确定index后同样会写入缓存
  • SuperStarBoy:@陶菜菜:给个例子吧!每看明白
    陶菜菜: @SuperStarBoy 文中讲的每点都有图示,伪代码或者示例,你想要什么例子
  • SuperStarBoy:能不能给个Demo看一下啊?
  • xkjQ:正好在做这方面的事情,多谢分享
  • 乘风在路上:不错,值得研究
  • 8be220e78a10:赞👍学习了
  • 开发者头条_程序员必装的App:感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/dngt96 欢迎点赞支持!
    欢迎订阅《移动前沿》https://toutiao.io/subject/199192
    陶菜菜: @开发者头条_程序员必装的App 😀多谢

本文标题:Android无埋点数据收集SDK关键技术

本文链接:https://www.haomeiwen.com/subject/divgottx.html