坚持原创日更,短平快的 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()方法。看看PhoneWindow的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,更要关注到它的更深层次的原因,这样才能在代码层面就发现其它的潜在问题,以免带来更多不必要的麻烦。本文就一个简单的示例进行了此次试探的讲解,但个人技术能力有限,唯恐出现纰漏,还望有心人士指出。