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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342