Android自动化埋点技术探索-2

上一篇文章 中,主要介绍了页面浏览事件、APP在前台还是后台这两类事件,在无埋点技术的理论分析和实践,本篇文章是自动化埋点技术探索的第二篇,主要介绍View层点击事件在无埋点技术上的理论分析和实践

View层的构建

在Android开发中,设置视图布局一般会在Activity的onCreate()方法中使用setContentView(),在这个方法里面传入具体的XML布局或者代码布局来生成View界面。XML布局在开发过程中是我们比较熟悉的,代码布局这种实现方式的代码量相较XML布局会变的异常巨大但是非常安全且运行效率会高于XML布局。言归正传,埋点技术最主要的功能,就是对View层上面的控件进行数据统计和分析,因此,了解View层的视图构建也就至关重要。

由于Android将视图展现的功能交给了Activity去完成,实际上View层的UI展现涉及到了Window、DecorView等等。它们之间的关系交互复杂,共同完成视图的显示以及与用户之间的交互;另外,UI视图的变化也是埋点技术讨论的一个焦点,Android针对视图树的变化,提出了ViewTreeObserver,来帮助开发者全局侦听视图树的更改。系统源码是宝贵的学习资源,下面就涉及到的知识点以及部分系统源码进行逐个分析

Window

首先看一下系统源码中Window的类结构,以及方法注释:

/**
 * Abstract base class for a top-level window look and behavior policy.  An
 * instance of this class should be used as the top-level view added to the
 * window manager. It provides standard UI policies such as a background, title
 * area, default key processing, etc.
 *
 * <p>The only existing implementation of this abstract class is
 * android.view.PhoneWindow, which you should instantiate when needing a
 * Window.
 */
public abstract class Window {
  ......
}

首先翻译一下系统源码关于Window的注释:Window是一个抽象基类,用于顶级窗口外观和行为策略。该类的实例应该用作添加到窗口管理器的顶级视图。它提供了标准的UI策略,如后台、标题区域、默认键处理等等

这个抽象类唯一的子类是PhoneWindow,在需要窗口时应该实例化该窗口。

中文的注释还是比较清晰易懂的,好了,这个PhoneWindow又是什么?打开 Android Studio 直接输入 PhoneWindow ,这个类实际上在IDE是找不到的,那这个类在那里?PhoneWindow 的源代码位置在: yousdk\sdk\sources\android-26\com\android\internal\policy\PhoneWindow

PhoneWindow类的内部持有一个 DecorView 的成员变量,而这个DecorView,系统源码也用一句英文注释描述了它的重要性:This is the top-level view of the window, containing the window decor. 翻译过来就是:这是窗口的顶层视图,包含窗口的装饰。因此,可以简单理解DecorView才是 view 的根布局。

DecorView

打开 Android Studio 直接输入 DecorView,IDE提示你会导入一个ViewPager的包,这明显不是我们想要的。那这个类的位置在那里?DecorView 的源代码位置在 yousdk\sdk\sources\android-26\com\android\internal\policy\DecorView,首先看一下系统源码DecorView的类结构:

/** @hide */
public class DecorView extends FrameLayout implements RootViewSurfaceTaker,
 WindowCallbacks {
  ......
}

可以看到DecorView是FrameLayout的子类,上面说到了DecorView可以被认为是Android视图树的根节点视图。DecorView作为顶级View,它内部包含一个LinearLayout,这个LinearLayout里面有三个部分,上面是个通知栏,中间是标题栏(根据Theme设置,有的布局没有),下面的是内容栏。合在一起代表的就是整个Window界面

注意:这个LinearLayout里有两个FrameLayout子元素

DcorView

20:为标题栏显示界面。只有一个TextView显示应用的名称。也可以自定义标题栏,载入后的自定义标题栏View将加入FrameLayout中。

21:为内容栏显示界面。就是setContentView()方法载入的布局界面,加入其中。

31:就是开发者自定义的XML,也就是说该布局会嵌入到里面的 FrameLayout

另外,这个 FrameLayout的Id是系统配置的,也就是android.R.id.content

ViewTreeObserver

首先看一下系统源码中ViewTreeObserver的类结构,以及方法注释:

/**
 * A view tree observer is used to register listeners that can be notified of global
 * changes in the view tree. Such global events include, but are not limited to,
 * layout of the whole tree, beginning of the drawing pass, touch mode change....
 *
 * A ViewTreeObserver should never be instantiated by applications as it is provided
 * by the views hierarchy. Refer to {@link android.view.View#getViewTreeObserver()}
 * for more information.
 */
public final class ViewTreeObserver {
    ......
}

首先翻译一下系统源码关于ViewTreeObserver 的注释:这个视图树的观察是用来注册可以通知全局监听器中视图树的更改。此类所有的事件包括但不限于:整棵树的布局,开始画,触摸模式更改等等。 ViewTreeObserver不应该由应用程序实例化,因为它是由视图层次结构提供的。如果想实例化可以参考getViewTreeObserver( )获得更多信息。

ViewTreeObserver全局监听又是如何实现的?

ViewTreeObserver内部提供了View的多种监听,每一种监听都有一个内部类接口与之对应,内部类接口全部保存在CopyOnWriteArrayList中,通过ViewTreeObserver.addXXXListener()来添加这些监听,源码如下:

public final class ViewTreeObserver {
    // Recursive listeners use CopyOnWriteArrayList
    private CopyOnWriteArrayList<OnWindowFocusChangeListener> mOnWindowFocusListeners;
    private CopyOnWriteArrayList<OnWindowAttachListener> mOnWindowAttachListeners;
    private CopyOnWriteArrayList<OnGlobalFocusChangeListener> mOnGlobalFocusListeners;
    private CopyOnWriteArrayList<OnTouchModeChangeListener> mOnTouchModeChangeListeners;
    private CopyOnWriteArrayList<OnEnterAnimationCompleteListener>
            mOnEnterAnimationCompleteListeners;

    // Non-recursive listeners use CopyOnWriteArray
    // Any listener invoked from ViewRootImpl.performTraversals() should not be recursive
    private CopyOnWriteArray<OnGlobalLayoutListener> mOnGlobalLayoutListeners;
    private CopyOnWriteArray<OnComputeInternalInsetsListener> mOnComputeInternalInsetsListeners;
    private CopyOnWriteArray<OnScrollChangedListener> mOnScrollChangedListeners;
    private CopyOnWriteArray<OnPreDrawListener> mOnPreDrawListeners;
    private CopyOnWriteArray<OnWindowShownListener> mOnWindowShownListeners;
    ......
}

以OnGlobalLayoutListener为例,首先是定义接口:

public interface OnGlobalLayoutListener {
        /**
         * Callback method to be invoked when the global layout state or the visibility of views
         * within the view tree changes
         */
        public void onGlobalLayout();
    }

这个注释很清楚: 当全局布局状态或视图的可见性发生改变时,调用回调接口。这个接口有一个回调方法 onGlobalLayout(),在开发中就是通过重写该方法,实现自己的逻辑。

接着将OnGlobalLayoutListener 添加到CopyOnWriteArray数组中:

/**
     * Register a callback to be invoked when the global layout state or the visibility of views
     * within the view tree changes
     *
     * @param listener The callback to add
     *
     * @throws IllegalStateException If {@link #isAlive()} returns false
     */
    public void addOnGlobalLayoutListener(OnGlobalLayoutListener listener) {
        checkIsAlive();

        if (mOnGlobalLayoutListeners == null) {
            mOnGlobalLayoutListeners = new CopyOnWriteArray<OnGlobalLayoutListener>();
        }
        //在这里进行添加
        mOnGlobalLayoutListeners.add(listener);
    }

这里通过 add 方法,添加一个对 view 布局发生改变的监听,传入的也就是 OnGlobalLayoutListener 接口对象,重写接口的 onGlobalLayout() 方法,系统会将传入的 OnGlobalLayoutListener 存在集合中。

既然有添加监听,与之对应的就有移除监听:

   /**
     * Remove a previously installed global layout callback
     *
     * @param victim The callback to remove
     *
     * @throws IllegalStateException If {@link #isAlive()} returns false
     * 
     * @see #addOnGlobalLayoutListener(OnGlobalLayoutListener)
     */
    public void removeOnGlobalLayoutListener(OnGlobalLayoutListener victim) {
        checkIsAlive();
        if (mOnGlobalLayoutListeners == null) {
            return;
        }
        mOnGlobalLayoutListeners.remove(victim);
    }

既然有添加和删除,那么应该还有事件的分发,

    /**
     * Notifies registered listeners that a global layout happened. This can be called
     * manually if you are forcing a layout on a View or a hierarchy of Views that are
     * not attached to a Window or in the GONE state.
     */
    public final void dispatchOnGlobalLayout() {
        // NOTE: because of the use of CopyOnWriteArrayList, we *must* use an iterator to
        // perform the dispatching. The iterator is a safe guard against listeners that
        // could mutate the list by calling the various add/remove methods. This prevents
        // the array from being modified while we iterate it.
        final CopyOnWriteArray<OnGlobalLayoutListener> listeners = mOnGlobalLayoutListeners;
        if (listeners != null && listeners.size() > 0) {
            CopyOnWriteArray.Access<OnGlobalLayoutListener> access = listeners.start();
            try {
                int count = access.size();
                for (int i = 0; i < count; i++) {
                    access.get(i).onGlobalLayout();
                }
            } finally {
                listeners.end();
            }
        }
    }

dispatchOnGlobalLayout 方法,会遍历存放 OnLayoutGlobalListener 对象的集合,然后调用 OnLayoutGlobalListener 对象的 onGlobalLayout() 方法,通知程序该事件发生。

本质上来说,ViewTreeObserver和View的关系 就是 熟悉的 观察者模式。当View发生变化的时候,及时通知ViewTreeObserver,因此,View的角色是被观察者、ViewTreeObserver的角色是 观察者;ViewTreeObserver通过addXxxListener 添加想要观察的事件,就拿上面的例子来说,想要观察 View 的全局布局状态变化,就是 view.getViewTreeObserver().addOnGlobalLayoutListener(…)

问题来了,View发生变化的时候,又是如何来通知 ViewTreeObserver?

这个问题的答案,需要分析 ViewRootImpl#performTraversals() 方法内部的代码,在 View 完成 layout(布局) 过程之后,通过调用 dispatchOnGlobalLayout() 方法,通知观察者“全局布局改变”事件发生了;这个方法调用以后,依次取出了集合中OnGlobalLayoutListener 接口对象,并调用接口的 onGlobalLayout() 方法,至此完成了事件的传递

总结

综上所述,针对View的点击事件在无埋点技术的实现就有以下步骤:

步骤一

在应用程序自定义的 Application 对象的 onCreate() 方法中初始化埋点 SDK,并传入当前的 Application 对象。SDK拿到Application对象之后,就可以通过registerActivityLifecycleCallback 方法注册 ActivityLife-cycleCallbacks。这样 SDK 就能对App 中所有Activity的生命周期事件进行集中监控。

注册之后,就可以拿到当前正在显示的 Activity,通过activity.findViewById(android.R.id.content) 方法就可以拿到整个内容区域对应的 View ( 是一个 FrameLayout )

然后 SDK 逐层遍历这个 RootView,并判断当前 View 是否设置了onClickLisenter,如果设置 onClickLisenter 且不是自定义的 WrapperOnClickListener 类型,则通过自定义的 WrapperOnClickListener 代理(如何代理,这个会在后面的文章做详细叙述)当前 View 设置的View.OnClickListener,然后重新设置 View onClickLisenter 为 WrapperOnClickListener。

Wrap-perOnClickListener 实现了 View.OnClickListener 接口,在WrapperOnClickListener 的 onClick 里会先调用 View的原有 OnClickListener 处理逻辑,然后再调用埋点代码,这样就实现了“插入”埋点代码,从而达到自动埋点的效果。

步骤二

但是通过步骤一 android.R.id.content 获取到的RootView 是不包含 Activity 标题栏的,也就是不包括MenuItem 的父容器。所以遍历 RootView 时是无法遍历到 MenuItem 的,因此无法代理其OnClickListener,从而导致无法采集 MenuItem 的点击事件。

所以,需要将之前方案中 activity.findViewById(android.R.id.content) 换成 activity.getWindow().get-DecorView(),这样就可以遍历到 MenuItem 、也就可以采集到 MenuItem 上面的点击事件。

步骤三

通过继续测试可以发现,步骤二有一个问题,即:目前该方案是无法采集 onResume() 生命周期之后动态创建的 View 的点击事件的。比如点击一个按钮,在其OnClickListener 里动态创建 一 个 Button,然后通过addView 添加到页面上此时,点击这个动态创建的 Button,是没有点击事件的。

这是因为 ,上面的步骤是在Activity 的 onResume 生命周期之前,去遍历整个 View 并代理其 View.OnClickListener 的。如果在 onResume 之后动态创建的 View,第一次是无法遍历到的,紧接着添加View之后,没有再次去遍历一次,所以它的 OnClickListener 就没有被代理过,因此这种情况下是无法采集其点击事件。

所以,基于这个问题,可以给 View 添加一个 ViewTreeObserver.OnGlobal-LayoutListener 监听,当收到 onGlobalLayout 回调时(即视图树的布局发生变化,比如新的 view 被创建),重新去遍历一次 View,然后找到那些没有被代理过 mOnClickListener 的 View 并进行代理。即可解决步骤二的问题,完成无埋点统计view的点击事件

文章部分内容选自:神策数据用户行为洞察研究院《安卓全埋点技术白皮书》,感谢分享!

如果这篇文章对您有开发or学习上的些许帮助,希望各位看官留下宝贵的star,谢谢。

Ps:著作权归作者所有,转载请注明作者, 商业转载请联系作者获得授权,非商业转载请注明出处(开头或结尾请添加转载出处,添加原文url地址),文章请勿滥用,也希望大家尊重笔者的劳动成果!

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

推荐阅读更多精彩内容