探究 | 如何捕获一个Activity页面上所有的点击行为
前言
最近逛wanAndroid
论坛,发现一个有趣的问题:如何捕获一个Activity页面上所有的点击行为。
一起研究下吧,不想看源码的小伙伴可以直接看文末总结~
准备工作
先得罗列出页面上的一些点击行为,常用的有:
- 普通View的点击
- 动态add的View的点击
- Dialog上的按钮点击
于是就有了如下代码:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btn1.setOnClickListener {
showToast("点了按钮1")
}
btn2.setOnClickListener {
val builder =
AlertDialog.Builder(this)
.setTitle("我是一个dialog")
val view: View = layoutInflater.inflate(R.layout.dialog_btn, null)
val btn4 =
view.findViewById<View>(R.id.btn4)
btn4.setOnClickListener {
showToast("点击了Dialog按钮")
}
builder.setView(view)
builder.create().show()
}
btn3.setOnClickListener {
var button = Button(this)
button.text = "我是新加的按钮"
var param = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
mainlayout.addView(button, param)
button.setOnClickListener {
showToast("点击了新加的按钮")
}
}
}
}
既然我要捕获点击事件,首先就想到的是通过事件分发机制,也就是在源头就去获取所有的触摸事件,然后对点击事件进行统计,干吧~
事件分发
重写Activity的dispatchTouchEvent
方法,由于只有点击事件,所以只需要统计ACTION_UP
事件即可,如果有长按事件就在需要判断下按下的时间。
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
ev?.let {
when (ev.action) {
MotionEvent.ACTION_UP -> {
Log.e(Companion.TAG,"ACTION_UP——CLICK")
}
else -> {}
}
}
return super.dispatchTouchEvent(ev)
}
ok,运行下。
- 点击按钮1,日志打印正常
- 点击按钮2中的dialog按钮,日志。。。没有
- 点击按钮3中的button,日志打印正常
结果大家也看到了,Dialog
中的点击事件无法被响应,这是为啥呢?
这就要从事件分发机制说起了,点击屏幕首先响应的是当前屏幕的顶层View,也就是DecorView
,在Activity中也就是Window的根布局。然后DecorView
会调用Activity的dispatchTouchEvent
方法,作为开发者事件分发的一个控制拦截,最后重新返回到DecorView
的super.dispatchTouchEvent(event)
方法开始ViewGroup的事件传递。看看相关源码:
//DecorView.java
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
//cb其实就是对应的Activity
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
}
//Activity.java
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
//PhoneWindow.java
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
//DecorView.java
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
可以看到事件的开始经历了DecorView——>Activity——>PhoneWindow——>DecorView——>ViewGroup
。
而我们在第二步Acitivity中就无法获取Dialog的点击事件了,很明显是DecorView就没把事件传过来,难道Dialog的DecorView
和Activity的DecorView
不是同一个?
继续来研究下Dialog这个物种,它和Activity之间可有着不清不楚的关系~
Dialog,Activity扯不断的关系
这里我们只看两个方法,一个是Dialog的构造函数,一个是show方法,看看这段三角恋是怎么形成的:
//构造函数
Dialog(Context context, int theme, boolean createContextThemeWrapper) {
//......
//获取了WindowManager对象,mContext一般是个Activity,获取系统服务一般是通过Binder获取
mWindowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
//创建新的Window
Window w = PolicyManager.makeNewWindow(mContext);
mWindow = w;
//这里也是上方mWindow.getCallback()为什么是Activity的原因,在创建新Window的时候会设置callback为自己
w.setCallback(this);
w.setOnWindowDismissedCallback(this);
//关联WindowManager与新Window,token为null
w.setWindowManager(mWindowManager, null, null);
w.setGravity(Gravity.CENTER);
mListenersHandler = new ListenersHandler(this);
}
//show方法
public void show() {
//......
if (!mCreated) {
//回调Dialog的onCreate方法
dispatchOnCreate(null);
}
//回调Dialog的onStart方法
onStart();
//获取当前新Window的DecorView对象
mDecor = mWindow.getDecorView();
WindowManager.LayoutParams l = mWindow.getAttributes();
try {
//把一个View添加到Activity共用的windowManager里面去
mWindowManager.addView(mDecor, l);
//......
} finally {
}
}
可以看到一个Dialog从无到有经历了以下几个步骤:
- 首先创建了一个新的Window,类型是PhoneWindow类型,与Activity创建Window过程类似,并设置
setCallback
回调。 - 将这个新Window与从Activity拿到的
WindowManager
对象相关联,也就是dialog与Activity公用了同一个WindowManager
对象。 - show方法展示Dialog,先回调了Dialog的
onCreate,onStart
方法。 - 然后获取Dialog自己的
DecorView
对象,并通过addView方法添加到WindowManager对象中,Dialog出现到屏幕上。
分析这个流程我们还可以得知一些平时遇到的小问题,比如为啥Dialog必须要依附于Activity显示?因为Dialog创建过程中需要使用Activity的Context
,即需要使用Activity的token
用来创建window
。所以传入Application的Content就会报错——“Unable to add window -- token null is not for an application”。
回到正题,这个过程用一句话总结就是,Dialog用了Activity的WindowManager对象,并在这之上添加了一个新的Window的DecorView
。
因此我们得知,Dialog和Activity但是所处的Window不一样,也就是所在的父View——DecorView
也是不一样的,所以在Dialog出现之后,点击屏幕上的按钮,是从Dialog自己的DecorView
开始响应,再回顾下刚才DecorView的代码:
//DecorView.java
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
//cb在这里就变成了Dialog
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
}
这时候getCallback
的对象变成了Dialog,所以不会回调Activity的dispatchTouchEvent
方法,而是走到Dialog的dispatchTouchEvent
方法。
这个问题终于搞清楚了,但是我们自己的问题该怎么解决呢?继续探索~
替换OnClickListener
既然点击事件都是通过setOnClickListener
完成的,那么我们替换这个OnClickListener
不就能获取所有的点击事件了?
ok,先看看setOnClickListener
方法,看看该怎么替换:
//View.java
ListenerInfo mListenerInfo;
public void setOnClickListener(@Nullable OnClickListener l) {
if (!isClickable()) {
setClickable(true);
}
getListenerInfo().mOnClickListener = l;
}
代码很简单,所以我们只需要替换View的getListenerInfo()
获取到的mListenerInfo对象中的mOnClickListener
即可。
1)思路有了,先生成我们自己需要替换的OnClickListener:
class MyOnClickListenerer(var onClickListener: View.OnClickListener?) : View.OnClickListener {
override fun onClick(v: View?) {
Log.e("lz", "点击了一个按钮——$v")
onClickListener!!.onClick(v)
}
}
2)然后选择hook
点,我们之前在《线程与更新UI》文章中说过,Activity的DecorView被完整绘制出来是在onResume
之后,所以我们就在这里进行hook我们的MyOnClickListenerer:
override fun onResume() {
super.onResume()
var rootView = window.decorView as ViewGroup
hookAllChildView(rootView)
}
private fun hookAllChildView(viewGroup: ViewGroup) {
val count = viewGroup.childCount
for (i in 0 until count) {
if (viewGroup.getChildAt(i) is ViewGroup) {
hookAllChildView(viewGroup.getChildAt(i) as ViewGroup)
} else {
hook(viewGroup.getChildAt(i))
}
}
}
@SuppressLint("DiscouragedPrivateApi", "PrivateApi")
private fun hook(view: View) {
try {
val getListenerInfo: Method = View::class.java.getDeclaredMethod("getListenerInfo")
getListenerInfo.isAccessible = true
//获取当前View的ListenerInfo对象
val mListenerInfo: Any = getListenerInfo.invoke(view)
try {
val listenerInfoClazz =
Class.forName("android.view.View\$ListenerInfo")
try {
//获取mOnClickListener参数
val mOnClickListener: Field =
listenerInfoClazz.getDeclaredField("mOnClickListener")
mOnClickListener.isAccessible = true
var oldListener: View.OnClickListener? =
mOnClickListener.get(mListenerInfo) as? View.OnClickListener
if (oldListener != null && oldListener !is MyOnClickListenerer) {
//替换OnClickListenerer
val proxyOnClick =
MyOnClickListenerer(oldListener)
mOnClickListener.set(mListenerInfo, proxyOnClick)
}
} catch (e: NoSuchFieldException) {
e.printStackTrace()
}
} catch (e: ClassNotFoundException) {
e.printStackTrace()
}
} catch (e: NoSuchMethodException) {
e.printStackTrace()
}
}
等我满意的去运行项目的时候,又被无情的现实扇了一巴掌:
- 点击按钮1,日志打印正常
- 点击按钮2中的dialog按钮,日志。。。没有
- 点击按钮3中的button,日志。。。没有
好家伙,结果只有一个按钮是正常捕获的。分析下原因吧,为啥Dialog和新加的View都无法捕获呢?
好好想想我们hook的时机,是在界面上的布局绘制出来之后,但是Dialog和新加的View都是在界面绘制之后
再出现的,自然也就没有hook
到。怎么解决呢?
新加的View
其实还比较好解决,给rootView 添加ViewTreeObserver.OnGlobalLayoutListener
监听即可,当视图树的布局发生改变时,就可以被ViewTreeObserver监听到,然后再hook一次就行了。- 但是
Dialog
又不好处理了,还是同样的问题,不是同一个rootView ,所以需要在Dialog的rootView也要进行一次hook。
4)再次改动
//Dialog增加hook
var rootView = dialog.window?.decorView as ViewGroup
hookAllChildView(rootView)
//增加监听view树
rootView.viewTreeObserver.addOnGlobalLayoutListener { hookAllChildView(rootView) }
这下运行确实都能打印出日志了,但是,这也太蠢了点吧。。
特别是Dialog
,不可能每个Dialog都去加一遍hook代码呀。
所以,还需要想想其他的方案。
AspectJ
经过上述问题,我们又想到了一个办法,同样是进行代码埋点,使用AspectJ
来解决我们的问题。
AspectJ
是一个面向切面编程(AOP)的框架,可以在编译期将代码插入到目标切入点中,达到AOP目的。
//AspectJ的配置代码就不贴了,需要的小伙伴可以看看文末的源代码链接
@Aspect
class ClickAspect {
@Pointcut("execution(* android.view.View.OnClickListener.onClick(..))")
fun pointcut() {
}
@Around("pointcut()")
@Throws(Throwable::class)
fun onClickMethodAround(joinPoint: ProceedingJoinPoint) {
val args: Array<Any> = joinPoint.args
var view: View? = null
for (arg in args) {
if (arg is View) {
view = arg
}
}
joinPoint.proceed()
Log.d("lz", "点击了一个按钮: $view")
}
}
通过找到切点,也就是View中的onClick
方法,*
表示任意返回值,..
表示任意参数,然后在这个切点中获取view信息,得到点击事件的反馈。
运行,三种情况都能正常打印日志。
所以这个方法是可行的。
AccessibilityService
到这里,问题也是有解决的办法了。但是还有没有其他的方案呢?既然是关于界面反馈类的问题,这里又想到一个方案——无障碍服务AccessibilityService
,来试试看。
class ClickAccessibilityService: AccessibilityService() {
override fun onInterrupt() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
val eventType = event?.eventType
val className = event?.className.toString()
when (eventType) {
AccessibilityEvent.TYPE_VIEW_CLICKED -> Log.e(TAG,"【无障碍方案】点击了一个按钮=$className")
}
}
companion object {
private const val TAG = "AccessibilityService"
}
}
//另外还需要在AndroidManifest.xml中配置service以及对应的config文件,具体可见文末源码,这里就不贴了。
关键代码就这么多,在onAccessibilityEvent
回调中,获取AccessibilityEvent.TYPE_VIEW_CLICKED
事件即可,运行,打开我们的无障碍服务。
三种点击事情的情况都能正常打印日志,搞定。
总结
我们一共试了四种方法:
- 事件分发方案。通过重写Activity的
dispatchTouchEvent
方法,对页面上的点击事件进行拦截。但是拦截不到Dialog中的点击事件,因为事件分发由DecorView开始发起,但是Dialog所处的DecorView和Activity的DecorView不是同一个,所以无法在Activitiy的dispatchTouchEvent
方法进行拦截Dialog中的点击事件。 - hook替换OnClickListener方案。这个方案主要是通过替换View中的
mOnClickListener
为我们自己的OnClickListener,然后进行点击事件的拦截处理。但是这个方案需要获取替换的那个View才行,所以新增的View和Dialog都需要单独处理才行。新增的View需要进行当前页面的View树进行监听,Dialog必须对Dialog中的View再进行一次hook。 - AspectJ切面编程方案。这个方案是在编译期将代码插入到目标方法中,所以只要找到切点——也就是View中的onClick方法即可。可以完美解决我们的问题,并且不需要用户另外操作。
- 无障碍服务方案。这个方案是通过Android中的无障碍服务,对APP中的所有点击事件进行拦截,对应的事件就是
AccessibilityEvent.TYPE_VIEW_CLICKED
。该方案也能完美解决我们的问题,但是有个很大的缺点,就是需要用户单独去设置页面开启该辅助服务才行。
虽然在我们实际项目中这个问题——获取页面的所有点击事件的需求几乎没有
,但是对于这种问题的分析能让我们了解相关的知识,比如今天了解到的事件分发机制,Hook方法,切面编程,无障碍服务
,有了这些知识,真正遇到一些关于页面事件的问题或需求,就能有自己的解决方案了。
参考
Android应用Activity、Dialog、PopWindow、Toast窗口添加机制及源码分析
源码
拜拜
有一起学习的小伙伴可以关注下️我的公众号——码上积木,每天剖析一个知识点,我们一起积累知识。公众号回复111可获得面试题《思考与解答》以往期刊。
探究 | 如何捕获一个Activity页面上所有的点击行为的更多相关文章
- 从一个Activity返回上一个Activity
从一个Activity返回上一个Activity 要求:保留上一个Activity的数据 方法: 第一步:从Activity1转向Activity2时,用startActivityForResult而 ...
- 使用 JavaScript 的 HTML 页面混合、JavaScript 文件引用和 HTML 代码嵌入 3 种方式在 HTML 页面上打印出“点击我进入到百度首页”的超链接
查看本章节 查看作业目录 需求说明: 使用 JavaScript 的 HTML 页面混合.JavaScript 文件引用和 HTML 代码嵌入 3 种方式在 HTML 页面上打印出"点击我进 ...
- java捕获一个网站页面的全部图片
直接上代码: package com.jeecg.util; import java.io.BufferedReader; import java.io.FileNotFoundException; ...
- Javascript(jQuery)中绑定页面上所有按钮点击事件的几种方式
方法一:使用document对象查找所有的按钮 [javascript] view plain copy 在CODE上查看代码片派生到我的代码片 //按照dom的方式添加事件处理 function B ...
- 打开一个activity,让edittext不获得焦点
在实际开发中,有的页面用到Edittext控件,这时候进入该页面可能会自动弹出输入法,这么显示不太友好,所以需要设置一下让Edittext默认不自动获取焦点.在网上查资料解决办法如下: 在Edit ...
- Android中点击按钮启动另一个Activity以及Activity之间传值
场景 点击第一个Activity中的按钮,启动第二个Activity,关闭第二个Activity,返回到第一个Activity. 在第一个Activity中给第二个Activity传递值,第二个Act ...
- 多动手试试,其实List类型的变量在页面上取到的值可以直接赋值给一个js的Array数组变量
多动手试试,其实List类型的变量在页面上取到的值可以直接赋值给一个js的Array数组变量,并且数组变量可以直接取到每一个元素var array1 = '<%=yearList =>'; ...
- Android开发:向下一个activity传递数据,返回数据给上一个activity
1.向下一个activity传递数据 activity1 Button button=(Button) findViewById(R.id.button1); button.setOnClickLis ...
- android一个页面上多个listview
android一个页面上多个listview,在滚动的时候,需要两个listview能够一起滚动,看起来是一个view. 这个功能的具体实现,参考: http://blog.csdn.net/xia2 ...
随机推荐
- python使用zlib库压缩图片,使用ffmpeg压缩视频
python压缩图片.视频 图片压缩使用zlib库 视频压缩使用工具ffmpeg # ffmpeg -i 1.mp4 -r 10 -pix_fmt yuv420p -vcodec libx264 -p ...
- [Luogu P3469] [POI2008]BLO-Blockade (割点)
题面 传送门:https://www.luogu.org/problemnew/show/P3469 Solution 先跟我大声念: poi! 然后开始干正事. 首先,我们先把题目中的点分为两类:去 ...
- [Luogu P2341] [HAOI2006]受欢迎的牛 (缩点+bitset)
题面 传送门:https://www.luogu.org/problemnew/show/P2341 Solution 前排提示,本蒟蒻做法既奇葩又麻烦 我们先可以把题目转换一下. 可以把一头牛喜欢另 ...
- insert into select 和select into from 备份表
一 insert into select要求表必须存在 INSERTINTO order_record SELECT * FROM order_today FORCEINDEX (idx_pay_su ...
- NIO源码分析:SelectionKey
SelectionKey SelectionKey,选择键,在每次通道注册到选择器上时都会创建一个SelectionKey储存在该选择器上,该SelectionKey保存了注册的通道.注册的选择器.通 ...
- axios封装接口
我们一般都是在做一个大型项目的时候,需要用到很多接口时,我们为了方便使用,就把接口封装起来. 先安装axios命令 :npm install axios --save 那么思路是什么呢? 首先在src ...
- IDEA与Eclipse创建struts项目
1.IDEA创建struts项目 这里再构建struts项目是选择jar包出问题了,可以重新配置 创建页面和action配置struts.xml 启动tomcat,浏览器中运行 具体参考: https ...
- python之《tkinter》
1.创建窗口 import tkinter as tk window = tk.Tk() window.title('my window') window.geometry('300x100') -- ...
- IntelliJ IDEA 2020.2.3永久破解激活教程 - 2020.10.27
申明:本教程 IntelliJ IDEA 破解补丁.激活码均收集于网络,请勿商用,仅供个人学习使用,如有侵权,请联系作者删除 不花钱 的方式 IDEA 2020.2 激活到 2089 年 注意:教程适 ...
- 建议收藏!超详细的JVM反射原理技术点总结
反射定义 1,JAVA反射机制是在运行状态中 对于任意一个类,都能够知道这个类的所有属性和方法: 对于任意一个对象,都能够调用它的任意一个方法和属性: 这种动态获取的信息以及动态调用对象的方法的功能称 ...