在我们日常开发当中,有三种功能是非常常见的,过滤重复点击(一般都要实现),某些功能模块需要登陆才能跳转(非必须),控件的点击需要有触碰效果(非必须)来反馈给用户
重复点击 这个有很多种方法来实现,比如在每个控件的点击事件中判断是否快速点击,代码为这样
private int MIN_CLICK_DELAY_TIME = 500;
private long lastClickTime = 0;
void click(){
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
long currentTime = System.currentTimeMillis();
if (currentTime - lastClickTime > MIN_CLICK_DELAY_TIME) {
lastClickTime = currentTime;
}
}
});
}
就是在每个控件的点击中注入这样的判断逻辑,即本次的点击距离上次点击时间超过500毫秒才让点击,当然这里可以写一个方法来返回boolean值来判断,就不用在每个onclick方法里面都写这堆代码那么lot。但即使这样也足以让人恶心,要知道一个项目中的点击事件是非常的多的,难道我们真的要在每个控件的点击事件中去这样判断。这当然是我们不乐意的,所以就要想一个办法来实现这种全局的判断了。
毫无疑问,要想实现这种全局的过滤重复点击效果,除了反射,我想不到其他方法了,如果还有知道其他方法的同学请告知我哈“-?-”
这里参考了该博文并且进行了优化
我们简要说明下如何反射来实现
我们一般给控件设置点击事件都是这样L:
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
}
});
然后我们点击setOnClickListener方法进去里面看做了什么
/**
* Register a callback to be invoked when this view is clicked. If this view is not
* clickable, it becomes clickable.
*
* @param l The callback that will run
*
* @see #setClickable(boolean)
*/
public void setOnClickListener(@Nullable OnClickListener l) {
if (!isClickable()) {
setClickable(true);
}
getListenerInfo().mOnClickListener = l;
}
看到,如果控件不是可点击的,就把他设为可点击,并且把我们的OnClickListener保存在了 getListenerInfo().mOnClickListener中,我们跟着getListenerInfo()方法进去看
ListenerInfo getListenerInfo() {
if (mListenerInfo != null) {
return mListenerInfo;
}
mListenerInfo = new ListenerInfo();
return mListenerInfo;
}
这里看到他返回的是ListenerInfo对象,那我们就进去这个对象里面看
static class ListenerInfo {
/**
* Listener used to dispatch focus change events.
* This field should be made private, so it is hidden from the SDK.
* {@hide}
*/
protected OnFocusChangeListener mOnFocusChangeListener;
/**
* 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;
private OnTouchListener mOnTouchListener;
private OnHoverListener mOnHoverListener;
private OnGenericMotionListener mOnGenericMotionListener;
private OnDragListener mOnDragListener;
}
可以看到我们设置的mOnClickListener被保存在这里了,并且我们还可以看到控件的长按事件对象也是保存在这里了,还有其他的一些事件等都保存在这里
找到了存储的对象,那么我们所要反射的对象也就明了了
下面直接上代码
public class AvoidMutipleClickHelper {
private static final String TAG = "AvoidMutipleClickHelper";
public Application.ActivityLifecycleCallbacks lifecycleCallbacks = new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
fixViewMutiClickInShortTime(activity);
}
@Override
public void onActivityStarted(Activity activity) {
//一般给控件设置点击事件都是在oncreate中设置的,所以在这个方法中移除ViewTree的监听,如果不移除会一直调用onGlobalLayout() ,影响性能
activity.getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener);
}
@Override
public void onActivityResumed(Activity activity) {
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
};
private void findClickNeedLoginViews(Activity activity) {
if (activity instanceof FragmentActivity) {
FragmentActivity fragmentActivity = (FragmentActivity) activity;
List<Fragment> fragments = fragmentActivity.getSupportFragmentManager().getFragments();
findFragmentClickNeedLoginViews(fragments);
}
findLoginViews(activity);
}
//遍历activity中的fragment
private void findFragmentClickNeedLoginViews(List<Fragment> fragments) {
if (fragments != null && fragments.size() > 0) {
for (Fragment fragment : fragments) {
findLoginViews(fragment);
}
}
}
//查找activity或fragment中的被CheckLogin注解修饰的控件,并设置tag或ContentDescription
private void findLoginViews(Object fragment) {
try {
Field[] declaredFields = fragment.getClass().getDeclaredFields();
for (Field declaredField : declaredFields) {
declaredField.setAccessible(true);
if (declaredField.getAnnotation(CheckLogin.class) != null) {
View view = (View) declaredField.get(fragment);
//过滤imageview 因为项目中有用到glide来加载图片的话,会报错
if (!(view instanceof ImageView)) {
view.setTag("needLogin");
} else {
view.setContentDescription("needLogin");
}
}
}
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, " fragment " + e.getMessage());
}
}
ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener;
//防止短时间内多次点击,弹出多个activity 或者 dialog ,等操作
public void fixViewMutiClickInShortTime(final Activity activity) {
activity.getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
@Override
public void onGlobalLayout() {
onGlobalLayoutListener = this;
//查找点击事件需要登陆的view
// Log.e(TAG," "+executeCount);
findClickNeedLoginViews(activity);
proxyOnlick(activity.getWindow().getDecorView());
}
});
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckLogin {
}
public void proxyOnlick(View view) {
if (view.getVisibility() == View.VISIBLE) {
if (view instanceof ViewGroup) {
ViewGroup p = (ViewGroup) view;
int childCount = p.getChildCount();
for (int i = 0; i < childCount; i++) {
View child = p.getChildAt(i);
proxyOnlick(child);
}
getClickListenerForView(p);
} else {
getClickListenerForView(view);
}
}
}
//通过反射 查找到view 的clicklistener
public void getClickListenerForView(final View view) {
try {
Class viewClazz = Class.forName("android.view.View");
Method listenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
if (!listenerInfoMethod.isAccessible()) {
listenerInfoMethod.setAccessible(true);
}
Object listenerInfoObj = listenerInfoMethod.invoke(view);
Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
//Field onClickListenerField = listenerInfoObj.getClass().getDeclaredField("mOnClickListener");
Field onClickListenerField = listenerInfoClazz.getDeclaredField("mOnClickListener");
if (null != onClickListenerField) {
if (!onClickListenerField.isAccessible()) {
onClickListenerField.setAccessible(true);
}
View.OnClickListener mOnClickListener = (View.OnClickListener) onClickListenerField.get(listenerInfoObj);
if (!(mOnClickListener instanceof ProxyOnclickListener)) {
final View.OnClickListener onClickListenerProxy = new ProxyOnclickListener(mOnClickListener);
onClickListenerField.set(listenerInfoObj, onClickListenerProxy);
//点击效果只能兼容到6.0及以上
if (view.isClickable() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M&& !(view instanceof ListView) && !(view instanceof EditText) && !(view instanceof ImageView)) {
Object tag = view.getTag();
final GradientDrawable gradientDrawable=new GradientDrawable();
//设置点击效果的颜色 可以自行替换颜色
gradientDrawable.setColor(Color.parseColor("#22000000"));
if (view.getBackground() != null && view.getBackground() instanceof GradientDrawable) {
//如果控件有设置了backgroud且设置了圆角值(radius),那么这个点击效果就要加上这些圆角值
//并且7.0以上的还没办法直接用这个方法来获取圆角值,只能手动赋予相应的圆角值
// 如 gradientDrawable.setCornerRadius(view.getContext().getResources().getDimensionPixelSize(R.dimen.xxx));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
gradientDrawable.setCornerRadius(((GradientDrawable) view.getBackground()).getCornerRadius());
}else {
//这里的radius自行替换
int radius=10;
gradientDrawable.setCornerRadius(radius);
}
}
view.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (MotionEvent.ACTION_DOWN == event.getAction()) {
view.setForeground(gradientDrawable);
} else if (MotionEvent.ACTION_UP == event.getAction()) {
view.setForeground(null);
}
return false;
}
});
}
}
}
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG,e.getMessage());
}
}
//自定义的代理事件监听器
class ProxyOnclickListener implements View.OnClickListener {
private View.OnClickListener onclick;
private int MIN_CLICK_DELAY_TIME = 500;
private long lastClickTime = 0;
public ProxyOnclickListener(View.OnClickListener onclick) {
this.onclick = onclick;
}
@Override
public void onClick(View v) {
long currentTime = System.currentTimeMillis();
if (currentTime - lastClickTime > MIN_CLICK_DELAY_TIME) {
lastClickTime = currentTime;
if (isNeedLogin(v)) {
return;
}
if (onclick != null) {
onclick.onClick(v);
}
}
}
//判断点击这个控件的时候,是否已经登陆了
private boolean isNeedLogin(View v) {
// Log.e(TAG,v.getTag()==null?"null is tag":v.getTag().getClass().getSimpleName() );
if (v.getTag() != null && v.getTag() instanceof String) {
String tag = (String) v.getTag();
if (tag.equals("needLogin") && !isLogin()) {
Toast.makeText(v.getContext(), "this field is need login", Toast.LENGTH_SHORT).show();
return true;
}
}
if (v.getContentDescription() != null && v.getContentDescription().toString().equals("needLogin") && !isLogin()) {
Log.e(TAG, " ContentDescription is " + v.getContentDescription().toString());
Toast.makeText(v.getContext(), "this view click need login", Toast.LENGTH_SHORT).show();
return true;
}
return false;
}
}
private boolean isLogin() {
//这里来写获取用户是否已经登录了的逻辑
return userManager.isLogin();
}
}
我把代码写在了一个类中,不写在application中,因为不想让application代码变得太多,显得臃肿。接下来只需要在application的实现如下代码
class App : Application() {
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(AvoidMutipleClick().lifecycleCallbacks)
}
}
那么我们的项目就再也不怕用户快速点击会打开两个页面的那种烦恼了
并且同时该类也已经实现控件的点击触感效果了,但是只支持安卓系统6.0及以上,低于该版本的系统,点击会没有触感效果
---------------------------
接下来我们看看什么使用注解判断点击事件是否需要登录
比如在一个activity中我们在一个点击事件可能会写这样的代码
private RelativeLayout qqLayout;
private void jumb(){
qqLayout.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if(userManage.isLogin()){
//do something
}else {
startActivity(new Intent(this,LoginAcitvity.class));
}
}
});
}
那么使用了注解之后,变成这样
@AvoidMutipleClick.CheckLogin
private RelativeLayout qqLayout;
private void jumb(){
qqLayout.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//do something
}
});
}
看,点击事件里面的判断逻辑不用在这里写了,而是放到了我们的那个类中,还有就是在qqLayout变量的上面加了我们写的@AvoidMutipleClick.CheckLogin注解
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckLogin {
}
,这个注解的作用就是一个标志而已,没有什么其他的作用,就是方便我们在反射当中,获取该控件是否需要登录的点击动作的一个标记
private boolean isLogin() {
//这里来写获取用户是否已经登录了的逻辑
return userManager.isLogin();
}
原理就是如果用户没有登录,我就不然他执行点击操作
======================接下来我们来讲解代码,总的代码量也不多,也就200多行,可以接受,看代码
public Application.ActivityLifecycleCallbacks lifecycleCallbacks = new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
fixViewMutiClickInShortTime(activity);
}
@Override
public void onActivityStarted(Activity activity) {
//一般给控件设置点击事件都是在oncreate中设置的,所以在这个方法中移除ViewTree的监听,如果不移除会一直调用onGlobalLayout() ,影响性能
activity.getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener);
}
@Override
public void onActivityResumed(Activity activity) {
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
};
这个对象是可以监听到所有activity的生命周期,因为我们前面已经把它注册到了application当中,看它的回调方法,就是activity的生命周期嘛,没什么好讲的,我们重点看 fixViewMutiClickInShortTime(activity);方法
//防止短时间内多次点击,弹出多个activity 或者 dialog ,等操作
public void fixViewMutiClickInShortTime(final Activity activity) {
activity.getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
@Override
public void onGlobalLayout() {
onGlobalLayoutListener = this;
//查找点击事件需要登陆的view
// Log.e(TAG," "+executeCount);
findClickNeedLoginViews(activity);
proxyOnlick(activity.getWindow().getDecorView());
}
});
}
看到,在这里存储了ViewTreeObserver.OnGlobalLayoutListener-----> onGlobalLayoutListener = this;这是方便我们在activity的onstart()方法当中移除对viewTree的监听,如果不移除的话,这个方法会不间断的回调,影响性能。有人可能会问,为什么不在这个方法里面直接移除监听
activity.getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this);
,还要去到activity的oncreate()方法中移除
@Override
public void onActivityStarted(Activity activity) {
//一般给控件设置点击事件都是在oncreate中设置的,所以在这个方法中移除ViewTree的监听,如果不移除会一直调用onGlobalLayout() ,影响性能
activity.getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener);
}
这是不行的,而且我也试过了,确实不行,因为那样控件的过滤重复点击会失效。下面讲下我理解的原因,由于个人对view相关的绘制流程知识理解有限,如果存在理解有误的地方,欢迎大家指出。
查看源码后知道,view的onGlobalLayout方法是在android.view.ViewTreeObserver#dispatchOnGlobalLayout调用的,所有我们只要看dispatchOnGlobalLayout在哪里被调用的就好了,查阅源码得知,在ViewRootImpl(源码级别为api28版本)的performTraversals方法里
private void performTraversals(){
//删除了大量代码
if (triggerGlobalLayoutListener) {
mAttachInfo.mRecomputeGlobalAttributes = false;
mAttachInfo.mTreeObserver.dispatchOnGlobalLayout();
}
//删除了大量代码
}
代码是精简后的,可以看到,只要triggerGlobalLayoutListener为true,就会执行dispatchOnGlobalLayout()方法,那么什么时候triggerGlobalLayoutListener为true呢,我们接着看代码,发现这个类中改变量只出现两次
boolean triggerGlobalLayoutListener = didLayout
|| mAttachInfo.mRecomputeGlobalAttributes;
它是又didLayout|| mAttachInfo.mRecomputeGlobalAttributes决定,这里我们只需要看didLayout就可以,
final boolean didLayout = layoutRequested && (!mStopped || mReportNextDraw);
这里我们也只要看layoutRequested,看它什么时候赋值,
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();
}
}
看到,requestLayout(),我们的view就是在该方法之后开始被布局的,在里面 mLayoutRequested = true; ViewRootImpl就是在requestLayout()之后才开始给各个view的绘制流程的,而到这里我们也就知道 mAttachInfo.mTreeObserver.dispatchOnGlobalLayout();是在requestLayout()之后执行的,并不一定是在view绘制完成时调用的(onmeasure-> onlayout ->ondraw),那么我们在oncreate()方法中移除对viewTree的监听,就不一定能获取view的setonClickListener所设置的对象,获取不到的话,就不能设置为我们的代理onclicklistener对象。
好了,讲完了为什么不能在oncreate方法移除监听之后,我们继续看代码
findClickNeedLoginViews(activity);
这个方法是查找activity和fragment中的所有被CheckLogin注解标识过的控件
private void findClickNeedLoginViews(Activity activity) {
if (activity instanceof FragmentActivity) {
FragmentActivity fragmentActivity = (FragmentActivity) activity;
List<Fragment> fragments = fragmentActivity.getSupportFragmentManager().getFragments();
findFragmentClickNeedLoginViews(fragments);
}
findLoginViews(activity);
}
//遍历activity中的fragment
private void findFragmentClickNeedLoginViews(List<Fragment> fragments) {
if (fragments != null && fragments.size() > 0) {
for (Fragment fragment : fragments) {
findLoginViews(fragment);
}
}
}
//查找activity或fragment中的被CheckLogin注解修饰的控件,并设置tag或ContentDescription
private void findLoginViews(Object fragment) {
try {
Field[] declaredFields = fragment.getClass().getDeclaredFields();
for (Field declaredField : declaredFields) {
declaredField.setAccessible(true);
//检查该变量是否被CheckLogin注解标识,是的话,就打上相关的标记,好在点击的时候根据标识是否需要走登录流程
if (declaredField.getAnnotation(CheckLogin.class) != null) {
View view = (View) declaredField.get(fragment);
//过滤imageview 因为项目中有用到glide来加载图片的话,会报错
if (!(view instanceof ImageView)) {
view.setTag("needLogin");
} else {
view.setContentDescription("needLogin");
}
}
}
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, " fragment " + e.getMessage());
}
}
给需要登录的view打上标识之后,那么在实际点击的时候我们就要根据该标识来判断是否需要登录的操作
//自定义的代理事件监听器
class ProxyOnclickListener implements View.OnClickListener {
private View.OnClickListener onclick;
private int MIN_CLICK_DELAY_TIME = 500;
private long lastClickTime = 0;
public ProxyOnclickListener(View.OnClickListener onclick) {
this.onclick = onclick;
}
@Override
public void onClick(View v) {
long currentTime = System.currentTimeMillis();
if (currentTime - lastClickTime > MIN_CLICK_DELAY_TIME) {
lastClickTime = currentTime;
if (isNeedLogin(v)) {
return;
}
if (onclick != null) {
onclick.onClick(v);
}
}
}
//判断点击这个控件的时候,是否已经登陆了
private boolean isNeedLogin(View v) {
// Log.e(TAG,v.getTag()==null?"null is tag":v.getTag().getClass().getSimpleName() );
if (v.getTag() != null && v.getTag() instanceof String) {
String tag = (String) v.getTag();
if (tag.equals("needLogin") && !isLogin()) {
Toast.makeText(v.getContext(), "this field is need login", Toast.LENGTH_SHORT).show();
return true;
}
}
if (v.getContentDescription() != null && v.getContentDescription().toString().equals("needLogin") && !isLogin()) {
Log.e(TAG, " ContentDescription is " + v.getContentDescription().toString());
Toast.makeText(v.getContext(), "this view click need login", Toast.LENGTH_SHORT).show();
return true;
}
return false;
}
}
private boolean isLogin() {
//这里获取用户是否已经登录了 todo
return new Random().nextInt(100)%2==0;
}
很简单,就根据前面设置的tag或v.getContentDescription()来找出需要登录才能点击的控件了,至此,注解监控登录的逻辑就完成,下面我们来看全局控件点击触感是什么实现的。
//点击效果只能兼容到6.0及以上
if (view.isClickable() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M&& !(view instanceof ListView) && !(view instanceof EditText) && !(view instanceof ImageView)) {
Object tag = view.getTag();
final GradientDrawable gradientDrawable=new GradientDrawable();
//设置点击效果的颜色 可以自行替换颜色
gradientDrawable.setColor(Color.parseColor("#22000000"));
if (view.getBackground() != null && view.getBackground() instanceof GradientDrawable) {
//如果控件有设置了backgroud且设置了圆角值(radius),那么这个点击效果就要加上这些圆角值
//并且7.0以上的还没办法直接用这个方法来获取圆角值,只能手动赋予相应的圆角值
// 如 gradientDrawable.setCornerRadius(view.getContext().getResources().getDimensionPixelSize(R.dimen.xxx));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
gradientDrawable.setCornerRadius(((GradientDrawable) view.getBackground()).getCornerRadius());
}else {
//这里的radius自行替换
int radius=10;
gradientDrawable.setCornerRadius(radius);
}
}
view.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (MotionEvent.ACTION_DOWN == event.getAction()) {
view.setForeground(gradientDrawable);
} else if (MotionEvent.ACTION_UP == event.getAction()) {
view.setForeground(null);
}
return false;
}
});
}
看到没,就是给view设置前景而已,并且做了过滤,imageview edittext不作处理,有特殊处理需求的同学完全可以在这里自行做一些改变