堅持原創日更,短平快的 Android 進階系列,敬請直接在微信公眾號搜尋:nanchen,直接關注並設為星標,精彩不容錯過。
一般我們被問到這樣的問題,通常來說,答案都是否定的,但一定得知道其中的原因,不然回答肯定與否又有什麼意義呢。
首先,顯而易見這個問題有不少陷阱,比如這個 View 是自己構造出來的,那肯定它的 getContext()
返回的是構造它的時候傳入的 Context
型別。
它也可能返回的是 TintContextWrapper
那,如果是 XML 裡面的 View 呢,會怎樣?可能不少人也知道了另外一個結論:直接繼承 Activity 的 Activity 構造出來的 View.getContext() 返回的是當前 Activity。但是:當 View 的 Activity 是繼承自 AppCompatActivity,並且在 5.0 以下版本的手機上,View.getContext() 得到的並非是 Activity,而是 TintContextWrapper。
不太熟悉 Context
的繼承關係的小夥伴可能也會很奇怪,正常來說,自己所知悉的 Context
繼承關係圖是這樣的。
Activity.setContentView()
我們可以先看看 Activity.setContentView()
方法:
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}
不過是直接呼叫 Window
的實現類 PhoneWindow
的 setContentView()
方法。看看 PhoneWindow
的 setContentView()
是怎樣的。
@Override
public void setContentView(int layoutResID) {
// Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
// decor, when theme attributes and the like are crystalized. Do not check the feature
// before this happens.
if (mContentParent == null) {
installDecor();
} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
mContentParent.removeAllViews();
}
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
mLayoutInflater.inflate(layoutResID, mContentParent);
}
mContentParent.requestApplyInsets();
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged();
}
mContentParentExplicitlySet = true;
}
假如沒有 FEATURE_CONTENT_TRANSITIONS
標記的話,就直接通過 mLayoutInflater.inflate()
載入出來。這個如果有 mLayoutInflater
的是在PhoneWindow
的構造方法中被初始化的。而 PhoneWindow
的初始化是在 Activity
的 attach()
方法中:
final void attach(Context context, ActivityThread aThread,
Instrumentation instr, IBinder token, int ident,
Application application, Intent intent, ActivityInfo info,
CharSequence title, Activity parent, String id,
NonConfigurationInstances lastNonConfigurationInstances,
Configuration config, String referrer, IVoiceInteractor voiceInteractor,
Window window, ActivityConfigCallback activityConfigCallback) {
attachBaseContext(context);
mFragments.attachHost(null /*parent*/);
mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(this);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
// 此處省略部分程式碼...
}
所以 PhoneWindow
的 Context
實際上就是 Activity
本身。
在回到我們前面分析的 PhoneWindow
的 setContentView()
方法,如果有 FEATURE_CONTENT_TRANSITIONS
標記,直接呼叫了一個 transitionTo()
方法:
private void transitionTo(Scene scene) {
if (mContentScene == null) {
scene.enter();
} else {
mTransitionManager.transitionTo(scene);
}
mContentScene = scene;
}
在看看 scene.enter()
方法。
public void enter() {
// Apply layout change, if any
if (mLayoutId > 0 || mLayout != null) {
// empty out parent container before adding to it
getSceneRoot().removeAllViews();
if (mLayoutId > 0) {
LayoutInflater.from(mContext).inflate(mLayoutId, mSceneRoot);
} else {
mSceneRoot.addView(mLayout);
}
}
// Notify next scene that it is entering. Subclasses may override to configure scene.
if (mEnterAction != null) {
mEnterAction.run();
}
setCurrentScene(mSceneRoot, this);
}
基本邏輯沒必要詳解了吧?還是通過這個 mContext
的 LayoutInflater
去 inflate
的佈局。這個 mContext
初始化的地方是:
public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context) {
SparseArray<Scene> scenes = (SparseArray<Scene>) sceneRoot.getTag(
com.android.internal.R.id.scene_layoutid_cache);
if (scenes == null) {
scenes = new SparseArray<Scene>();
sceneRoot.setTagInternal(com.android.internal.R.id.scene_layoutid_cache, scenes);
}
Scene scene = scenes.get(layoutId);
if (scene != null) {
return scene;
} else {
scene = new Scene(sceneRoot, layoutId, context);
scenes.put(layoutId, scene);
return scene;
}
}
即 Context
來源於外面傳入的 getContext()
,這個 getContext()
返回的就是初始化的 Context
也就是 Activity
本身。
AppCompatActivity.setContentView()
我們不得不看看 AppCompatActivity
的 setContentView()
是怎麼實現的。
public void setContentView(@LayoutRes int layoutResID) {
this.getDelegate().setContentView(layoutResID);
}
@NonNull
public AppCompatDelegate getDelegate() {
if (this.mDelegate == null) {
this.mDelegate = AppCompatDelegate.create(this, this);
}
return this.mDelegate;
}
這個 mDelegate
實際上是一個代理類,由 AppCompatDelegate
根據不同的 SDK 版本生成不同的實際執行類,就是代理類的相容模式:
/**
* Create a {@link android.support.v7.app.AppCompatDelegate} to use with {@code activity}.
*
* @param callback An optional callback for AppCompat specific events
*/
public static AppCompatDelegate create(Activity activity, AppCompatCallback callback) {
return create(activity, activity.getWindow(), callback);
}
private static AppCompatDelegate create(Context context, Window window,
AppCompatCallback callback) {
final int sdk = Build.VERSION.SDK_INT;
if (BuildCompat.isAtLeastN()) {
return new AppCompatDelegateImplN(context, window, callback);
} else if (sdk >= 23) {
return new AppCompatDelegateImplV23(context, window, callback);
} else if (sdk >= 14) {
return new AppCompatDelegateImplV14(context, window, callback);
} else if (sdk >= 11) {
return new AppCompatDelegateImplV11(context, window, callback);
} else {
return new AppCompatDelegateImplV9(context, window, callback);
}
}
關於實現類 AppCompatDelegateImpl
的 setContentView()
方法這裡就不做過多分析了,感興趣的可以直接移步掘金上的 View.getContext() 裡的小祕密 進行查閱。
不過這裡還是要結合小緣的回答,簡單總結一下:之所以能得到上面的結論是因為我們在 AppCompatActivity
裡面的 layout.xml
檔案裡面使用原生控制元件,比如 TextView
、ImageView
等等,當在 LayoutInflater
中把 XML 解析成 View
的時候,最終會經過 AppCompatViewInflater
的 createView()
方法,這個方法會把這些原生的控制元件都變成 AppCompatXXX
一類。包含了哪些 View 呢?
- RatingBar
- CheckedTextView
- MultiAutoCompleteTextView
- TextView
- ImageButton
- SeekBar
- Spinner
- RadioButton
- ImageView
- AutoCompleteTextView
- CheckBox
- EditText
- Button
那麼重點肯定就是在 AppCompat
這些開頭的控制元件了,隨便開啟一個原始碼吧,比如 AppCompatTextView
。
public AppCompatTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(TintContextWrapper.wrap(context), attrs, defStyleAttr);
this.mBackgroundTintHelper = new AppCompatBackgroundHelper(this);
this.mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr);
this.mTextHelper = new AppCompatTextHelper(this);
this.mTextHelper.loadFromAttributes(attrs, defStyleAttr);
this.mTextHelper.applyCompoundDrawablesTints();
}
可以看到,關鍵是 super(TintContextWrapper.wrap(context), attrs, defStyleAttr);
這行程式碼。我們點進去看看這個 wrap()
做了什麼。
public static Context wrap(@NonNull Context context) {
if (shouldWrap(context)) {
Object var1 = CACHE_LOCK;
synchronized(CACHE_LOCK) {
if (sCache == null) {
sCache = new ArrayList();
} else {
int i;
WeakReference ref;
for(i = sCache.size() - 1; i >= 0; --i) {
ref = (WeakReference)sCache.get(i);
if (ref == null || ref.get() == null) {
sCache.remove(i);
}
}
for(i = sCache.size() - 1; i >= 0; --i) {
ref = (WeakReference)sCache.get(i);
TintContextWrapper wrapper = ref != null ? (TintContextWrapper)ref.get() : null;
if (wrapper != null && wrapper.getBaseContext() == context) {
return wrapper;
}
}
}
TintContextWrapper wrapper = new TintContextWrapper(context);
sCache.add(new WeakReference(wrapper));
return wrapper;
}
} else {
return context;
}
}
可以看到當,shouldWrap()
這個方法返回為 true 的時候,就會採用了 TintContextWrapper
這個物件來包裹了我們的 Context
。來看看什麼情況才能滿足這個條件。
private static boolean shouldWrap(@NonNull Context context) {
if (!(context instanceof TintContextWrapper) && !(context.getResources() instanceof TintResources) && !(context.getResources() instanceof VectorEnabledTintResources)) {
return VERSION.SDK_INT < 21 || VectorEnabledTintResources.shouldBeUsed();
} else {
return false;
}
}
很明顯了吧?如果是 5.0 以前,並且沒有包裝的話,就會直接返回 true;所以也就得出了上面的結論:當執行在 5.0 系統版本以下的手機,並且 Activity
是繼承自 AppCompatActivity
的,那麼View
的 getConext()
方法,返回的就不是 Activity
而是 TintContextWrapper
。
還有其它情況麼?
上面講述了兩種非 Activity
的情況:
- 直接構造
View
的時候傳入的不是Activity
; - 使用
AppCompatActivity
並且執行在 5.0 以下的手機上,XML 裡面的View
的getContext()
方法返回的是TintContextWrapper
。
那不禁讓人想想,還有其他情況麼?有。
我們直接從我前兩天線上灰測包出現的一個 bug 說起。先說說 bug 背景,灰測包是 9.5.0,而線上包是 9.4.0,在灰測包上發生崩潰的程式碼是三個月前編寫的程式碼,也就是說這可能是 8.43.0 或者 9.0.0 加入的程式碼,線上上穩定執行了 4 個版本以上沒有做過任何修改。但在 9.5.0 灰測的時候,這裡卻出現了必現崩潰。
Fatal Exception: java.lang.ClassCastException: android.view.ContextThemeWrapper cannot be cast to android.app.Activity
at com.codoon.common.dialog.CommonDialog.openProgressDialog + 145(CommonDialog.java:145)
at com.codoon.common.dialog.CommonDialog.openProgressDialog + 122(CommonDialog.java:122)
at com.codoon.common.dialog.CommonDialog.openProgressDialog + 116(CommonDialog.java:116)
at com.codoon.find.product.item.detail.i$a.onClick + 57(ProductReceiveCouponItem.kt:57)
at android.view.View.performClick + 6266(View.java:6266)
at android.view.View$PerformClick.run + 24730(View.java:24730)
at android.os.Handler.handleCallback + 789(Handler.java:789)
at android.os.Handler.dispatchMessage + 98(Handler.java:98)
at android.os.Looper.loop + 171(Looper.java:171)
at android.app.ActivityThread.main + 6699(ActivityThread.java:6699)
at java.lang.reflect.Method.invoke(Method.java)
at com.android.internal.os.Zygote$MethodAndArgsCaller.run + 246(Zygote.java:246)
at com.android.internal.os.ZygoteInit.main + 783(ZygoteInit.java:783)
單看崩潰日誌應該非常好改吧,出現了一個強轉錯誤,原來是在我編寫的 ProductReceiveCouponItem
類的 57 行呼叫專案中的通用對話方塊 CommonDialog
直接崩潰了。翻看 CommonDialog
的相關程式碼發現,原來是之前的同學在使用傳入的 Context
的時候沒有做型別驗證,直接強轉為了 Activity
。
// 得到等待對話方塊
public void openProgressDialog(String message, OnDismissListener listener, OnCancelListener mOnCancelistener) {
if (waitingDialog != null) {
waitingDialog.dismiss();
waitingDialog = null;
}
if (mContext == null) {
return;
}
if (((Activity) mContext).isFinishing()) {
return;
}
waitingDialog = createLoadingDialog(mContext, message);
waitingDialog.setCanceledOnTouchOutside(false);
waitingDialog.setOnCancelListener(mOnCancelistener);
waitingDialog.setCancelable(mCancel);
waitingDialog.setOnDismissListener(listener);
waitingDialog.show();
}
而我的程式碼通過 View.getContext()
傳入的 Context
型別是 ContextThemeWrapper
。
// 領取優惠券
val dialog = CommonDialog(binding.root.context)
dialog.openProgressDialog("領取中...") // 第 57 行出問題的程式碼
ProductService.INSTANCE.receiveGoodsCoupon(data.class_id)
.compose(RetrofitUtil.schedulersAndGetData())
.subscribeNet(true) {
// 邏輯處理相關程式碼
}
看到了日誌改起來就非常簡單了,第一種方案是直接在 CommonDialog
強轉前做一下型別判斷。第二種方案是直接在我這裡的程式碼中通過判斷 binding.root.context
的型別,然後取出裡面的 Activity
。
雖然 bug 非常好解決,但作為一名 Android 程式設計師,絕對不可以滿足於僅僅解決 bug 上,任何事情都事出有因,這裡為什麼數月沒有更改的程式碼,在 9.4.0 上沒有問題,在 9.5.0 上就成了必現崩潰呢?
切換程式碼分支到 9.4.0,debug 發現,這裡的 binding.root.context
返回的確實就是 Activity
,而在 9.5.0 上 binding.root.context
確實就返回的是 ContextThemeWrapper
,檢查後確定程式碼沒有任何改動。
分析出現 ContextThemeWrapper 的原因
看到 ContextThemeWrapper
,不由得想起了這個類使用的地方之一:Dialog
,熟悉 Dialog
的童鞋一定都知道,我們在構造 Dialog
的時候,會把 Context
直接變成 ContextThemeWrapper
。
public Dialog(@NonNull Context context) {
this(context, 0, true);
}
public Dialog(@NonNull Context context, @StyleRes int themeResId) {
this(context, themeResId, true);
}
Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
if (createContextThemeWrapper) {
if (themeResId == ResourceId.ID_NULL) {
final TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
themeResId = outValue.resourceId;
}
mContext = new ContextThemeWrapper(context, themeResId);
} else {
mContext = context;
}
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
final Window w = new PhoneWindow(mContext);
mWindow = w;
w.setCallback(this);
w.setOnWindowDismissedCallback(this);
w.setOnWindowSwipeDismissedCallback(() -> {
if (mCancelable) {
cancel();
}
});
w.setWindowManager(mWindowManager, null, null);
w.setGravity(Gravity.CENTER);
mListenersHandler = new ListenersHandler(this);
}
oh,在第三個構造方法中,通過構造的時候傳入的 createContextThemeWrapper
總是 true
,所以它一定可以進到這個 if
語句裡面去,把 mContext
強行指向了 Context
的包裝類 ContextThemeWrapper
。所以這裡會不會是由於這個原因呢?
我們再看看我們的程式碼,我這個 ProductReceiveCouponItem
實際上是一個 RecyclerView
的 Item,而這個相應的 RecyclerView
是顯示在 DialogFragment
上的。熟悉 DialogFragment
的小夥伴可能知道,DialogFragment
實際上也是一個 Fragment
。而 DialogFragment
裡面,其實是有一個 Dialog
的變數 mDialog
的,這個 Dialog
會在 onStart()
後通過 show()
展示出來。
在我們使用 DialogFragment
的時候,一定都會重寫 onCreatView()
對吧,有一個 LayoutInflater
引數,返回值是一個 View
,我們不禁想知道這個 LayoutInflater
是從哪兒來的? onGetLayoutInflater()
,我們看看。
@Override
public LayoutInflater onGetLayoutInflater(Bundle savedInstanceState) {
if (!mShowsDialog) {
return super.onGetLayoutInflater(savedInstanceState);
}
mDialog = onCreateDialog(savedInstanceState);
if (mDialog != null) {
setupDialog(mDialog, mStyle);
return (LayoutInflater) mDialog.getContext().getSystemService(
Context.LAYOUT_INFLATER_SERVICE);
}
return (LayoutInflater) mHost.getContext().getSystemService(
Context.LAYOUT_INFLATER_SERVICE);
}
我們是以一個 Dialog
的形式展示,所以不會進入其中的 if
條件。所以我們直接通過了 onCreateDialog()
構造了一個 Dialog
。如果這個 Dialog
不為空的話,那麼我們的 LayoutInflater
就會直接通過 Dialog
的 Context
構造出來。我們來看看 onCreateDialog()
方法。
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new Dialog(getActivity(), getTheme());
}
很簡單,直接 new
了一個 Dialog
,Dialog
這樣的構造方法上面也說了,直接會把 mContext
指向一個 Context
的包裝類 ContextThemeWrapper
。
至此我們能做大概猜想了,DialogFragment
負責 inflate
出佈局的 LayoutInflater
是由 ContextThemeWrapper
構造出來的,所以我們暫且在這裡說一個結論:DialogFragment onCreatView() 裡面這個 layout 檔案裡面的 View.getContext() 返回應該是 `ContextThemeWrapper。
但是!!!我們出問題的是 Item,Item 是通過 RecyclerView
的 Adapter
的 ViewHolder
顯示出來的,而非 DialogFragent
裡面 Dialog
的 setContentView()
的 XML 解析方法。看起來,分析了那麼多,並沒有找到問題的癥結所在。所以得看看我們的 Adapter
是怎麼寫的,直接開啟我們的 MultiTypeAdapter
的 onCreateViewHolder()
方法。
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (typeMap.get(viewType, TYPE_DEFAULT) == TYPE_ONE) {
return holders.get(viewType).createHolder(parent);
}
ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), viewType, parent, false);
return new ItemViewHolder(binding);
}
oh,在這裡我們的 LayoutInflater.from()
接受的引數是 parent.getContext()
。parent
是什麼?就是我們的 RecyclerView
,這個 RecyclerView
是從哪兒來的?通過 DialogFragment
的 LayoutInflater
給 inflate
出來的。所以 parent.getContext()
返回是什麼?在這裡,一定是 ContextThemeWrapper
。
也就是說,我們的 ViewHolder
的 rootView
也就是通過 ContextThemeWrapper
構造的 LayoutInflater
給 inflate
出來的了。所以我們的 ProductReceiveCouponItem
這個 Item 裡面的 binding.root.context
返回值,自然也就是 ContextThemeWrapper
而不是 Activity
了。自然而然,在 CommonDialog
裡面直接強轉為 Activity
一定會出錯。
那為什麼在 9.4.0 上沒有出現這個問題呢?我們看看 9.4.0 上 MultiTypeAdapter
的 onCreateViewHolder()
方法:
@Override
public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
ViewDataBinding binding = DataBindingUtil.inflate(mInflater, viewType, parent, false);
return new ItemViewHolder(binding);
}
咦,看起來似乎不一樣,這裡直接傳入的是 mInflater
,我們看看這個 mInflater
是在哪兒被初始化的。
public MultiTypeAdapter(Context context) {
mInflater = LayoutInflater.from(context);
}
oh,在 9.4.0 的分支上,我們的 ViewHolder
的 LayoutInflater
的 Context
,是從外面傳進來的。再看看我們 DialogFragment
中對 RecyclerView
的處理。
val rvAdapter = MultiTypeAdapter(context)
binding.recyclerView.run {
layoutManager = LinearLayoutManager(context)
val itemDecoration = DividerItemDecoration(context, DividerItemDecoration.VERTICAL_LIST)
itemDecoration.setDividerDrawable(R.drawable.list_divider_10_white.toDrawable())
addItemDecoration(itemDecoration)
adapter = rvAdapter
}
是吧,在 9.4.0 的時候,MultiTypeAdapter
的 ViewHolder
會使用外界傳入的 Context
,這個 Context
是 Activity
,所以我們的Item 的 binding.root.context
返回為 Activity
。而在 9.5.0 的時候,同事重構了 MultiTypeAdapter
,而讓其 ViewHolder
的 LayoutInflater
直接取的 parent.getContext()
,這裡的情況即 ContextThemeWrapper
,所以出現了幾個月沒動的程式碼,在新版本上灰測卻崩潰了。
總結
寫了這麼多,還是做一些總結。首先對題目做個答案: View.getContext() 的返回不一定是 Activity。
實際上,View.getContext()
和 inflate
這個 View
的 LayoutInflater
息息相關,比如 Activity
的 setContentView()
裡面的 LayoutInflater
就是它本身,所以該 layoutRes
裡面的 View.getContext()
返回的就是 Activity
。但在使用 AppCompatActivity
的時候,值得關注的是, layoutRes
裡面的原生 View
會被自動轉換為 AppCompatXXX
,而這個轉換在 5.0 以下的手機系統中,會把 Context
轉換為其包裝類 TintThemeWrapper
,所以在這樣的情況下的 View.getContext()
返回是 TintThemeWrapper
。
最後,從一個奇怪的 bug 中,給大家分享了一個簡單的原因探索分析,也進一步驗證了上面的結論。任何 bug 的出現,總是有它的原因,作為 Android 開發,我們不僅要處理掉 bug,更要關注到它的更深層次的原因,這樣才能在程式碼層面就發現其它的潛在問題,以免帶來更多不必要的麻煩。本文就一個簡單的示例進行了此次試探的講解,但個人技術能力有限,唯恐出現紕漏,還望有心人士指出。
文章部分來源於:View.getContext() 裡的小祕密