网易HubbleData的Android SDK在代码埋点整体架构的基础上新增了无埋点功能,本文主要针对网易HubbleData在Android SDK中的无埋点实践进行分享。重点死磕无埋点两大核心技术:1. View的唯一ID;2. 无埋点实现(代理监听方案和gradle插件方案)。

1 背景

网易HubbleData是一个洞察用户行为的数据分析系统,提供一套完整的数据解决方案。一个典型的数据平台,对于数据的处理,是由如下的5个步骤组成的:

1-data_process

其中,第一个步骤,也即数据采集是最核心的问题。网易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采用手动代码埋点的方案,主要关注的是事件模型、埋点接口、上报策略等问题。整体架构如下图所示:

2-code_structrue

代码埋点虽然使用起来灵活,但是开发成本较高,并且一旦上线就很难修改。参考业界先进方案并结合网易公司内部产品的埋点需求,网易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,无则不拼接。
1
parent1[index]#id/parent2[index]#id/.../view[index]#id

简单示例如下:

3-view_path_example

2.1.3 ViewID优化

考虑到在实际布局中有可能存在一些动态插入、删除的控件,或者说控件被复用,都可能引起View Path的变化,从而导致ViewID不唯一。为了保证ViewID的一致性,我们从以下几个方面着手,对ViewID进行了一定程度地优化。

(1) Index

3-viewPath_example_bad.png

如上图所示,当页面布局发生动态变化时,比如说删除一个子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不受影响。

4-index_remove_example

(2) 可复用View

先来看一个应用场景:

5-recycleView

如图所示,当ListView上滑时,屏幕下方即将显示的<元素6>其实复用了屏幕上方即将滑出的<元素0>,也就是说<元素6>与<元素0>的index均为0,在这种情况下,我们无法通过前述index的定义来区分这两个列表Item。

7-example_position_versus_index

所幸,针对这种情况,我们可以用position的取值进行区分,也就是令index = position。

通过实践发现,发生上述复用情形的View主要有以下几类:AdapterView、RecyclerView和ViewPager,其api都提供了获取position的接口。

a. 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
2
3
4
// 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);

示例如下:

6-ExpandListView

b. V7-RecyclerView

RecyclerView的情形比较简单,可通过调用getChildPositiongetChildAdapterPosition获取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动态变化。

8-mainpage_frags_init_orders_uedc

也就是说,Fragment初始化顺序影响ViewID。而前述Index优化方案并不能解决这一问题。

Fragment节点特殊处理

针对Fragment初始化顺序影响ViewID的问题,我们采用的解决方案是:

如果能够获取到Fragment实例的类名,则使用Fragment实例的类名替换View Path中的Fragment,并设置[index]为特殊标记[-]。例如:使用控件篇Tab对应的Fragment实例ControlSetFragment以及特殊标记[-]替换原View Path中的Fragment[3]

10-fragment_classname_replace

如何获取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中的。

11-viewpager_positon_0_7_uedc

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数据搜集代码。

12-gradle_plugin_theory

监控哪些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子类的onResumedonPausedonHiddenChangedsetFragmentUserVisibleHint方法的字节码进行修改,添加数据采集代码。

目标效果:

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 ,欢迎一起研究。