android全局监控click事件的四种方式(小结)
本文主要给大家分享如何在全局上去监听click点击事件,并做些通用处理或是拦截。使用场景可能就是具体的全局防快速重复点击,或是通用打点分析上报,用户行为监控等。以下将以四种不同的思路和实现方式去监控全局的点击操作,由简单到复杂逐一讲解。
方式一,适配监听接口,预留全局处理接口并作为所有监听器的基类使用
抽象出公共基类监听对象,可预留拦截机制和通用点击处理,简要代码如下:
publicabstractclassCustClickListenerimplementsView.OnClickListener{
@Override
publicvoidonClick(Viewview){
if(!interceptViewClick(view)){
onViewClick(view);
}
}
protectedbooleaninterceptViewClick(Viewview){
//TODO:这里可做一此通用的处理如打点,或拦截等。
returnfalse;
}
protectedabstractvoidonViewClick(Viewview);
}
使用方式之一匿名对象作为公共监听器
CustClickListenermClickListener=newCustClickListener(){
@Override
protectedvoidonViewClick(Viewview){
Toast.makeText(CustActvity.this,view.toString(),Toast.LENGTH_SHORT).show();
}
};
@Override
protectedvoidonCreate(@NullableBundlesavedInstanceState){
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
findViewById(R.id.button).setOnClickListener(mClickListener);
}
这种方式比较简单,无兼容问题,但是需要自始至终都要使用基于基类的监听器对象,对开发者约束比较大。适用于新项目之初就有此使用约定。对于老代码重构工作量比较大,而且如果接入第三方墨盒模块就无能为力了。
方式二,反射代理,适时偷梁换柱开发者无感知,在适配包装器里做通用处理。
以下是代理接口和内置监听适配器,全局的监听接口需要实现IProxyClickListener并设置到内置适配器WrapClickListener里
publicinterfaceIProxyClickListener{
booleanonProxyClick(WrapClickListenerwrap,Viewv);
classWrapClickListenerimplementsView.OnClickListener{
IProxyClickListenermProxyListener;
View.OnClickListenermBaseListener;
publicWrapClickListener(View.OnClickListenerl,IProxyClickListenerproxyListener){
mBaseListener=l;
mProxyListener=proxyListener;
}
@Override
publicvoidonClick(Viewv){
booleanhandled=mProxyListener==null?false:mProxyListener.onProxyClick(WrapClickListener.this,v);
if(!handled&&mBaseListener!=null){
mBaseListener.onClick(v);
}
}
}
}
我们需要选择一个时机对所有设置有监听器的View做监听代理的hook.这个时机可以对Activity的根View添加一个视图变化监听(当然也可选择在Activity的DOWN事件的分发时机):
rootView.getViewTreeObserver().addOnGlobalLayoutListener(newViewTreeObserver.OnGlobalLayoutListener(){
@Override
publicvoidonGlobalLayout(){
hookViews(rootView,0)
}
});
注:以上为了方便匿名注册了监听,实际使用在Activity退出时要反注册掉。
在进行代理前先要反射获取View监听器相关的Method和Field对象如下:
publicvoidinit(){
if(sHookMethod==null){
try{
ClassviewClass=Class.forName("android.view.View");
if(viewClass!=null){
sHookMethod=viewClass.getDeclaredMethod("getListenerInfo");
if(sHookMethod!=null){
sHookMethod.setAccessible(true);
}
}
}catch(Exceptione){
reportError(e,"init");
}
}
if(sHookField==null){
try{
ClasslistenerInfoClass=Class.forName("android.view.View$ListenerInfo");
if(listenerInfoClass!=null){
sHookField=listenerInfoClass.getDeclaredField("mOnClickListener");
if(sHookField!=null){
sHookField.setAccessible(true);
}
}
}catch(Exceptione){
reportError(e,"init");
}
}
}
只有保证了sHookMethod和sHookField成功获取才能进入下一步递归去设置监听代理偷梁换柱。以下为具体实现递归设置代理监听的过程。其中mInnerClickProxy为外部传入的的全局处理点击事件的代理接口。
privatevoidhookViews(Viewview,intrecycledContainerDeep){
if(view.getVisibility()==View.VISIBLE){
booleanforceHook=recycledContainerDeep==1;
if(viewinstanceofViewGroup){
booleanexistAncestorRecycle=recycledContainerDeep>0;
ViewGroupp=(ViewGroup)view;
if(!(pinstanceofAbsListView||pinstanceofRecyclerView)||existAncestorRecycle){
hookClickListener(view,recycledContainerDeep,forceHook);
if(existAncestorRecycle){
recycledContainerDeep++;
}
}else{
recycledContainerDeep=1;
}
intchildCount=p.getChildCount();
for(inti=0;i
以上深度优先从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源码关键片段。
publicbooleanperformClick(){
finalbooleanresult;
finalListenerInfoli=mListenerInfo;
if(li!=null&&li.mOnClickListener!=null){
playSoundEffect(SoundEffectConstants.CLICK);
li.mOnClickListener.onClick(this);
result=true;
}else{
result=false;
}
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
returnresult;
}
publicvoidsendAccessibilityEvent(inteventType){
if(mAccessibilityDelegate!=null){
mAccessibilityDelegate.sendAccessibilityEvent(this,eventType);
}else{
sendAccessibilityEventInternal(eventType);
}
}
publicvoidsetAccessibilityDelegate(@NullableAccessibilityDelegatedelegate){
mAccessibilityDelegate=delegate;
}
基于此原理我们可在某个时机给所有的View注册我们自己的AccessibilityDelegate去监听系统行为事件,简要实现代码如下。
publicclassViewClickTrackerextendsView.AccessibilityDelegate{
booleanmInstalled=false;
WeakReferencemRootView=null;
ViewTreeObserver.OnGlobalLayoutListenermOnGlobalLayoutListener=null;
publicViewClickTracker(ViewrootView){
if(rootView!=null&&rootView.getViewTreeObserver()!=null){
mRootView=newWeakReference(rootView);
mOnGlobalLayoutListener=newViewTreeObserver.OnGlobalLayoutListener(){
@Override
publicvoidonGlobalLayout(){
Viewroot=mRootView==null?null:mRootView.get();
booleaninstall=;
if(root!=null&&root.getViewTreeObserver()!=null&&root.getViewTreeObserver().isAlive()){
try{
installAccessibilityDelegate(root);
if(!mInstalled){
mInstalled=true;
}
}catch(Exceptione){
e.printStackTrace();
}
}else{
destroyInner(false);
}
}
};
rootView.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener);
}
}
privatevoidinstallAccessibilityDelegate(Viewview){
if(view!=null){
view.setAccessibilityDelegate(ViewClickTracker.this);
if(viewinstanceofViewGroup){
ViewGroupparent=(ViewGroup)view;
intcount=parent.getChildCount();
for(inti=0;i
以上实现比较巧妙,在监测到window上全局视图树发生变化后递归的给所有的View安装AccessibilityDelegate。经测试大多数厂商的机型和版本都是可以的,然而部分机型无法成功捕获监控到点击事件,所以不推荐使用。
方式四,通过分析Activity的dispatchTouchEvent事件并查找事件接受的目标View。
这个方式初看有点匪夷所思,但是一系列触屏事件发生后总归要有一个组件消耗了它,查看ViewGroup关键源码如下:
//Firsttouchtargetinthelinkedlistoftouchtargets.
privateTouchTargetmFirstTouchTarget;
publicbooleandispatchTouchEvent(MotionEventev){
......
if(newTouchTarget==null&&childrenCount!=0){
for(inti=childrenCount-1;i>=0;i--){
if(dispatchTransformedTouchEvent(ev,false,child,idBitsToAssign)){
newTouchTarget=addTouchTarget(child,idBitsToAssign);
alreadyDispatchedToNewTouchTarget=true;
break;
}
}
}
......
//Dispatchtotouchtargets.
if(mFirstTouchTarget==null){
//Notouchtargetssotreatthisasanordinaryview.
handled=dispatchTransformedTouchEvent(ev,canceled,null,TouchTarget.ALL_POINTER_IDS);
}else{
//Dispatchtotouchtargets,excludingthenewtouchtargetifwealready
//dispatchedtoit.Canceltouchtargetsifnecessary.
TouchTargetpredecessor=null;
TouchTargettarget=mFirstTouchTarget;
while(target!=null){
finalTouchTargetnext=target.next;
if(alreadyDispatchedToNewTouchTarget&&target==newTouchTarget){
handled=true;
}else{
finalbooleancancelChild=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事件后,以下为实现判断是否为可能的点击判断
//whetheritcouldbeaclickaction
publicbooleanisClickPossible(floatslop){
if(mCancel||mDownId==-1||mUpId==-1||mDownTime==0||mUpTime==0){
returnfalse;
}else{
returnMath.abs(mDownX-mUpX)
在up事件发生后立即查找目标View.首先要保证反射mFirstTouchTarge相关的准备工作。
privatebooleanensureTargetField(){
if(sTouchTargetField==null){
try{
ClassviewClass=Class.forName("android.view.ViewGroup");
if(viewClass!=null){
sTouchTargetField=viewClass.getDeclaredField("mFirstTouchTarget");
sTouchTargetField.setAccessible(true);
}
}catch(Exceptione){
e.printStackTrace();
}
try{
if(sTouchTargetField!=null){
sTouchTargetChildField=sTouchTargetField.getType().getDeclaredField("child");
sTouchTargetChildField.setAccessible(true);
}
}catch(Exceptione){
e.printStackTrace();
}
}
returnsTouchTargetField!=null&&sTouchTargetChildField!=null;
}
然后从Activity的DecorView去递归查找目标View.
//findthetargetviewwhoisinterestinthetouchevent.nullifnotfind
privateViewfindTargetView(){
ViewnextTarget,target=null;
if(ensureTargetField()&&mRootView!=null){
nextTarget=findTargetView(mRootView);
do{
target=nextTarget;
nextTarget=null;
if(targetinstanceofViewGroup){
nextTarget=findTargetView((ViewGroup)target);
}
}while(nextTarget!=null);
}
returntarget;
}
//reflecttofindtheTouchTargetchildview,nullifnotfound.
privateViewfindTargetView(ViewGroupparent){
try{
Objecttarget=sTouchTargetField.get(parent);
if(target!=null){
Objectview=sTouchTargetChildField.get(target);
if(viewinstanceofView){
return(View)view;
}
}
}catch(Exceptione){
e.printStackTrace();
}
returnnull;
}
通过以上方式所有具有点击功能的View都能正确监听,然而可能存在并没有监听点击事件的View也被认为是一次点击事件。要过滤掉这部分可通过分析目标View是否安装了点击监听器,这里就不多贴代码了,原理和代码在方式二中有讲过。
以上四种方式各有优劣,效率上都比较快,综合对比以方式二比较精准。像方式三和试四只作为参考,具有学习意义,特别是方式四可应用前景比较广泛,所有的手势的目标View都可查找得到
本文讲述的是我最近研究的用户行为监控的一个监控点。具体更多的行为监控请参考项目InteractionHook目前还在持续开发中。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。