AppCompatXX檢視元件在5.0以下系統使用的問題
問題
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檢視元件了。
哎,挖到最後,還是要知難而退了。