1 背景
很多時候,我們在代碼中可能有如下寫法:
(Activity)(view.getContext()),直接強轉為Activity後調用其中方法。
又或者擔心強轉錯誤,幹脆:
Context context = view.getContext():if(context instanceof Activity){// do something}
然後else就不管了,反正線上不會崩潰,測試還正常,其實都埋下了風險的種子。
一般我們被問到這樣的問題,通常來說,答案都是否定的,但我們一定得知道其中的原因,不然回答肯定與否又有什麼意義呢。
首先,顯而易見這個問題有不少陷阱,比如這個 View 是我們自己構造出來的,那肯定它的 getContext() 傳回的是我們構造它的時候傳入的 Context 類型。
2 它也可能傳回的是 TintContextWrapper
那,如果是 XML 裡面的 View 呢,會怎樣?
可能不少人也知道了另外一個結論:
直接繼承 Activity 的 Activity 構造出來的 View.getContext() 傳回的是目前 Activity。
但是:當 View 的 Activity 是繼承自 AppCompatActivity,并且在 5.0 以下版本的手機上,View.getContext() 得到的并非是 Activity,而是 TintContextWrapper。
不太熟悉
Context
的繼承關系的小夥伴可能也會很奇怪,正常來說,自己所知悉的
Context
繼承關系圖是這樣的。
Activity.setContentView()
我們可以先看看
Activity.setContentView()
方法:
不過是直接調用
Window
的實作類
PhoneWindow
的
setContentView()
方法。看看
PhoneWindow
的
setContentView()
是怎樣的。
假如我們沒有
FEATURE_CONTENT_TRANSITIONS
标記的話,我們直接通過
mLayoutInflater.inflate()
加載出來。這個如果有
mLayoutInflater
的是在
PhoneWindow
的構造方法中被初始化的。而
PhoneWindow
的初始化是在
Activity
的
attach()
方法中:
是以
PhoneWindow
的
Context
實際上就是
Activity
本身。
在回到我們前面分析的
PhoneWindow
的
setContentView()
方法,如果有
FEATURE_CONTENT_TRANSITIONS
标記,我們直接調用了一個
transitionTo()
方法:
我們在看看
scene.enter()
方法。
基本邏輯沒必要詳解了吧?
還是通過這個 mContext 的 LayoutInflater 去 inflate 的布局。這個 mContext 初始化的地方是:
public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context) {
SparseArray scenes = (SparseArray) sceneRoot.getTag(
com.android.internal.R.id.scene_layoutid_cache);if (scenes == null) {
scenes = new SparseArray();
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()
是怎麼實作的。
這個
mDelegate
實際上是一個代理類,由
AppCompatDelegate
根據不同的 SDK 版本生成不同的實際執行類,就是代理類的相容模式:
關于實作類
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。
可以看到,關鍵是 super(TintContextWrapper.wrap(context), attrs, defStyleAttr);這行代碼。我們點進去看看這個 wrap() 做了什麼。
可以看到當,shouldWrap() 這個方法傳回為 true 的時候,就會采用 TintContextWrapper 這個對象來包裹了我們的 Context。我們看看什麼情況才能滿足這個條件。
很明顯了吧?如果是 5.0 以前,并且沒有包裝的話,就會直接傳回 true;是以也就得出了上面的結論:
當運作在 5.0 系統版本以下的手機,并且 Activity 是繼承自 AppCompatActivity 的,那麼View 的 getConext() 方法,傳回的就不是 Activity 而是 TintContextWrapper。
3 還有其它情況麼?
上面講述了兩種非 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 灰測的時候,這裡卻出現了必現崩潰。
單看崩潰日志應該非常好改吧,出現了一個強轉錯誤,原來是在我編寫的 ProductReceiveCouponItem 類的 57 行調用項目中的通用對話框 CommonDialog 直接崩潰了。
翻看 CommonDialog 的相關代碼發現,原來是之前的同學在使用傳入的 Context的時候沒有做類型驗證,直接強轉為了 Activity。
而我的代碼通過 View.getContext() 傳入的 Context 類型是 ContextThemeWrapper。
看到了日志改起來就非常簡單了,第一種方案是直接在 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。
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(),我們看看。
我們是以一個 Dialog 的形式展示,是以不會進入其中的 if 條件。是以我們直接通過了 onCreateDialog() 構造了一個 Dialog。
如果這個 Dialog 不為空的話,那麼我們的 LayoutInflater 就會直接通過 Dialog 的 Context 構造出來。我們來看看 onCreateDialog() 方法。
很簡單,直接 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() 方法。
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() 方法:
咦,看起來似乎不一樣,這裡直接傳入的是 mInflater,我們看看這個 mInflater 是在哪兒被初始化的。
oh,在 9.4.0 的分支上,我們的 ViewHolder 的 LayoutInflater 的 Context,是從外面傳進來的。我們看看我們 DialogFragment 中對 RecyclerView 的處理。
是吧,在 9.4.0 的時候,MultiTypeAdapter 的 ViewHolder 會使用我們外接傳入的 Context,這個 Context 是 Activity,是以我們的Item 的 binding.root.context 傳回為 Activity。
而在 9.5.0 的時候,同僚重構了 MultiTypeAdapter,而讓其 ViewHolder 的 LayoutInflater 直接取的 parent.getContext(),這裡的情況即 ContextThemeWrapper,是以出現了幾個月沒動的代碼,在新版本上灰測卻崩潰了。
4 總結
寫了這麼多,還是做一些總結。首先對題目做個答案: 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() 裡的小秘密,連結:https://juejin.im/post/5a54ad16f265da3e347b211