全局监控 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 目前还在持续开发中。

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 144,112评论 18 619
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 4,293评论 0 17
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 106,381评论 12 127
  • 红民咯哦 非凡哥 就离开了就离开 镂空你了
    剑走偏疯阅读 15评论 0 0
  • 读经时间: 2017年11月10日 星期五 阴 读经人员: 可可。 读经内容:《易经》系辞上传10-12章;《诗词...
    407452068阅读 15评论 0 0