全局监控 click事件的四种方式

本文主要给大家分享如何在全局上去监听 click 点击事件,并做些通用处理或是拦截。使用场景可能就是具体的全局防快速重复点击,或是通用打点分析上报,用户行为监控等。以下将以四种不同的思路和实现方式去监控全局的点击操作,由简单到复杂逐一讲解。

方式一,适配监听接口,预留全局处理接口并作为所有监听器的基类使用

抽象出公共基类监听对象,可预留拦截机制和通用点击处理,简要代码如下:

public abstract class CustClickListener implements View.OnClickListener{
    @Override
    public void onClick(View view) {
        if(!interceptViewClick(view)){
            onViewClick(view);
        }
    }
    protected boolean interceptViewClick(View view){
        //TODO:这里可做一此通用的处理如打点,或拦截等。
        return false;
    }
    protected abstract void onViewClick(View view);
}

使用方式之一匿名对象作为公共监听器

CustClickListener mClickListener = new CustClickListener() {
    @Override
    protected void onViewClick(View view) {
        Toast.makeText(CustActvity.this, view.toString(), Toast.LENGTH_SHORT).show();
    }
};

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_login);
    findViewById(R.id.button).setOnClickListener(mClickListener);
}

这种方式比较简单,无兼容问题,但是需要自始至终都要使用基于基类的监听器对象,对开发者约束比较大。适用于新项目之初就有此使用约定。对于老代码重构工作量比较大,而且如果接入第三方墨盒模块就无能为力了。

方式二,反射代理,适时偷梁换柱开发者无感知,在适配包装器里做通用处理。

以下是代理接口和内置监听适配器,全局的监听接口需要实现IProxyClickListener并设置到内置适配器WrapClickListener

public interface IProxyClickListener {

    boolean onProxyClick(WrapClickListener wrap, View v);
    
    class WrapClickListener implements View.OnClickListener {
    
        IProxyClickListener mProxyListener;
        View.OnClickListener mBaseListener;
        
        public WrapClickListener(View.OnClickListener l, IProxyClickListener proxyListener) {
            mBaseListener = l;
            mProxyListener = proxyListener;
        }
        
        @Override
        public void onClick(View v) {
            boolean handled = mProxyListener == null ? false : mProxyListener.onProxyClick(WrapClickListener.this, v);
            if (!handled && mBaseListener != null) {
                mBaseListener.onClick(v);
            }
        }
    }
}

我们需要选择一个时机对所有设置有监听器的 View做监听代理的 hook .这个时机可以对 Activity 的根View添加一个视图变化监听(当然也可选择在 Activity 的 DOWN 事件的分发时机):

rootView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
     @Override
     public void onGlobalLayout() {
        hookViews(rootView, 0)      
     }
});
注:以上为了方便匿名注册了监听,实际使用在 Activity 退出时要反注册掉。

在进行代理前先要反射获取View监听器相关的 Method 和 Field 对象如下:

public void init() {
    if (sHookMethod == null) {
        try {
            Class viewClass = Class.forName("android.view.View");
            if (viewClass != null) {
                sHookMethod = viewClass.getDeclaredMethod("getListenerInfo");
                if (sHookMethod != null) {
                    sHookMethod.setAccessible(true);
                }
            }
        } catch (Exception e) {
            reportError(e, "init");
        }
    }
    if (sHookField == null) {
        try {
            Class listenerInfoClass = Class.forName("android.view.View$ListenerInfo");
            if (listenerInfoClass != null) {
                sHookField = listenerInfoClass.getDeclaredField("mOnClickListener");
                if (sHookField != null) {
                    sHookField.setAccessible(true);
                }
            }
        } catch (Exception e) {
            reportError(e, "init");
        }
    }
}

只有保证了sHookMethodsHookField成功获取才能进入下一步递归去设置监听代理偷梁换柱。以下为具体实现递归设置代理监听的过程。其中mInnerClickProxy为外部传入的的全局处理点击事件的代理接口。

private void hookViews(View view, int recycledContainerDeep) {
    if (view.getVisibility() == View.VISIBLE) {
        boolean forceHook = recycledContainerDeep == 1;
        if (view instanceof ViewGroup) {
            boolean existAncestorRecycle = recycledContainerDeep > 0;
            ViewGroup p = (ViewGroup) view;
            if (!(p instanceof AbsListView || p instanceof RecyclerView) || existAncestorRecycle) {
                hookClickListener(view, recycledContainerDeep, forceHook);
                if (existAncestorRecycle) {
                    recycledContainerDeep++;
                }
            } else {
                recycledContainerDeep = 1;
            }
            int childCount = p.getChildCount();
            for (int i = 0; i < childCount; i++) {
                View child = p.getChildAt(i);
                hookViews(child, recycledContainerDeep);
            }
        } else {
            hookClickListener(view, recycledContainerDeep, forceHook);
        }
    }
}

private void hookClickListener(View view, int recycledContainerDeep, boolean forceHook) {
    boolean needHook = forceHook;
    if (!needHook) {
        needHook = view.isClickable();
        if (needHook && recycledContainerDeep == 0) {
            needHook = view.getTag(mPrivateTagKey) == null;
        }
    }
    if (needHook) {
        try {
            Object getListenerInfo = sHookMethod.invoke(view);
            View.OnClickListener baseClickListener = getListenerInfo == null ? null : (View.OnClickListener) sHookField.get(getListenerInfo);//获取已设置过的监听器
            if ((baseClickListener != null && !(baseClickListener instanceof IProxyClickListener.WrapClickListener))) {
                sHookField.set(getListenerInfo, new IProxyClickListener.WrapClickListener(baseClickListener, mInnerClickProxy));
                view.setTag(mPrivateTagKey, recycledContainerDeep);
            }
        } catch (Exception e) {
            reportError(e,"hook");
        }
    }
}

以上深度优先从 Activity 的根 View 进行递归设置监听。只会对原来的 View 本身有点击的事件监听器的进行设置,成功设置后还会对操作的 View 设置一个 tag 标志表明已经设置了代理,避免每次变化重复设置。这个 tag 具有一定的含意,记录该 View 相对可能存在的可回收容器的层级数。因为对于像AbsListViewRecyclerView的直接子 View 是需要强制重新绑定代理的,因为它们的复用机制可能被重新设置了监听。

此方式实现实现稍微复杂,但是实现效果比较好,对开发者无感知进行监听器的hook代理。反射效率上也可以接受速度比较快无影响。对任何设置了监听器的 View都有效。 然而AbsListView的Item点击无效,因为它的点击事件不是通过 onClick 实现的,除非不是用 setItemOnClick 而是自己绑定 click 事件。

方式三,通过AccessibilityDelegate捕获点击事件。

分析View的源码在处理点击事件的回调时调用了 View.performClick 方法,内部调用了sendAccessibilityEvent而此方法有个托管接口mAccessibilityDelegate可以由外部处理所有的 AccessibilityEvent. 正好此托管接口的设置也是开放的setAccessibilityDelegate,如以下 View 源码关键片段。

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    return result;
}
public void sendAccessibilityEvent(int eventType) {
    if (mAccessibilityDelegate != null) {
        mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
    } else {
        sendAccessibilityEventInternal(eventType);
    }
}

public void setAccessibilityDelegate(@Nullable AccessibilityDelegate delegate) {
    mAccessibilityDelegate = delegate;
}

基于此原理我们可在某个时机给所有的 View 注册我们自己的AccessibilityDelegate去监听系统行为事件,简要实现代码如下。

public class ViewClickTracker extends View.AccessibilityDelegate {
    boolean mInstalled = false;
    WeakReference<View> mRootView = null;
    ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener = null;

    public ViewClickTracker(View rootView) {
        if (rootView != null && rootView.getViewTreeObserver() != null) {
            mRootView = new WeakReference(rootView);
            mOnGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
                @Override
                public void onGlobalLayout() {
                    View root = mRootView == null ? null : mRootView.get();
                    boolean install = ;
                    if (root != null && root.getViewTreeObserver() != null && root.getViewTreeObserver().isAlive()) {
                        try {
                            installAccessibilityDelegate(root);
                            if (!mInstalled) {
                                mInstalled = true;
                            }
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    } else {
                        destroyInner(false);
                    }
                }
            };
            rootView.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener);
        }
    }

    private void installAccessibilityDelegate(View view) {
        if (view != null) {
            view.setAccessibilityDelegate(ViewClickTracker.this);
            if (view instanceof ViewGroup) {
                ViewGroup parent = (ViewGroup) view;
                int count = parent.getChildCount();
                for (int i = 0; i < count; i++) {
                    View child = parent.getChildAt(i);
                    if (child.getVisibility() != View.GONE) {
                        installAccessibilityDelegate(child);
                    }
                }
            }
        }
    }

    @Override
    public void sendAccessibilityEvent(View host, int eventType) {
        super.sendAccessibilityEvent(host, eventType);
        if (AccessibilityEvent.TYPE_VIEW_CLICKED == eventType && host != null) {
         //TODO 这里处理通用的点击事件,host 即为相应被点击的 View.
        }
    }
}

以上实现比较巧妙,在监测到window上全局视图树发生变化后递归的给所有的View安装AccessibilityDelegate。经测试大多数厂商的机型和版本都是可以的,然而部分机型无法成功捕获监控到点击事件,所以不推荐使用。

方式四,通过分析 Activity 的 dispatchTouchEvent 事件并查找事件接受的目标 View。

这个方式初看有点匪夷所思,但是一系列触屏事件发生后总归要有一个组件消耗了它,查看ViewGroup关键源码如下:

// First touch target in the linked list of touch targets.
private TouchTarget mFirstTouchTarget;

public boolean dispatchTouchEvent(MotionEvent ev) {
    ......
    if (newTouchTarget == null && childrenCount != 0) {
        for (int i = childrenCount - 1; i >= 0; i--) {
            if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                newTouchTarget = addTouchTarget(child, idBitsToAssign);
                alreadyDispatchedToNewTouchTarget = true;
                break;
            }
        }
    }
    ......
    // Dispatch to touch targets.
    if (mFirstTouchTarget == null) {
        // No touch targets so treat this as an ordinary view.
        handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS);
    } else {
        // Dispatch to touch targets, excluding the new touch target if we already
        // dispatched to it.  Cancel touch targets if necessary.
        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 (cancelChild) {
                    if (predecessor == null) {
                        mFirstTouchTarget = next;
                    } else {
                        predecessor.next = next;
                    }
                    target.recycle();
                    target = next;
                    continue;
                }
            }
            predecessor = target;
            target = next;
        }
    }
} 

这里发现意愿接受 touch 事件的 直接子View 都会被添加到mFirstTouchTarget这个链式对象里,且链经过调整后 next 几乎总是 null. 这就给我们一个突破口。可以从mFirstTouchTarget.child 得到当前接受事件的直接子 View , 然后按此方法递归去查找直至mFirstTouchTarget.child 为 null。我们就算是找到了最终 touch 事件的接受者。这个查找最好的时机应该是在ACTION_UP 或 ACTION_CANCEL

通过以上原理我们可以有法获取一系列 Touch 事件最终接受处理的目标 View,再根据我们记录的按下位置和松开位置及偏移偏量可判断是否为可能的点击动作。为了加强判断是否为真正的 click 事件,可进一步分析目标 View 是否安装了点击监听器(原理可参考上面讲的方式二。以下获取和分析事件时机都是在 Activity 的 dispatchTouchEvent 方法中进行的。

记录 down 和 up 事件后,以下为实现判断是否为可能的点击判断

//whether it could be a click action
public boolean isClickPossible(float slop) {
    if (mCancel || mDownId == -1 || mUpId == -1 || mDownTime == 0 || mUpTime == 0) {
        return false;
    } else {
        return Math.abs(mDownX - mUpX) < slop && Math.abs(mDownY - mUpY) < slop;
    }
}

在 up 事件发生后立即查找目标 View.首先要保证反射 mFirstTouchTarge 相关的准备工作。

private boolean ensureTargetField() {
    if (sTouchTargetField == null) {
        try {
            Class viewClass = Class.forName("android.view.ViewGroup");
            if (viewClass != null) {
                sTouchTargetField = viewClass.getDeclaredField("mFirstTouchTarget");
                sTouchTargetField.setAccessible(true);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        try {
            if (sTouchTargetField != null) {
                sTouchTargetChildField = sTouchTargetField.getType().getDeclaredField("child");
                sTouchTargetChildField.setAccessible(true);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    return sTouchTargetField != null && sTouchTargetChildField != null;
}

然后从 Activity 的 DecorView 去递归查找目标 View .

// find the target view who is interest in the touch event. null if not find
private View findTargetView() {
    View nextTarget, target = null;
    if (ensureTargetField() && mRootView != null) {
        nextTarget = findTargetView(mRootView);
        do {
            target = nextTarget;
            nextTarget = null;
            if (target instanceof ViewGroup) {
                nextTarget = findTargetView((ViewGroup) target);
            }
        } while (nextTarget != null);
    }
    return target;
}

//reflect to find the TouchTarget child view,null if not found .
private View findTargetView(ViewGroup parent) {
    try {
        Object target = sTouchTargetField.get(parent);
        if (target != null) {
            Object view = sTouchTargetChildField.get(target);
            if (view instanceof View) {
                return (View) view;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

通过以上方式所有具有点击功能的 View 都能正确监听,然而可能存在并没有监听点击事件的 View 也被认为是一次点击事件。要过滤掉这部分可通过分析目标 View 是否安装了点击监听器,这里就不多贴代码了,原理和代码在方式二中有讲过。

以上四种方式各有优劣,效率上都比较快,综合对比以方式二比较精准。像方式三和试四只作为参考,具有学习意义,特别是方式四可应用前景比较广泛,所有的手势的目标View都可查找得到

本文讲述的是我最近研究的用户行为监控的一个监控点。具体更多的行为监控请参考项目 InteractionHook 目前还在持续开发中。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,470评论 25 707
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,157评论 0 17
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,046评论 18 139
  • 读经时间: 2017年11月10日 星期五 阴 读经人员: 可可。 读经内容:《易经》系辞上传10-12章;《诗词...
    161d968e601f阅读 178评论 0 0
  • 你目睹过最亲的人鬼上身吗? 对于读过书的人来说,很难相信这世上有鬼神之说,我也是。但是发生在我妈身上的一件事,改变...
    阿游笔记阅读 1,418评论 2 32