Android开发经验谈

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

2018-11-16  本文已影响12人  sollian

问题

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视图组件了。

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

上一篇下一篇

猜你喜欢

热点阅读