View.getContext() 一定會返回 Activity 對象么?

堅持原創日更,短平快的 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()方法:

publicvoidsetContentView(@LayoutResintlayoutResID){getWindow().setContentView(layoutResID);initWindowDecorActionBar();}

不過是直接調用Window的實現類PhoneWindow的setContentView()方法??纯碢honeWindow的setContentView()是怎樣的。

@OverridepublicvoidsetContentView(intlayoutResID){// 省略部分代碼...if(hasFeature(FEATURE_CONTENT_TRANSITIONS)){finalScenenewScene=Scene.getSceneForLayout(mContentParent,layoutResID,getContext());transitionTo(newScene);}else{mLayoutInflater.inflate(layoutResID,mContentParent);}// 省略部分代碼...}

假如沒有FEATURE_CONTENT_TRANSITIONS標記的話,就直接通過mLayoutInflater.inflate()加載出來。這個如果有mLayoutInflater的是在PhoneWindow的構造方法中被初始化的。而PhoneWindow的初始化是在Activity的attach()方法中:

finalvoidattach(Contextcontext,ActivityThreadaThread,Instrumentationinstr,IBindertoken,intident,Applicationapplication,Intentintent,ActivityInfoinfo,CharSequencetitle,Activityparent,Stringid,NonConfigurationInstanceslastNonConfigurationInstances,Configurationconfig,Stringreferrer,IVoiceInteractorvoiceInteractor,Windowwindow,ActivityConfigCallbackactivityConfigCallback){attachBaseContext(context);mFragments.attachHost(null/*parent*/);mWindow=newPhoneWindow(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()方法:

privatevoidtransitionTo(Scenescene){if(mContentScene==null){scene.enter();}else{mTransitionManager.transitionTo(scene);}mContentScene=scene;}

在看看scene.enter()方法。

publicvoidenter(){// Apply layout change, if anyif(mLayoutId>0||mLayout!=null){// empty out parent container before adding to itgetSceneRoot().removeAllViews();if(mLayoutId>0){LayoutInflater.from(mContext).inflate(mLayoutId,mSceneRoot);}else{mSceneRoot.addView(mLayout);}}// 省略部分代碼...}

基本邏輯沒必要詳解了吧?還是通過這個mContext的LayoutInflater去inflate的布局。這個mContext初始化的地方是:

publicstaticScenegetSceneForLayout(ViewGroupsceneRoot,intlayoutId,Contextcontext){// 省略部分代碼...if(scene!=null){returnscene;}else{scene=newScene(sceneRoot,layoutId,context);// 初始化關鍵代碼scenes.put(layoutId,scene);returnscene;}}

即Context來源于外面傳入的getContext(),這個getContext()返回的就是初始化的Context也就是Activity本身。

AppCompatActivity.setContentView()

我們不得不看看AppCompatActivity的setContentView()是怎么實現的。

publicvoidsetContentView(@LayoutResintlayoutResID){this.getDelegate().setContentView(layoutResID);}@NonNullpublicAppCompatDelegategetDelegate(){if(this.mDelegate==null){this.mDelegate=AppCompatDelegate.create(this,this);}returnthis.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

*/publicstaticAppCompatDelegatecreate(Activityactivity,AppCompatCallbackcallback){returncreate(activity,activity.getWindow(),callback);}privatestaticAppCompatDelegatecreate(Contextcontext,Windowwindow,AppCompatCallbackcallback){finalintsdk=Build.VERSION.SDK_INT;if(BuildCompat.isAtLeastN()){returnnewAppCompatDelegateImplN(context,window,callback);}elseif(sdk>=23){returnnewAppCompatDelegateImplV23(context,window,callback);}elseif(sdk>=14){returnnewAppCompatDelegateImplV14(context,window,callback);}elseif(sdk>=11){returnnewAppCompatDelegateImplV11(context,window,callback);}else{returnnewAppCompatDelegateImplV9(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。

publicAppCompatTextView(Contextcontext,AttributeSetattrs,intdefStyleAttr){super(TintContextWrapper.wrap(context),attrs,defStyleAttr);this.mBackgroundTintHelper=newAppCompatBackgroundHelper(this);this.mBackgroundTintHelper.loadFromAttributes(attrs,defStyleAttr);this.mTextHelper=newAppCompatTextHelper(this);this.mTextHelper.loadFromAttributes(attrs,defStyleAttr);this.mTextHelper.applyCompoundDrawablesTints();}

可以看到,關鍵是super(TintContextWrapper.wrap(context), attrs, defStyleAttr);這行代碼。我們點進去看看這個wrap()做了什么。

publicstaticContextwrap(@NonNullContextcontext){if(shouldWrap(context)){// 省略關鍵代碼...TintContextWrapperwrapper=newTintContextWrapper(context);sCache.add(newWeakReference(wrapper));returnwrapper;}else{returncontext;}}

可以看到當,shouldWrap()這個方法返回為 true 的時候,就會采用了TintContextWrapper這個對象來包裹了我們的Context。來看看什么情況才能滿足這個條件。

privatestaticbooleanshouldWrap(@NonNullContextcontext){if(!(contextinstanceofTintContextWrapper)&&!(context.getResources()instanceofTintResources)&&!(context.getResources()instanceofVectorEnabledTintResources)){returnVERSION.SDK_INT<21||VectorEnabledTintResources.shouldBeUsed();}else{returnfalse;}}

很明顯了吧?如果是 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 灰測的時候,這里卻出現了必現崩潰。

FatalException:java.lang.ClassCastException:android.view.ContextThemeWrappercannot be casttoandroid.app.Activityat 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。

// 得到等待對話框publicvoidopenProgressDialog(Stringmessage,OnDismissListenerlistener,OnCancelListenermOnCancelistener){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。

// 領取優惠券valdialog=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。

publicDialog(@NonNullContextcontext){this(context,0,true);}publicDialog(@NonNullContextcontext,@StyleResintthemeResId){this(context,themeResId,true);}Dialog(@NonNullContextcontext,@StyleResintthemeResId,booleancreateContextThemeWrapper){if(createContextThemeWrapper){if(themeResId==ResourceId.ID_NULL){finalTypedValueoutValue=newTypedValue();context.getTheme().resolveAttribute(R.attr.dialogTheme,outValue,true);themeResId=outValue.resourceId;}mContext=newContextThemeWrapper(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(),我們看看。

@OverridepublicLayoutInflateronGetLayoutInflater(BundlesavedInstanceState){if(!mShowsDialog){returnsuper.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()方法。

publicDialogonCreateDialog(BundlesavedInstanceState){returnnewDialog(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@OverridepublicRecyclerView.ViewHolderonCreateViewHolder(@NonNullViewGroupparent,intviewType){if(typeMap.get(viewType,TYPE_DEFAULT)==TYPE_ONE){returnholders.get(viewType).createHolder(parent);}ViewDataBindingbinding=DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()),viewType,parent,false);returnnewItemViewHolder(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()方法:

@OverridepublicItemViewHolderonCreateViewHolder(ViewGroupparent,intviewType){ViewDataBindingbinding=DataBindingUtil.inflate(mInflater,viewType,parent,false);returnnewItemViewHolder(binding);}

咦,看起來似乎不一樣,這里直接傳入的是mInflater,我們看看這個mInflater是在哪兒被初始化的。

publicMultiTypeAdapter(Contextcontext){mInflater=LayoutInflater.from(context);}

oh,在 9.4.0 的分支上,我們的ViewHolder的LayoutInflater的Context,是從外面傳進來的。再看看我們DialogFragment中對RecyclerView的處理。

valrvAdapter=MultiTypeAdapter(context)binding.recyclerView.run{layoutManager=LinearLayoutManager(context)valitemDecoration=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。

?解決辦法:1、問題 View.getContext() 如何強制轉為 Activity ?

下面給個常用思路作為參考:

public static Context getActivity( Context context) {

int a =0;

if (null != context) {

while (contextinstanceof ContextWrapper) {

if (contextinstanceof Activity) {

return? context;

}else if (a >10){

return? context;

}

a ++;

context = ((ContextWrapper) context).getBaseContext();

}

}

return context;

}


最后,從一個奇怪的 bug 中,給大家分享了一個簡單的原因探索分析,也進一步驗證了上面的結論。任何 bug 的出現,總是有它的原因,作為 Android 開發,我們不僅要處理掉 bug,更要關注到它的更深層次的原因,這樣才能在代碼層面就發現其它的潛在問題,以免帶來更多不必要的麻煩。本文就一個簡單的示例進行了此次試探的講解,但個人技術能力有限,唯恐出現紕漏,還望有心人士指出。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,565評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,115評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,577評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,514評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,234評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,621評論 1 326
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,641評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,822評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,380評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,128評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,319評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,879評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,548評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,970評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,229評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,048評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,285評論 2 376