堅持原創日更,短平快的 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,更要關注到它的更深層次的原因,這樣才能在代碼層面就發現其它的潛在問題,以免帶來更多不必要的麻煩。本文就一個簡單的示例進行了此次試探的講解,但個人技術能力有限,唯恐出現紕漏,還望有心人士指出。