Explore how to capture all click behaviors on an activity page

Time:2021-6-24

preface

Recent shoppingwanAndroidForum, found an interesting question:How to capture all click behaviors on an activity page

Let’s study together. If you don’t want to see the source code, you can directly see the summary at the end of the article

preparation

First, list some click behaviors on the page

  • Normal view Click
  • Dynamic add view Click
  • Click the button on dialog

So there is the following code:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        btn1.setOnClickListener {
            Showtoast ("click button 1")
        }


        btn2.setOnClickListener {
            val builder =
                AlertDialog.Builder(this)
                    . settitle ("I am a dialog")
            val view: View = layoutInflater.inflate(R.layout.dialog_btn, null)
            val btn4 =
                view.findViewById(R.id.btn4)
            btn4.setOnClickListener {
                Showtoast ("dialog button has been clicked")
            }
            builder.setView(view)
            builder.create().show()
        }


        btn3.setOnClickListener {

            var button = Button(this)
            Button.text = "I'm a new button"
            var param = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
            )
            mainlayout.addView(button, param)

            button.setOnClickListener {
                Show toast
            }
        }
    }
}

Since I want to capture click events, the first thing I want to do is to use the event distribution mechanism, that is, to get all the touch events at the source, and then count the click events. Go ahead

Event distribution

Override activity’sdispatchTouchEventMethod, because there are only click events, only statistics are neededACTION_UPEvent, if there is a long press event, you need to judge the press time.

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, run it.

  • Click button 1, the log printing is normal
  • Click the dialog button in button 2, log… No,
  • Click button in button 3 and the log will print normally

As you can see,DialogWhy can’t the click events in be responded to?

This starts with the event distribution mechanism. Clicking on the screen first responds to the top view of the current screen, which isDecorViewIn activity, which is the root layout of window. thenDecorViewWill call thedispatchTouchEventMethod, as a control interception of developer event distribution, and finally return to theDecorViewOfsuper.dispatchTouchEvent(event)Method to start event delivery for ViewGroup. Look at the source code:

//DecorView.java
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //CB is actually the corresponding 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);
    }

You can see the beginning of the eventDecorView——>Activity——>PhoneWindow——>DecorView——>ViewGroup

In the second step of activity, we can’t get the click event of dialog. It’s obvious that decorview didn’t pass the event. Isn’t it true that dialogDecorViewAnd activityDecorViewNot the same one?

Let’s continue to study dialog. Is there any unclear relationship between dialog and activity

Dialog, activity

Here we only look at two methods, one is the dialog constructor, and the other is the show method. Let’s see how this love triangle is formed

//Constructors
Dialog(Context context, int theme, boolean createContextThemeWrapper) {
        //......
        //When the WindowManager object is obtained, mcontext is generally an activity, and the system service is generally obtained through binder
        mWindowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
        //Create a new window
        Window w = PolicyManager.makeNewWindow(mContext);
        mWindow = w;
        //This is also the reason why mwindow. Getcallback() is an activity. When you create a new window, you will set callback as your own
        w.setCallback(this);
        w.setOnWindowDismissedCallback(this);
        //Associate WindowManager with new window, token is null
        w.setWindowManager(mWindowManager, null, null);
        w.setGravity(Gravity.CENTER);
        mListenersHandler = new ListenersHandler(this);
    }



//Show method
    public void show() {
        //......
        if (!mCreated) {
            //Oncreate method of callback dialog
            dispatchOnCreate(null);
        }
        //OnStart method of callback dialog
        onStart();
        //Gets the decorview object of the current new window
        mDecor = mWindow.getDecorView();
        WindowManager.LayoutParams l = mWindow.getAttributes();
        try {
            //Add a view to the windows manager shared by activity
            mWindowManager.addView(mDecor, l);
            //......
        } finally {
        }
    }

You can see that a dialog has gone through the following steps from scratch:

  • First, a new window is created. The type is phonewindow, which is similar to the process of creating a window with activity, and set thesetCallbackCallback.
  • Compare this new window with theWindowManagerObject, that is, dialog and activity share the same objectWindowManagerObject.
  • The show method displays dialog, calling back theonCreate,onStartmethod.
  • Then get dialog’s ownDecorViewObject, and add it to the WindowManager object through the addview method, and the dialog appears on the screen.

By analyzing this process, we can also find out some common problems, such as why dialog must be attached to activity display? Because you need to use activity’sContextThat is, you need to use thetokenUsed to createwindow. Therefore, the content of the application will report an error——“Unable to add window — token null is not for an application”

Back to the point,This process is summed up in one sentenceDialog uses the activity’s WindowManager object and adds a new window’sDecorView

Therefore, we know that dialog and activity are in different windows, that is, in different windowsParent view -- decorviewIt’s also different, so after dialog appears, click the button on the screen, and it’s from dialog itselfDecorViewStart to respond, and then review the code of decorview:

//DecorView.java
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //CB becomes dialog here
        final Window.Callback cb = mWindow.getCallback();
        return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
                ? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
    }

At this timegetCallbackThe object becomes dialog, so it will not call back the activitydispatchTouchEventMethod, but go to dialogdispatchTouchEventmethod.

This problem is finally clear, but how can we solve our own problem? Continue to explore ~

Replace onclicklistener

Since the click events are all through thesetOnClickListenerIt’s done, so let’s replace this oneOnClickListenerCan’t we get all the click events?

OK, take a look firstsetOnClickListenerMethod to see how to replace:

//View.java
    ListenerInfo mListenerInfo;

    public void setOnClickListener(@Nullable OnClickListener l) {
        if (!isClickable()) {
            setClickable(true);
        }
        getListenerInfo().mOnClickListener = l;
    }

The code is simple, so we just need to replace the viewgetListenerInfo()In the obtained mlistenerinfo objectmOnClickListenerThat’s it.

1) With the idea, we need to replace onclicklistener

class MyOnClickListenerer(var onClickListener: View.OnClickListener?) : View.OnClickListener {

    override fun onClick(v: View?) {
        Log. E ("LZ", "click a button - $V")
        onClickListener!!.onClick(v)
    }
}

2) Then selecthookA little bit. We were there beforeThread and update UIAs mentioned in the article, the decorview of activity is completely drawn in theonResumeAfter that, we hook our myonclicklistener here

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
            //Gets the listener info object of the current view
            val mListenerInfo: Any = getListenerInfo.invoke(view)
            try {
                val listenerInfoClazz =
                    Class.forName("android.view.View$ListenerInfo")
                try {
                    //Gets the monclicklistener parameter
                    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) {
                        //Replace onclicklistener
                        val proxyOnClick =
                            MyOnClickListenerer(oldListener)
                        mOnClickListener.set(mListenerInfo, proxyOnClick)
                    }
                } catch (e: NoSuchFieldException) {
                    e.printStackTrace()
                }
            } catch (e: ClassNotFoundException) {
                e.printStackTrace()
            }
        } catch (e: NoSuchMethodException) {
            e.printStackTrace()
        }
    }

When I was satisfied with running the project, I was slapped by the merciless reality

  • Click button 1, the log printing is normal
  • Click the dialog button in button 2, log… No,
  • Click button in button 3, log… No,

Good guy, only one button is captured normally. Why can’t dialog and the newly added view be captured?

When we think about hook, it’s time to draw the layout on the interface, but dialog and the newly added view are all drawn on the interfaceafterIf it reappears, it will nothookHere we are. How to solve it?

  • New viewIn fact, it’s a better solution to addViewTreeObserver.OnGlobalLayoutListenerJust listen. When the layout of the view tree changes, it can be monitored by the viewtree observer, and then hook again.
  • howeverDialogIt’s not easy to deal with again. It’s the same problem. It’s not the same rootview, so you need to do a hook in dialog’s rootview.

4) Change again

//Dialog add hook
    var rootView = dialog.window?.decorView as ViewGroup
    hookAllChildView(rootView)

//Add monitor view tree
    rootView.viewTreeObserver.addOnGlobalLayoutListener { hookAllChildView(rootView) }

This run can indeed print out the log, but, this is too stupid..
especiallyDialogIt’s impossible to add hook code to every dialog.
So, we need to think about other solutions.

AspectJ

After the above problems, we have come up with a way, the same is the code buried point, usingAspectJTo solve our problems.

AspectJAOP is a framework of aspect oriented programming (AOP), which can insert the code into the target pointcut at compile time to achieve the goal of AOP.

//AspectJ configuration code is not posted, the need for small partners can see the source code link at the end of the article

@Aspect
class ClickAspect {
    @Pointcut("execution(* android.view.View.OnClickListener.onClick(..))")
    fun pointcut() {

    }

    @Around("pointcut()")
    @Throws(Throwable::class)
    fun onClickMethodAround(joinPoint: ProceedingJoinPoint) {
        val args: Array = joinPoint.args
        var view: View? = null
        for (arg in args) {
            if (arg is View) {
                view = arg
            }
        }
        joinPoint.proceed()
        Log. D ("LZ", "click a button: $view")
    }
}

By finding the tangent point, which is in viewonClickmethod,*Represents any return value,..Represents any parameter, and then gets the view information in this tangent point to get the feedback of the click event.

The log can be printed normally in three cases.
So this method is feasible.

AccessibilityService

Here, there is a solution to the problem. But is there any other plan? Since it’s about interface feedback, here’s another solution — barrier free serviceAccessibilityServiceLet’s have a try.

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_ Clipped > log. E (tag, "[accessibility program] clicks a button = $classname")
        }
    }

    companion object {
        private const val TAG = "AccessibilityService"
    }

}

//In addition, you need to configure the service and the corresponding config file in androidmanifest.xml. See the source code at the end of the article for details. I won't post it here.

That’s all the key code, rightonAccessibilityEventCallback, getAccessibilityEvent.TYPE_VIEW_CLICKEDEvent, run, open our barrier free service.

Three kinds of click events can be normal print log, done.

summary

We tried four methods

  • Event distribution scheme. By rewriting thedispatchTouchEventMethod to intercept the click events on the page. However, the click event in dialog cannot be intercepted because the event distribution is initiated by the decorview. However, the decorview of dialog and the decorview of activity are not the samedispatchTouchEventMethod to intercept the click events in dialog.
  • Hook replacing onclicklistener scheme. This solution is mainly through replacing themOnClickListenerFor our own onclicklistener, and then intercept the click event. However, this scheme needs to obtain the replaced view, so the new view and dialog need to be processed separately. The new view needs to listen to the view tree of the current page. Dialog must hook the view in dialog again.
  • AspectJ aspect programming scheme. The solution is to insert the code into the target method at compile time, so you just need to find the pointcut, that is, the onclick method in view. It can solve our problem perfectly, and it doesn’t need any other operation.
  • Barrier free service scheme. This solution is to intercept all click events in the app through the barrier free service in Android, and the corresponding event isAccessibilityEvent.TYPE_VIEW_CLICKED. This solution can also perfectly solve our problem, but there is a big disadvantage, that is, the user needs to set the page to open the auxiliary service.

Although in our actual project, this problem is the need to get all the click events of the pagehardly anyBut the analysis of this kind of problem can let us understand the relevant knowledge, such as what we learned todayEvent distribution mechanism, hook method, aspect programming, barrier free serviceWith this knowledge, we can have our own solutions when we really encounter some problems or requirements about page events.

reference resources

wanAndroid

Android application activity, dialog, popwindow, toast window adding mechanism and source code analysis

Reflection on the design and implementation of Android event distribution mechanism

Source code

PageClickMonitor

bye-bye

There are small partners who can study together and pay attention to them ❤️ My official account, the building blocks on the code, dissect a knowledge point every day, and we accumulate knowledge together. The official account reply 111 can get the past topic of the interview question and answer.