怎么全局监听View的各种事件?

陈尔展

本文主要是对全局监听View的事件方案调研的总结,全局监听View的事件可以方便我们做一些全局防快速重复点击,或是通用打点分析上报,用户行为监控等功能,Android端目前主要可以通过以下几种方式来实现:

1 抽象公共的事件监听基类

这里以click事件为例,我们先抽象出公共的点击事件监听基类,预留拦截与通用的点击处理:

public abstract class AbsClickListener 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);
}

使用者可以这样使用:

AbsClickListener clickListener = new AbsClickListener() {  
    @Override
    protected void onViewClick(View view) {
        //TODO:处理自己的事件。
    }
};

用这种方式来实现比较简单,也没有兼容性问题。但是使用者在开发的过程中就必须基于这个基类来实现事件监听,对开发约束过大。并且在老代码改造方面也面临来巨大的工作量。而且面对引入的第三方library的监听事件也是束手无策。

2 动态代理模式

通过阅读android.view.View.java的源码,我们可以发现几个关键的方法:

// getListenerInfo方法:返回所有的监听器信息mListenerInfo
ListenerInfo getListenerInfo() {  
    if (mListenerInfo != null) {
        return mListenerInfo;
    }
    mListenerInfo = new ListenerInfo();
    return mListenerInfo;
}
// 监听器信息
static class ListenerInfo {  
    ... // 此处省略各种xxxListener
    /**
     * Listener used to dispatch click events.
     * This field should be made private, so it is hidden from the SDK.
     * {@hide}
     */
    public OnClickListener mOnClickListener;
    /**
     * Listener used to dispatch long click events.
     * This field should be made private, so it is hidden from the SDK.
     * {@hide}
     */
    protected OnLongClickListener mOnLongClickListener;
    ...
}
ListenerInfo mListenerInfo;  
// 我们非常熟悉的方法,内部其实是把mListenerInfo的mOnClickListener设成了我们创建的OnclickListner对象
public void setOnClickListener(@Nullable OnClickListener l) {  
    if (!isClickable()) {
        setClickable(true);
    }
    getListenerInfo().mOnClickListener = l;
}
/**
 * 判断这个View是否设置了点击监听器
 * Return whether this view has an attached OnClickListener.  Returns
 * true if there is a listener, false if there is none.
 */
public boolean hasOnClickListeners() {  
    ListenerInfo li = mListenerInfo;
    return (li != null && li.mOnClickListener != null);
}

通过这几个方法我们可以看到,点击监听器其实被保存在了mListenerInfo.mOnClickListener里面。那么实现hook点击监听器时,只要将这个mOnClickListener替换成我们包装的点击监听器代理对象就可以实现点击监听的代理了。

我们先来创建一个监听器代理类:

// 点击监听器的代理类,具有上报点击行为的功能
class OnClickListenerProxy implements View.OnClickListener {  
    // 原始的点击监听器对象
    private View.OnClickListener onClickListener;
    public OnClickListenerProxy(View.OnClickListener onClickListener) {
        this.onClickListener = onClickListener;
    }
    @Override
    public void onClick(View view) {
        // 让原来的点击监听器正常工作
        if(onClickListener != null){
            onClickListener.onClick(view);
        }
        // 点击事件上报,可以获取被点击view的一些属性
        hackLog(EVENT_ID, getProperties(view));
    }
}

反射调用android.view.View的getListenerInfo方法获取mListenerInfo对象,然后再从mListenerInfo中获取mOnClickListener对象:

// hook一个View的监听器,替换成上面的代理监听器
public void hookListener(View view) {  
    // 1. 反射调用View的getListenerInfo方法(API>=14),获得mListenerInfo对象
    Class viewClazz = Class.forName("android.view.View");
    Method getListenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
    if (!getListenerInfoMethod.isAccessible()) {
        getListenerInfoMethod.setAccessible(true);
    }
    Object mListenerInfo = listenerInfoMethod.invoke(view);

    // 2. 然后从mListenerInfo中反射获取mOnClickListener对象
    Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
    Field onClickListenerField = listenerInfoClazz.getDeclaredField("mOnClickListener");
    if (!onClickListenerField.isAccessible()) {
        onClickListenerField.setAccessible(true);
    }
    View.OnClickListener mOnClickListener = (View.OnClickListener) onClickListenerField.get(mListenerInfo);

    // 3. 创建代理的点击监听器对象
    View.OnClickListener mOnClickListenerProxy = new OnClickListenerProxy(mOnClickListener);

    // 4. 把mListenerInfo的mOnClickListener设成新的onClickListenerProxy
    onClickListenerField.set(mListenerInfo, mOnClickListenerProxy);
    // 用这个似乎也可以:view.setOnClickListener(mOnClickListenerProxy);     
}
3 注册AccessibilityDelegate捕获点击事件。

系统在处理点击事件的回调时调用了 View.performClick 方法,内部调用了sendAccessibilityEvent而此方法有个托管接口mAccessibilityDelegate可以由外部处理所有的 AccessibilityEvent. 正好此托管接口的设置也是开放的setAccessibilityDelegate,可以看一下android.view.View这部分的代码:

    public void setAccessibilityDelegate(@Nullable AccessibilityDelegate delegate) {
        mAccessibilityDelegate = delegate;
    }

    public boolean performClick() {
        // We still need to call this method to handle the cases where performClick() was called
        // externally, instead of through performClickInternal()
        notifyAutofillManagerOnClick();

        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);

        notifyEnterOrExitForAutoFillIfNeeded(true);

        return result;
    }

    public void sendAccessibilityEvent(int eventType) {
        if (mAccessibilityDelegate != null) {
            mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
        } else {
            sendAccessibilityEventInternal(eventType);
        }
    }

所以,我们可以自己继承AccessibilityDelegate实现一个自定义的Delegate,注册到View中去监听系统的点击事件:

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);
        //TODO 这里处理通用的事件回调,host 就是相应被点击的 View;eventType就是相应的事件类型
    }

这个自定义Delegate注册出发点是监测到ViewTree发生变化后,递归给所有的View注册,兼容性方面可能会有点问题。

4 全局Hook App的View

在Activity创建完成调用onResume()的时候,获取contentView,在contentView与contentView的Parent之间插入一个透明的View。然后处理该透明View的dispatchTouchEvent()方法,分析并查找事件消费者的View。

app.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {  
            @Override
            public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
            }

            @Override
            public void onActivityStarted(Activity activity) {
            }

            @Override
            public void onActivityResumed(Activity activity) {
                try {
                    bindTouchLayout(activity);
                } catch (Exception e) {
                    getLogConsumer().hackError(e.getMessage());
                    e.printStackTrace();
                }
            }

            @Override
            public void onActivityPaused(Activity activity) {
            }

            @Override
            public void onActivityStopped(Activity activity) {
            }

            @Override
            public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
            }

            @Override
            public void onActivityDestroyed(Activity activity) {
            }
        });

private void bindTouchLayout(Activity activity) {  
        if (activity == null) {
            return;
        }
        ViewGroup root = activity.findViewById(android.R.id.content);
        if (root == null) {
            return;
        }
        if (root.getChildCount() <= 0) {
            return;
        }
        View child = root.getChildAt(0);
        if (child instanceof TouchHookLayout) {
            return;
        }
        root.removeView(child);
        TouchHookLayout hookLayout = new TouchHookLayout(activity, this);
        root.addView(hookLayout, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        hookLayout.addView(child, new TouchHookLayout.LayoutParams(child.getLayoutParams()));
    }

上面的代码其实就是我们注册Application的registerActivityLifecycleCallbacks()方法来监听每个Activity的声明周期,然后在每个Activity显示onResume的时候插入我们自己的TouchHookLayout。

@Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        boolean consumed = super.dispatchTouchEvent(ev);
        int action = ev.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                if (consumed) {
                    mTouchRecord = TouchRecord.startDown(ev);
                    findTargetView(this, mTouchRecord.getDown());
                }
                break;
            case MotionEvent.ACTION_MOVE:
            case MotionEvent.ACTION_HOVER_MOVE:
                if (mTouchRecord != null) {
                    mTouchRecord.move(ev);
                    findTargetView(this, mTouchRecord.getMove());
                }
                break;
            case MotionEvent.ACTION_UP:
                if (mTouchRecord != null) {
                    findTargetView(this, mTouchRecord.getUp());
                }
            case MotionEvent.ACTION_CANCEL:
                if (mTouchRecord != null) {
                    mTouchRecord.leave(ev);
                }
                if (mTouchHookListener != null) {
                    mTouchHookListener.onTouchHook(this, mTouchRecord);
                }
                break;
            default:
                break;
        }
        return consumed;
    }

    private void findTargetView(View view, TouchRecord.Target target) {
        if (target == null) {
            return;
        }
        PathFinder pathFinder = target.getPathFinder();

        View childView = view;
        if (view instanceof ViewGroup) {
            try {
                Object mFirstTouchTarget = HookUtil.get(view, ViewGroup.class, FIELD_NAME_FIRST_TOUCH_TARGET);
                if (mFirstTouchTarget != null) {
                    Object child = HookUtil.get(mFirstTouchTarget, FIELD_NAME_CHILD);
                    if (child instanceof View) {
                        childView = (View) child;
                        if (pathFinder != null) {
                            pathFinder.addPath(childView);
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
                if (mTouchHookListener != null) {
                    mTouchHookListener.onTouchError(e.getMessage());
                }
            }
        }

        target.setView(childView);

        if (childView != view) {
            findTargetView(childView, target);
        }
    }

在这里我们重写dispatchTouchEvent方法,用于捕获touch事件。当捕获到touch事件时,我们反射获取android.View.ViewGroup的mFirstTouchTarget对象,这个对象保存了所有的即将消费该touch事件的所有View链路。我们可以递归反射获取的mFirstTouchTarget的child对象,直到获取的child对象为null,则说明我们找到了最终的touch事件消费View。