探究 | 如何捕获一个 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方法,作为开发者事件分发的一个控制拦截,最后重新返回到DecorViewsuper.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方法,切面编程,无障碍服务,有了这些知识,真正遇到一些关于页面事件的问题或需求,就能有自己的解决方案了。

参考

wanAndroid

Android应用Activity、Dialog、PopWindow、Toast窗口添加机制及源码分析

反思|Android 事件分发机制的设计与实现

源码

PageClickMonitor

拜拜

感谢大家的阅读,有一起学习的小伙伴可以关注下我的公众号——码上积木❤️❤️
每日三问知识点/面试题,积少成多。

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

推荐阅读更多精彩内容