android全局监控click事件的四种方式(小结)
作者:rexyren 发布时间:2023-05-02 07:33:31
本文主要给大家分享如何在全局上去监听 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");
}
}
}
只有保证了sHookMethod和sHookField成功获取才能进入下一步递归去设置监听代理偷梁换柱。以下为具体实现递归设置代理监听的过程。其中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 相对可能存在的可回收容器的层级数。因为对于像AbsListView或RecyclerView的直接子 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 目前还在持续开发中。
来源:http://www.jianshu.com/p/1c672083f301?utm_source=tuicool&utm_medium=referral
猜你喜欢
- 后台Java代码【验证码生成】/** * 随机生成6位随机验证码 */ public static String createRandomV
- @ApiImplicitParam作用在方法上,表示单独的请求参数参数name:参数名。value:参数的具体意义,作用。required:
- 前言:对于一个程序员来说,尤其是在java web端开发的程序员,三大框架:Struts+Hibernate+Spring是必须要掌握熟透的
- Android读取资源文件的方法1、放入到资源文件夹里面,也就是所创建android工程的res下面。
- 目录一、SpringMvc框架简介1、Mvc设计理念2、SpringMvc简介二、SpringMvc执行流程1、流程图解2、步骤描述3、核心
- 项目信息使用SpringBoot web框架,版本号 2.7.10<dependency><groupId>org.
- 一、使用QueryByExampleExecutor1. 继承MongoRepositorypublic interface Student
- 一、简介CyclicBarrier 字面意思回环栅栏(循环屏障),它可以实现让一组线程等待至某个状态(屏障点)之后再全部同时执行。叫做回环是
- 本文实例讲述了android打开本地图像的方法。分享给大家供大家参考。具体如下:方法一,调用手机安装的图像浏览工具浏览:Intent int
- 1、很多资料说,添加以下代码,可以隐藏地址栏,但我试了很多次,貌似不成功啊。<meta name="apple-mobile
- 之前有个兄弟给我的卷一re了帖子,我当时没有g,m,直到他把它删掉才后悔莫及,人生最痛苦的事情莫过于此。。。。。。好,即便如此,我们还是满怀
- ApplicationContext简述ApplicationContext代表IOC容器,在SpringIOC容器中读取Bean配置创建B
- 1.DRUID连接池介绍Druid是阿里巴巴开发的号称为监控而生的数据库连接池,Druid是目前最好的数据库连接池。在功能、性能、扩展性方面
- Idea中directory和package的区别,要是错了就右键,make directory as 目录或者源代码目录(Source R
- Mybatis有什么用前两天跟阿里的大牛聊天,他讲到对于性能要求高,需求变化多的互联网项目来说,用在sql优化上的开发时间是大头,有时候代码
- 在使用IDEA写代码的时候,打开tabs都挤在一行,当打开页面过多的时候,前面的页面无法直观看到,非常不方便。通过简单设置就可以实现tabs
- 冒泡排序在八大排序中,冒泡排序是最为出名的排序算法之一!冒泡排序的代码还是相当简单的,两层循环,外层是冒泡轮数,里层是依次比较,这个算法的时
- 最近我在考虑如何远程控制tomcat的启动和关机,最后是有友好的界面,能够实现一键式操作的,这样会肯定是会很方便的,网上找了半天,没找到,有
- 背景Java8的stream接口极大地减少了for循环写法的复杂性,stream提供了map/reduce/collect等一系列聚合接口,
- 定义在类里面的类就叫做内部类。内部类的特点:在内部类中可以直接访问外部类的成员,包括私有的成员在外部类中不能直接访问内部类的成员,必须通过创