得到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的核心运作机制已经全部梳理清楚。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容