Android-页面点击事件拦截替换方案

一 需求来源

在一些特定页面中,绝大部分的布局填充内容是复用的UI卡片(组件),这些卡片(组件)在≥2个页面中提现的点击事件可能会随着不同的页面而有不同的响应事件。

在类似这种背景下,一些简单常规的做法:

  1. 对不同的view中设置不同的点击事件;

  2. 页面的差异化导致的点击事件的差异化可通过传参或内部判断的方式进行;

上述做法潜在的问题是:

  1. 随着版本迭代 or UI多样化,需要对每一个view不断地CV操作设置点击事件;
  2. 上述case情况下,绝大多数点击的响应事件统一,没有必要给每一个view CV 相同的点击事件,写了大量的if-else操作,一旦逻辑更改将涉及到n处修改;
  3. UI卡片(组件)内部不应掺杂过多有关页面(Activity/Fragment)差异化的判断;

在这种背景下,提出了一种白名单 + 点击事件拦截的方案,旨在对页面中view的点击事件做拦截,替换成目标事件,而对想保持原响应事件的view进行放过。主要源码可直接翻到最后。

二 方案思路

  1. 在view设置点击事件的时候,为view添加一个tag,表明这个view是白名单;

  2. 获取需要进行事件拦截&替换的View对象,创建完成,添加到界面的时候,对这个view进行遍历;

  • 2.1 看这个view及其子view是否有点击事件;

  • 2.2 如果有点击事件,根据view的tag判断是否是白名单;

  • 2.3 如果是白名单则跳过,如果不是白名单则替换view的点击事件为期望事件;

三 方案分析

3.1 需要获取全所有子view

安卓中一般的ViewGroupRelativeLayoutLinearLayout我们都可以通过viewGroup.getChildAt(i)来获取它们的子view,但是对RecycleView,由于其缓存复用及回收机制,存在一时无法获取其所有itemView的情况;

这种问题我们可以通过RecyclerView的OnChildAttachStateChangeListener()方法来解决:

recyclerView.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() {
                @Override
                public void onChildViewAttachedToWindow(@NonNull View view) {
                    // 该回调中,子view已经可以被获取
                }

                @Override
                public void onChildViewDetachedFromWindow(@NonNull View view) {

                }
            });
复制代码

3.2 需要确定获取子view的时机

存在这样的一些情况我们可能获取不到我们所期望的所有子view:Activity的onCreate方法中,view还没创建完毕;一些容器类的ViewGroup需要配合业务在特定时机addView等。

这个问题需要交给开发者在具体业务下选择合适的时机调用本文所写的拦截替换,时机定在在onCreateView()或者onBIndView()这种方法后。可用接口的方式进行onViewCreated的回调锚定时机。一个常见的例子是,赶集安卓App中常见的ViewHolder思想下所写的一些卡片(cell / Dctrl / XXViewHolder)中,均可在视图创建完成后进行接口回调的通知;

3.3 如何应对非常规的点击事件

所说的常规点击事件指setClickListener,本文所描述的替换点击也指它,但是一些特殊情况,比如富文本的点击事件,是通过Span的形式去实现的。比如:

builder.setSpan(new ClickableSpan() {
            @Override
            public void onClick(@NonNull View widget) {
              // 点击事件
            }
        }, start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
复制代码

这种是实现无法通过获取该TextView的ClickListener实现替换,针对这种情况,可以通过spannable.getSpans()获取所有的ClickableSpan类型,以此对其点击事件进行替换。核心的逻辑如下:

ClickableSpan[] spans = spannable.getSpans(0, spannable.length(), ClickableSpan.class);
                for (ClickableSpan clickableSpan : spans) {
                    int start = spannable.getSpanStart(clickableSpan);
                    int end = spannable.getSpanEnd(clickableSpan);
                    int flags = spannable.getSpanFlags(clickableSpan);
                    spannable.removeSpan(clickableSpan);
                    spannable.setSpan(new ClickableSpan() {
                        @Override
                        public void onClick(@NonNull View view) {
                            interceptEvent(view);  //这是最终需要被替换的具体点击事件方法
                        }
                    }, start, end, flags);
复制代码

四 方案的一个改进

对view设置白名单是我们是使用的setTag(final Object tag)方法,该方法存在的隐患是,tag值会在setTag时覆盖;协同开发者在开发过程中如果在后续操作中由于某些业务实现需求,对已经打上白名单tag的view再进行了setTag(final Object tag)方法,就会将原tag进行覆盖;同样的,反过来我们打白名单tag的操作也可能影响其他人可能存在的tag;

针对这个问题,在ViewClickInterceptHelper中设置key-value值,原本的setTag(final Object tag)方法替换成setTag(int key, final Object tag)方法,以此隔离可能存在的污染;

值得注意的是,setTag(int key, final Object tag)的key值不要占用Android系统的保留或系统已使用的值,如1,-1等;

五 风险告知

本文讨论的目前主要针对的是clickListener,考虑了TextView的富文本点击事件(Spanable),但依然存在一些其他可能非常规点击响应事件需要关注,一个可能存在的case是:自定义view中自定义了特殊的手势操作,并写了相关的响应事件方法。

六 反思总结

实现的过程中尝试过其他一些方案,了解这些方案可以在某些业务场景下,选择更合适的方法。

6.1 OnTouchListener

在实现的过程中,考虑到想对所有的点击事件毫无例外的拦截,曾尝试使用OnTouchListener。OnTouchListener 的优先级是高于 onTouchEvent 的,并且 OnTouchListener 的返回值能够决定是否还会执行 onTouchEvent 方法;

和采用判断有没有clickListener事件的逻辑一致,这种方案可以在在listener.onTouch()方法中进行白名单判断。这种采用OnTouchListener方案的好处是,可以拦截到任何的点击事件,不会存在一些点击没有捕捉到情况。

但是这种方案存在一些不稳定因素:

  1. 更底层的touch拦截,意味着存在更大的潜在隐患。我们常用的click事件、long click事件、滑动事件等,均在View.onTouchEvent()方法中,一个可能的case是可能存在误拦截从而导致列表无法滑动的隐患;
  2. 相比较clickListener的方案,对富文本Span的点击事件,存在问题,case是:对TextView设置tag后,如果通过onTouch()方法拦截并替换事件后,对整个TextView的文本都会响应替换后的事件,而事实可能是我只是对TextView的文本最后四个字如“查看更多”,设置了点击响应事件。

6.2 反射hook点击事件

点击事件我们也可以通过反射获取view的mOnClickListener进行替换,这种方案可如下实行。

  • 根据需求确定要hook的对象
  • 寻找要hook的对象的持有者,拿到要hook的对象
  • 定义要hook的对象的代理类,并且创建该类的对象
  • 使用上一步创建出来的对象,替换掉要hook的对象
public class HookManager {
    
    @SuppressLint({"DiscouragedPrivateApi", "PrivateApi"})
    public static void hook(Context context, final View view) {
        try {
            //1.反射执行View类的getListenerInfo()方法,拿到View的ListenerInfo对象,这个对象就是点击事件的持有者
            Method method = View.class.getDeclaredMethod("getListenerInfo");
            method.setAccessible(true);//由于getListenerInfo()方法并不是public的,所以要加这个代码来保证访问权限
            Object listenerInfo = method.invoke(view);
            //2.取得真实的mOnClickListener对象
            Class<?> listenerInfoClz = Class.forName("android.view.View$ListenerInfo");//这是内部类的表示方法
            Field field = listenerInfoClz.getDeclaredField("mOnClickListener");
            final View.OnClickListener clickListener = (View.OnClickListener) field.get(listenerInfo);
            //3.创建我们自己的点击事件代理类
            //方式1:自己创建代理类
//            ProxyOnClickListener proxyOnClickListener = new ProxyOnClickListener(clickListener);
            //方式2:由于View.OnClickListener是一个接口,所以可以直接用动态代理模式
            //Proxy.newProxyInstance的3个参数依次分别是:
            //本地的类加载器
            //代理类的对象所继承的接口(用Class数组表示,支持多个接口)
            //代理类的实际逻辑,封装在new出来的InvocationHandler内
            Object proxyOnClickListener = Proxy.newProxyInstance(context.getClass().getClassLoader(), new Class[]{View.OnClickListener.class}, new InvocationHandler() {
                @Override
                public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                    Log.e("xjj", "点击事件被hook到了");//加入自己的逻辑
                    return method.invoke(clickListener, args);//执行被代理的对象的逻辑
                }
            });
            //4.用我们自己的点击事件代理类,设置到"持有者"中
            field.set(listenerInfo, proxyOnClickListener);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    //自定义代理类
    private static class ProxyOnClickListener implements View.OnClickListener {

        View.OnClickListener clickListener;

        public ProxyOnClickListener(View.OnClickListener clickListener) {
            this.clickListener = clickListener;
        }

        @Override
        public void onClick(View v) {
            Log.e("xjj", "点击事件被hook到了");//加入自己的逻辑
            if (clickListener != null) {
                clickListener.onClick(v);//执行被代理的对象的逻辑
            }
        }

    }

}
复制代码

给对应的View设置点击事件,并hook这个View,这样就能在View被点击时加入自己的逻辑。

        tv.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.e("xjj", "点击事件");
            }
        });
        HookManager.hook(this, tv);
复制代码

本文没有采用这个方案的原因是考虑到当前需求的实现期望,该方案相比复杂,可直接色值设置view的clickListener进行替换,就没必要再去反射hook了。

七 源码

package com.wuba.ganji.utils;

import static com.wuba.ganji.utils.ViewTraverseHelper.traverseChildView;
import android.text.Spannable;
import android.text.style.ClickableSpan;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

public class ViewClickInterceptHelper {

    public static int CLICK_WHITE_LIST_TAG_KEY = -153231455;
    public static String CLICK_WHITE_LIST_TAG_VALUE = "intercept_click";

    private ClickEvent clickEvent;

    public ViewClickInterceptHelper(ClickEvent clickEvent) {
        this.clickEvent = clickEvent;
    }


    public void interceptClick(View view) {
        traverseChildView(view, new ViewTraverseHelper.OnFindViewListener() {
            @Override
            public void onFindView(View child) {
                dealSpecial(child);
                if (child.hasOnClickListeners()) {
                    dealIntercept(child);
                }
            }
        });
    }

    /**
     * @param view
     * 特殊情况下的一些处理
     */
    public void dealSpecial(View view) {
        // 对TextView 富文本 Span的处理
        if (view instanceof TextView) {
            if (((TextView) view).getText() instanceof Spannable) {
                Spannable spannable = (Spannable) ((TextView) view).getText();
                ClickableSpan[] spans = spannable.getSpans(0, spannable.length(), ClickableSpan.class);
                for (ClickableSpan clickableSpan : spans) {

                    int start = spannable.getSpanStart(clickableSpan);
                    int end = spannable.getSpanEnd(clickableSpan);
                    int flags = spannable.getSpanFlags(clickableSpan);
                    spannable.removeSpan(clickableSpan);
                    spannable.setSpan(new ClickableSpan() {
                        @Override
                        public void onClick(@NonNull View view) {
                            interceptEvent(view);
                        }
                    }, start, end, flags);
                }
            }
        }
        // 对RecycleView子view的缓存加载情况的处理
        if (view instanceof RecyclerView) {
            RecyclerView recyclerView = (RecyclerView) view;
            recyclerView.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() {
                @Override
                public void onChildViewAttachedToWindow(@NonNull View view) {
                    interceptClick(view);
                }

                @Override
                public void onChildViewDetachedFromWindow(@NonNull View view) {

                }
            });

        }
    }

    // 需要判断view是否是白名单tag的view
    public void dealIntercept(View view) {
        if (view.getTag(CLICK_WHITE_LIST_TAG_KEY) != null && view.getTag(CLICK_WHITE_LIST_TAG_KEY).toString().equals(CLICK_WHITE_LIST_TAG_VALUE)){
            // 添加了白名单tag的view
        }else {
            interceptEvent(view);
        }
    }

    //拦截后的事件处理
    public void interceptEvent(View view) {
        // 替换view的点击事件为期望事件
        view.setOnClickListener((v) -> {
            if (this.clickEvent != null){
                clickEvent.clickEvent(view);
            }
        });
    }

    public interface ClickEvent {
        /**
         * 具体执行的
         * 被替换的click方法
         * @param view
         */
        void clickEvent(View view);
    }

}
复制代码
package com.wuba.ganji.utils;

import android.view.View;
import android.view.ViewGroup;

public class ViewTraverseHelper {

    public static void traverseChildView(View view, OnFindViewListener listener) {
        if (view == null || listener == null || !(view instanceof ViewGroup)) {
            // 不是ViewGroup
            return;
        }

        ViewGroup viewGroup = (ViewGroup) view;
        //ViewGroup的点击事件也需要获取
        listener.onFindView(view);
        if (viewGroup.getChildCount() <= 0){
            // 没有子View
            return;
        }

        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            View childView = viewGroup.getChildAt(i);
            listener.onFindView(childView);

            if (childView instanceof ViewGroup) {
                traverseChildView(childView, listener);
            }
        }
    }

    public interface OnFindViewListener {
        void onFindView(View view);
    }
}
复制代码

参考文章

www.jianshu.com/p/62e2c843c…

www.jianshu.com/p/351a211c7…

猜你喜欢

转载自juejin.im/post/7108310736643817508
今日推荐