得到Android团队无埋点方案

概述

客户端埋点是数据收集的最基本手段,但由于业务迭代速度很快,手动埋点方案虽然灵活多变,但是极大的增加了客户端开发人员的工作量。开发完成业务功能需要花费很大的精力处理埋点事宜,而且随着迭代版本,埋点的数量会越来越多,这些老旧埋点的维护工作也需要付出不小的努力。并且,手动埋点的正确性同样是个极度考验开发人员的耐性和认真程度的问题,在所难免会出现这样那样的问题。所以,如果能够研发出一款不需要或者很少需要开发人员介入就能实现根据不同业务场景埋点的功能sdk对于提高版本迭代速度和开发人员的幸福感绝对是一件非常有价值的事情。

更大的价值还在于,不需要开发人员介入,运营或者用研的同学就可以随时动态调整数据收集方案。

纵观目前比较成熟的无埋点方案,存在着如下问题:

问题1:通过XPath定位控件,理论上可行,但实践表明这个方案的复杂度非常高,尤其对于处理像GridView,ListView,RecyclerView的控件更是捉襟见肘。不仅如此,生成xpath的过程本身就是一个及其耗费性能的行为,它需要遍历view tree,存储非常多的路径信息到view上。

问题2:获取控件对应的数据是通过 data path的方式解决,每次添加新埋点时,如果需要上报数据,那用研人员需要和开发人员逐一确认控件数据的path,这极大的限制了客户端开发的自由度,即使简单的重构也会使得之前配置的埋点信息失效。

针对如上问题,我们经过深挖内在逻辑关系及对比优劣,总结出了一套更灵活,更合理的无埋点方案,下面分三个部分逐一介绍实现考量及内部机制。

定位与用户产生交互行为的目标控件

关于定位交互控件,我们也考虑过xpath的方案,但是考虑到其实现的复杂度,不灵活和各种潜在的问题,我们抛弃了这种方案。通过反复的阅读View的touch事件处理相关的源码,我们终于发现了解决问题的更好的方式。

ViewGroup中有一个TouchTarget 类型的变量 mFirstTouchTarget,表示消费当前触摸事件的控件列表。例如,点击屏幕上一个按钮,那么按钮所在ViewGroup的mFirstTouchTarget 变量就指向这个按钮。当ViewGroup派发触摸事件时,他会首先判断变量mFirstTouchTarget是否存在,如果变量存在,会循环遍历TouchTarget链表元素,找到能处理该事件的View并将MotionEvent 派发给该View。如果不存在TouchTarget,ViewGroup 会循环遍历所有child view,直到找到一个能处理该事件的View,并将该View作为first touch target 赋值给mFirstTouchTarget。

当用户触发Down事件时,会执行如下逻辑,寻找消费当前事件的TouchTarget。

if (actionMasked == MotionEvent.ACTION_DOWN){
   //如果是down事件,遍历child,找到TouchTarget
   ..
   ..
   final View[] children = mChildren;
   for (int i = childrenCount - 1; i >= 0; i--) {
      final int childIndex = getAndVerifyPreorderedIndexchildrenCount, i, customOrder);
      final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
      ..
      ..
      if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
         // child 消费了触摸事件
         ..
         ..
         // 根据消费了触摸事件的View创建TouchTarget
          newTouchTarget = addTouchTarget(child, idBitsToAssign);
         ..
         ..
         break;
     }
}

当触发Down事件并且找到TouchTarget,或者触发非Down事件时,执行如下处理逻辑。

if (mFirstTouchTarget == null) {
   // No touch targets so treat this as an ordinary view.
   handled = dispatchTransformedTouchEvent(ev, canceled, null,TouchTarget.ALL_POINTER_IDS);
} else {
   //Down事件发生时找到TouchTarget,或者非Down事件直接执行如下逻辑

   // 将事件派发给TouchTarget表示的View
   TouchTarget predecessor = null;
   TouchTarget target = mFirstTouchTarget;

   while (target != null) {
       final TouchTarget next = target.next;

       if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
           handled = true;
       } else {
           final boolean cancelChild = resetCancelNextUpFlag(target.child)|| intercepted;

           if (dispatchTransformedTouchEvent(ev, cancelChild,target.child,target.pointerIdBits)) {
              //指定TouchTarget对应的View正确消费了事件
               handled = true;
            }
            ..
            ..
        }
    ..
    ..
    }
}

提示:由于消费触摸事件的控件可能为多个(splitting touch events),所以需要遍历TouchTarget链表。引用官方原文:This behavior is enabled by default for applications that target an SDK version of 11 (Honeycomb) or newer. On earlier platform versions this feature was not supported and this method is a no-op.

MotionEvents may be split and dispatched to different child views depending on where each pointer initially went down. This allows for user interactions such as scrolling two panes of content independently, chording of buttons, and performing independent gestures on different pieces of content.

利用ViewGroup的这种事件处理机制,我们通过在Activity的window上调用window.setCallback() 接管窗口的事件派发,并在dispatchTouchEvent处理函数中添加analyzeMotionEvent()方法。如果接收到up事件,执行处理逻辑,通过ViewGroup TouchTarget链表,找到本次交互行为的目标控件。拿到控件后,通过 Activity的类名+控件所在的layout文件名+控件id对应的资源名,我们就可以确定目标控件的唯一标识。

dispatchTouchEvent源码如下:

@Override
   public boolean dispatchTouchEvent(MotionEvent ev) {
       if (!AutoPointer.isAutoPointEnable()) {
           return super.dispatchTouchEvent(ev);
       }

       int actionMasked = ev.getActionMasked();

       if (actionMasked != MotionEvent.ACTION_UP) {
           return super.dispatchTouchEvent(ev);
       }

       long t = System.currentTimeMillis();
       analyzeMotionEvent();

       //非线上版本,打印执行时间
       if (!AutoPointer.isOnlineEnv()) {
           long time = System.currentTimeMillis() - t;
           DDLogger.d(TAG, String.format(Locale.CHINA, "处理时间:%d 毫秒", time));
       }

       return super.dispatchTouchEvent(ev);
   }

analyzeMotionEvent源码如下:

/**
    * 分析用户的点击行为
    */
   private void analyzeMotionEvent() {
       if (mViewRef == null || mViewRef.get() == null) {
           DDLogger.e(TAG, "window is null");
           return;
       }

       ViewGroup decorView = (ViewGroup) mViewRef.get();
       int content_id = android.R.id.content;
       ViewGroup content = (ViewGroup) decorView.findViewById(content_id);
       if (content == null) {
           content = decorView; //对于非Activity DecorView 的情况处理
       }

       Pair<View, Object> targets = findActionTargets(content);
       if (targets == null) {
           DDLogger.e(TAG, "has no action targets!!!");
           return;
       }

       //发送任务在单线程池中
       int hashcode = targets.first.hashCode();
       if (mIgnoreViews.contains(hashcode)) return;

       PointerExecutor.getHandler().post(PointPostAction.create(targets.first, targets.second));
   }

获取与目标控件对应的业务数据

对于获取控件数据,为了最大化获取速度,我们在系统中配置了多个数据获取策略。如果目标控件是AbsListView或者RecyclerView 的child view及child view 的chid,那我们可以通过child view在adapter中的位置获取到我们想要的数据。这种方式能够处理大多数页面控件数据的获取问题。系统配置策略的方式如下:

private static Map<String, DataStrategy> mStrategies = new HashMap<>();

   static {
       //configure RecyclerView and subclass's search strategy
       DataStrategy recyclerViewStrategy = new RecyclerViewStrategy();
       mStrategies.put("RecyclerView", recyclerViewStrategy);
       mStrategies.put("DDCollectionView", recyclerViewStrategy);

       //ExpandableListView
       DataStrategy EListViewStrategy = new ExpandableListViewStrategy();
       mStrategies.put("ExpandableListView", EListViewStrategy);
       mStrategies.put("DDExpandableListView", EListViewStrategy);

       DataStrategy adapterViewStrategy = new AdapterViewStrategy();
       //ListView
       mStrategies.put("ListView", adapterViewStrategy);
       mStrategies.put("DDListView", adapterViewStrategy);
       mStrategies.put("ListViewCompat", adapterViewStrategy);

       //GridView
       mStrategies.put("GridView", adapterViewStrategy);
       mStrategies.put("DDGridView", adapterViewStrategy);

       //ViewPager
       DataStrategy viewPagerStrategy = new ViewPagerStrategy();
       mStrategies.put("ViewPager", viewPagerStrategy);

       //TabLayout
       DataStrategy tabLayoutStrategy = new TabLayoutStrategy();
       mStrategies.put("TabLayout", tabLayoutStrategy);
   }

对于那些完全自定义布局绘制的页面,例如个人中心等页面,业务开发人员需要通过框架api建立一个控件树到数据的映射关系,这样框架在需要获取数据时,通过这个关系就可以非常容易的获取到想要的数据。

/**
    * 配制自定义布局的数据绑定关系,自定义布局内的任何
    * 控件发生点击行为时,发送的埋点都会携带改数据
    *
    * @param id
    * @param object
    * @return
    */
   @NonNull
   @Override
   public DataConfigureImp configLayoutData(@IdRes int id, @NonNull Object object) {
       Preconditions.checkNotNull(object);

       mDataLayout.put(id, object);
       return this;
   }

根据TouchTarget找到数据获取策略或者数据映射关系,我们可以非常简单的获取到绑定的数据,获取数据的算法如下:

if (strategyView != null) {
           Object data = strategy.fetchTargetData(strategyView);

           return Pair.create(touchTarget, data);
       }

       if (configDataView != null) {
           return Pair.create(touchTarget, mDataLayout.get(configId));
       }

       //解决自定义布局的数据绑定问题
       if (dataAdapter != null) {
           return Pair.create(touchTarget, dataAdapter.getData());
       }

实现埋点的动态可配置

在测试环境下,用研人员会通过手动模拟点击的方式获取sdk上报的控件唯一id和数据信息,在确认id,和数据的正确性之后,需要手动配置id和埋点事件的对应关系,及上报的数据字段,并存储到配置仓库。在线上环境,当用户启动app会拉取配置信息并加载到内存。这样,当用户触发点击行为时,会根据第一步获取的id信息查询配置,如果在配置中查到对应的条目,会将对应的事件及数据上报到服务器。

为了处理配置下拉失败无法发送埋点的情况,我们需要将同样的配置放在主项目的assets目录下,每次启动app请求配置接口判断配置信息是否发生变化,如果配置没有变化,直接使用assets中的配置文件,否则,下拉最新配置,使用最新的埋点配置信息。

无痕埋点方案对现有项目的约束

使用无埋点sdk需要遵循一定的开发规范,关于具体的开发规范请查看工程README。为了确保项目编码的规范性,我们开发了一系列lint检查规则来帮助发现错误。

lint 工程代码 https://github.com/jessie345/CustomLintRules.git

集成lint功能 https://github.com/jessie345/CustomLintsUsage.git

继续优化

目前,集成这个无埋点方案有一些使用约束并且需要在主项目中添加一些特定的配置函数。下一步需要做的就是解耦。通过javasist技术,尽量将所有约束迁移到用动态技术保证,而不是通过lint规范,将其侵入性降到最低。

至此,无埋点sdk的核心运作机制已经全部梳理清楚。

原文发布于微信公众号 - 我就是马云飞(coding_ma)

原文发表时间:2017-11-21

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Android干货园

Android 百度地图SDK 实现获取周边位置POI

版权声明:本文为博主原创文章,转载请标明出处。 https://blog.csdn.net/lyhhj/article/details/49...

19920
来自专栏Flutter入门到实战

Android M Launcher3主流程源码浅析

关于Launcher是啥的问题我想这里就没必要再强调了。由于一些原因迫使最近开始需要研究一下Launcher3源码,为了不再像以前那么傻逼(研究Settings...

22120
来自专栏程序员互动联盟

【专业技术第十讲】嵌入式系统的中断流程剖析

存在问题: 搞嵌入式特别是底层,常常提到中断,中断时干什么的呢? 解决方案: 做嵌入式肯定要了解中断。本文根据实例详细介绍中断过程,包括软件和硬件方面。 示例:...

47660
来自专栏格子的个人博客

Java定时发布文章简单方案

早上迷迷糊糊地开始了春节后的一天上班日程,脑袋还在噼里啪啦放烟花,项目管理部和SEO小伙伴就提了一桶凉水过来,往我头上一浇,瞬间烟花都湮灭了。

19610
来自专栏tkokof 的技术,小趣及杂念

“疑难杂症”二记

  今日开发遇到些许问题,大抵都很琐碎,但却又颇为扰人,在此随便一记,提醒自己的同时,也可以方便方便遇到类似情况的朋友~

7720
来自专栏Seebug漏洞平台

TCTF/0CTF2018 XSS Writeup

刚刚4月过去的TCTF/0CTF2018一如既往的给了我们惊喜,其中最大的惊喜莫过于多道xss中Bypass CSP的题目,其中有很多应用于现代网站的防御思路。...

17530
来自专栏非著名程序员

Retrofit--使用Retrofit时怎样去设置OKHttp

? 投稿作者:黄海杰 原文链接:http://blog.csdn.net/lyhhj/article/details/51388147 特别声明:本文为黄海...

20790
来自专栏技术墨客

Ubuntu修改分辨率 转

通常情况下,图形界面的发行版 linux 可以在 Setting->Device->Display 中直接设置多个屏幕的分辨率。但是坑总是无处不在的,有时候明明...

2K40
来自专栏编程思想之路

Android6.0源码分析之menu键弹出popupwindow菜单流程分析

例如上图,在按下菜单键后会弹出对应的菜单选项,准确来说,是在菜单键弹起后出现的一个popupwindow,那么从菜单键弹起到popupwindow创建所涉及到的...

50460
来自专栏葡萄城控件技术团队

提高性能:用RequireJS优化Wijmo Web页面

上周Wijmo 2014 V2版本刚刚发布(下载地址),  有网友下载后发现仅仅使用了40个Widgets的一小部分,还需要加载全部的jquery.wijmo-...

21650

扫码关注云+社区

领取腾讯云代金券