堅持原創日更,短平快的 Android 進階系列,敬請直接在微信公衆號搜索:nanchen,直接關注並設爲星標,精彩不容錯過。java
通常咱們被問到這樣的問題,一般來講,答案都是否認的,但必定得知道其中的緣由,否則回答確定與否又有什麼意義呢。android
首先,顯而易見這個問題有很多陷阱,好比這個 View 是本身構造出來的,那確定它的 getContext()
返回的是構造它的時候傳入的 Context
類型。程序員
那,若是是 XML 裏面的 View 呢,會怎樣?可能很多人也知道了另一個結論:直接繼承 Activity 的 Activity 構造出來的 View.getContext() 返回的是當前 Activity。可是:當 View 的 Activity 是繼承自 AppCompatActivity,而且在 5.0 如下版本的手機上,View.getContext() 獲得的並不是是 Activity,而是 TintContextWrapper。bash
不太熟悉 Context
的繼承關係的小夥伴可能也會很奇怪,正常來講,本身所知悉的 Context
繼承關係圖是這樣的。 微信
咱們能夠先看看 Activity.setContentView()
方法:app
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}
複製代碼
不過是直接調用 Window
的實現類 PhoneWindow
的 setContentView()
方法。看看 PhoneWindow
的 setContentView()
是怎樣的。ide
@Override
public void setContentView(int layoutResID) {
// 省略部分代碼...
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
mLayoutInflater.inflate(layoutResID, mContentParent);
}
// 省略部分代碼...
}
複製代碼
假如沒有 FEATURE_CONTENT_TRANSITIONS
標記的話,就直接經過 mLayoutInflater.inflate()
加載出來。這個若是有 mLayoutInflater
的是在PhoneWindow
的構造方法中被初始化的。而 PhoneWindow
的初始化是在 Activity
的 attach()
方法中:oop
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()
方法:post
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);
}
}
// 省略部分代碼...
}
複製代碼
基本邏輯不必詳解了吧?仍是經過這個 mContext
的 LayoutInflater
去 inflate
的佈局。這個 mContext
初始化的地方是:
public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context) {
// 省略部分代碼...
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()
是怎麼實現的。
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 呢?
那麼重點確定就是在 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)) {
// 省略關鍵代碼...
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
,不禁得想起了這個類使用的地方之一: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;
}
// 省略部分代碼...
}
複製代碼
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() 裏的小祕密