美文网首页Android开发经验谈
AppCompatXX视图组件在5.0以下系统使用的问题

AppCompatXX视图组件在5.0以下系统使用的问题

作者: sollian | 来源:发表于2018-11-16 21:09 被阅读12次

    问题

    appcompatV7包包含AppCompatXX视图组件,使用这些组件可以在5.0以下版本使用tint属性进行着色。
    比如:

            <android.support.v7.widget.AppCompatImageView
                xmlns:android="http://schemas.android.com/apk/res/android"
                xmlns:app="http://schemas.android.com/apk/res-auto"
                android:id="@+id/image"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="5dp"
                android:adjustViewBounds="true"
                android:onClick="onLog"
                android:src="@drawable/ic_launcher"
                app:tint="#f00" />
    

    使用app:tint="#f00"可以将图标染成红色。这是一个很实用的功能。
    而在5.0以下使用AppCompatImageView等组件有一个bug。上面代码中,我们设置了android:onClick属性,5.0以下会发生如下崩溃:

        java.lang.IllegalStateException: Could not find a method onLog(View) in the activity class android.support.v7.widget.TintContextWrapper for onClick handler on view class android.support.v7.widget.AppCompatImageView with id 'image'
            at android.view.View$1.onClick(View.java:3810)
            at android.view.View.performClick(View.java:4438)
            at android.view.View$PerformClick.run(View.java:18422)
            at android.os.Handler.handleCallback(Handler.java:733)
            at android.os.Handler.dispatchMessage(Handler.java:95)
            at android.os.Looper.loop(Looper.java:136)
            at android.app.ActivityThread.main(ActivityThread.java:5017)
            at java.lang.reflect.Method.invokeNative(Native Method)
            at java.lang.reflect.Method.invoke(Method.java:515)
            at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:779)
            at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:595)
            at dalvik.system.NativeStart.main(Native Method)
    

    看到日志感觉莫名其妙,为什么程序会去android.support.v7.widget.TintContextWrapper这个类中找onLog(View)方法呢?不应该去我们自己的Activity中去找么?

    原因

    带着这个疑问,我们来翻翻源码。
    首先AppCompatImageView的构造函数当中:

        public AppCompatImageView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(TintContextWrapper.wrap(context), attrs, defStyleAttr);
            ...
        }
    

    可以看到,传入的context,也就是我们自己的Activity,被TintContextWrapper包装了一下。

    然后转到View处理onClick属性的地方,api19的源码是这样的:

                    case R.styleable.View_onClick:
                        ...
                        //handlerName就是"android:onClick"属性的值
                        final String handlerName = a.getString(attr);
                        if (handlerName != null) {
                            setOnClickListener(new OnClickListener() {
                                private Method mHandler;
    
                                public void onClick(View v) {
                                    if (mHandler == null) {
                                        try {
                                            //直接使用getContext()方法,得到的是TintContextWrapper实例,所以找不到我们的onLog方法
                                            mHandler = getContext().getClass().getMethod(handlerName,
                                                    View.class);
                                        } catch (NoSuchMethodException e) {
                                            int id = getId();
                                            String idText = id == NO_ID ? "" : " with id '"
                                                    + getContext().getResources().getResourceEntryName(
                                                        id) + "'";
                                            //这个就是我们看到的异常信息
                                            throw new IllegalStateException("Could not find a method " +
                                                    handlerName + "(View) in the activity "
                                                    + getContext().getClass() + " for onClick handler"
                                                    + " on view " + View.this.getClass() + idText, e);
                                        }
                                    }
                                    ...
                                }
                            });
                        }
                        break;
    

    关于崩溃的原因,代码的注释中写的很清楚了。
    那么为什么5.0以上没有问题呢?就在于对context的处理不一样,在查找onLog方法时,代码是这样的:

            private void resolveMethod(@Nullable Context context, @NonNull String name) {
                //首先是个循环查找
                while (context != null) {
                    try {
                        if (!context.isRestricted()) {
                            //首次执行,context就是TintContextWrapper,所以找不到我们的方法
                            final Method method = context.getClass().getMethod(mMethodName, View.class);
                            if (method != null) {
                                mResolvedMethod = method;
                                mResolvedContext = context;
                                return;
                            }
                        }
                    } catch (NoSuchMethodException e) {
                    }
    
                    if (context instanceof ContextWrapper) {
                        //通过getBaseContext就拿到了我们的Activity,第二次循环就能找到我们的方法了
                        context = ((ContextWrapper) context).getBaseContext();
                    } else {
                        context = null;
                    }
                }
                ...
            }
        }
    

    解决方法

    1、暴力解决

    放弃使用AppCompatXX视图组件,不过好挫的赶脚有没有,怎么能知难而退呢?

    2、异想天开

    覆写View的getContext()方法,直接返回context.getBaseContext()不就行了吗?然鹅:

        public final Context getContext() {
            return mContext;
        }
    

    final !

    3、创新才是出路

    LayoutInflater全解析一文中提到,Activity中的View在inflate之前会先调用Activity的如下方法:

        @Nullable
        public View onCreateView(String name, Context context, AttributeSet attrs) {
            return null;
        }
    

    该方法若返回null,则由LayoutInflater来构建View。这就有了处理的余地。
    首先正式开发中都会有一个BaseActivity,在该Activity中覆写上面的方法:

    public class BaseActivity extends AppCompatActivity {
        @Override
        public View onCreateView(String name, Context context, AttributeSet attrs) {
            return WidgetUtil.onCreateView(super.onCreateView(name, context, attrs), name, context, attrs);
        }
    }
    
    

    WidgetUtil.java如下:

    class WidgetUtil {
        static View onCreateView(View view, String name, Context context, AttributeSet attrs) {
            //5.0以上系统没有问题,直接返回
            if (Build.VERSION.SDK_INT >= 21) {
                return view;
            }
    
            //查找是否设置了android:onClick属性,没有设置直接返回
            TypedArray a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.onClick});
            String handlerName = a.getString(0);
            if (handlerName == null) {
                return view;
            }
    
            //view一般来说都是null
            if (view == null) {
                //这里注意,LayoutInflater.from(context)一定要使用这个context,不要使用BaseActivity作为参数,否则可能出问题。
                view = WidgetUtil.getViewByName(LayoutInflater.from(context), name, attrs);
                if (view == null) {
                    return null;
                }
            }
    
            //重新给view设置监听器
            view.setOnClickListener(new DeclaredOnClickListener(view, handlerName));
            a.recycle();
            return view;
        }
    
        @Nullable
        private static View getViewByName(LayoutInflater inflater, String name, AttributeSet attrs) {
            if (TextUtils.isEmpty(name)) {
                return null;
            }
    
            /*
            过滤自己App中定义的各个View的基类
             */
    //        String[] parts = name.split("\\.");
    //        String viewName = parts[parts.length - 1];
    //        if (!viewName.startsWith("Custom")) {
    //            return null;
    //        }
    
            try {
                //调用LayoutInflater的方法来创建view,其实就是通过反射来创建
                return inflater.createView(name, null, attrs);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
            return null;
        }
    
        //监听器的源码拷贝自5.0以上View#DeclaredOnClickListener类
        private static class DeclaredOnClickListener implements View.OnClickListener {
            private final View mHostView;
            private final String mMethodName;
    
            private Method mResolvedMethod;
            private Context mResolvedContext;
    
            DeclaredOnClickListener(@NonNull View hostView, @NonNull String methodName) {
                mHostView = hostView;
                mMethodName = methodName;
            }
    
            @Override
            public void onClick(@NonNull View v) {
                if (mResolvedMethod == null) {
                    resolveMethod(mHostView.getContext(), mMethodName);
                }
    
                try {
                    mResolvedMethod.invoke(mResolvedContext, v);
                } catch (IllegalAccessException e) {
                    throw new IllegalStateException(
                            "Could not execute non-public method for android:onClick", e);
                } catch (InvocationTargetException e) {
                    throw new IllegalStateException(
                            "Could not execute method for android:onClick", e);
                }
            }
    
            private void resolveMethod(@Nullable Context context, @NonNull String name) {
                while (context != null) {
                    try {
                        if (!context.isRestricted()) {
                            Method method = context.getClass().getMethod(name, View.class);
                            if (method != null) {
                                mResolvedMethod = method;
                                mResolvedContext = context;
                                return;
                            }
                        }
                    } catch (NoSuchMethodException e) {
                        // Failed to find method, keep searching up the hierarchy.
                    }
    
                    if (context instanceof ContextWrapper) {
                        context = ((ContextWrapper) context).getBaseContext();
                    } else {
                        // Can't search up the hierarchy, null out and fail.
                        context = null;
                    }
                }
    
                int id = mHostView.getId();
                String idText = id == View.NO_ID ? "" : " with id '"
                        + mHostView.getContext().getResources().getResourceEntryName(id) + "'";
                throw new IllegalStateException("Could not find method " + name
                        + "(View) in a parent or ancestor Context for android:onClick "
                        + "attribute defined on view " + mHostView.getClass() + idText);
            }
        }
    }
    

    OK,完美解决!


    还没完!!

    上面虽然解决了设置android:onClick属性崩溃的问题,但是还有一个更加严重的问题,那就是上面提到的getContext()函数的返回值问题!
    由于在5.0以下使用AppCompat组件时,getContext()方法返回的是TintContextWrapper这样一个类的实例,所以类似getContext() instanceOf XXActivity的调用全部为false,会导致已有代码需要不小的改动。

    所以,为了稳定,如果程序最小使用版本在5.0以下,还是别用AppCompatXX视图组件了。

    哎,挖到最后,还是要知难而退了。

    相关文章

      网友评论

        本文标题:AppCompatXX视图组件在5.0以下系统使用的问题

        本文链接:https://www.haomeiwen.com/subject/dgcvfqtx.html