每日一问:View.getContext() 的返回必定是 Activity 么?

坚持原创日更,短平快的 Android 进阶系列,敬请直接在微信公众号搜索:nanchen,直接关注并设为星标,精彩不容错过。java

通常咱们被问到这样的问题,一般来讲,答案都是否认的,但必定得知道其中的缘由,否则回答确定与否又有什么意义呢。android

首先,显而易见这个问题有很多陷阱,好比这个 View 是本身构造出来的,那确定它的 getContext() 返回的是构造它的时候传入的 Context 类型。程序员

它也可能返回的是 TintContextWrapper

那,若是是 XML 里面的 View 呢,会怎样?可能很多人也知道了另一个结论:直接继承 Activity 的 Activity 构造出来的 View.getContext() 返回的是当前 Activity。可是:当 View 的 Activity 是继承自 AppCompatActivity,而且在 5.0 如下版本的手机上,View.getContext() 获得的并不是是 Activity,而是 TintContextWrapper。微信

不太熟悉 Context 的继承关系的小伙伴可能也会很奇怪,正常来讲,本身所知悉的 Context 继承关系图是这样的。
app

Activity.setContentView()

咱们能够先看看 Activity.setContentView() 方法:ide

public void setContentView(@LayoutRes int layoutResID) {
    getWindow().setContentView(layoutResID);
    initWindowDecorActionBar();
}

不过是直接调用 Window 的实现类 PhoneWindowsetContentView() 方法。看看 PhoneWindowsetContentView() 是怎样的。oop

@Override
public void setContentView(int layoutResID) {
    // Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
    // decor, when theme attributes and the like are crystalized. Do not check the feature
    // before this happens.
    if (mContentParent == null) {
        installDecor();
    } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        mContentParent.removeAllViews();
    }

    if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                getContext());
        transitionTo(newScene);
    } else {
        mLayoutInflater.inflate(layoutResID, mContentParent);
    }
    mContentParent.requestApplyInsets();
    final Callback cb = getCallback();
    if (cb != null && !isDestroyed()) {
        cb.onContentChanged();
    }
    mContentParentExplicitlySet = true;
}

假如没有 FEATURE_CONTENT_TRANSITIONS 标记的话,就直接经过 mLayoutInflater.inflate() 加载出来。这个若是有 mLayoutInflater 的是在PhoneWindow 的构造方法中被初始化的。而 PhoneWindow 的初始化是在 Activityattach() 方法中:布局

final void attach(Context context, ActivityThread aThread,
        Instrumentation instr, IBinder token, int ident,
        Application application, Intent intent, ActivityInfo info,
        CharSequence title, Activity parent, String id,
        NonConfigurationInstances lastNonConfigurationInstances,
        Configuration config, String referrer, IVoiceInteractor voiceInteractor,
        Window window, ActivityConfigCallback activityConfigCallback) {
    attachBaseContext(context);
    mFragments.attachHost(null /*parent*/);
    mWindow = new PhoneWindow(this, window, activityConfigCallback);
    mWindow.setWindowControllerCallback(this);
    mWindow.setCallback(this);
    mWindow.setOnWindowDismissedCallback(this);
    mWindow.getLayoutInflater().setPrivateFactory(this);

    // 此处省略部分代码...
}

因此 PhoneWindowContext 实际上就是 Activity 自己。post

在回到咱们前面分析的 PhoneWindowsetContentView() 方法,若是有 FEATURE_CONTENT_TRANSITIONS 标记,直接调用了一个 transitionTo() 方法:ui

private void transitionTo(Scene scene) {
    if (mContentScene == null) {
        scene.enter();
    } else {
        mTransitionManager.transitionTo(scene);
    }
    mContentScene = scene;
}

在看看 scene.enter() 方法。

public void enter() {
    // Apply layout change, if any
    if (mLayoutId > 0 || mLayout != null) {
        // empty out parent container before adding to it
        getSceneRoot().removeAllViews();
        if (mLayoutId > 0) {
            LayoutInflater.from(mContext).inflate(mLayoutId, mSceneRoot);
        } else {
            mSceneRoot.addView(mLayout);
        }
    }
    // Notify next scene that it is entering. Subclasses may override to configure scene.
    if (mEnterAction != null) {
        mEnterAction.run();
    }
    setCurrentScene(mSceneRoot, this);
}

基本逻辑不必详解了吧?仍是经过这个 mContextLayoutInflaterinflate 的布局。这个 mContext 初始化的地方是:

public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context) {
    SparseArray<Scene> scenes = (SparseArray<Scene>) sceneRoot.getTag(
            com.android.internal.R.id.scene_layoutid_cache);
    if (scenes == null) {
        scenes = new SparseArray<Scene>();
        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()

咱们不得不看看 AppCompatActivitysetContentView() 是怎么实现的。

public void setContentView(@LayoutRes int layoutResID) {
    this.getDelegate().setContentView(layoutResID);
}

@NonNull
public AppCompatDelegate getDelegate() {
    if (this.mDelegate == null) {
        this.mDelegate = AppCompatDelegate.create(this, this);
    }

    return this.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
 */
public static AppCompatDelegate create(Activity activity, AppCompatCallback callback) {
    return create(activity, activity.getWindow(), callback);
}

private static AppCompatDelegate create(Context context, Window window,
        AppCompatCallback callback) {
    final int sdk = Build.VERSION.SDK_INT;
    if (BuildCompat.isAtLeastN()) {
        return new AppCompatDelegateImplN(context, window, callback);
    } else if (sdk >= 23) {
        return new AppCompatDelegateImplV23(context, window, callback);
    } else if (sdk >= 14) {
        return new AppCompatDelegateImplV14(context, window, callback);
    } else if (sdk >= 11) {
        return new AppCompatDelegateImplV11(context, window, callback);
    } else {
        return new AppCompatDelegateImplV9(context, window, callback);
    }
}

关于实现类 AppCompatDelegateImplsetContentView() 方法这里就不作过多分析了,感兴趣的能够直接移步掘金上的 View.getContext() 里的小秘密 进行查阅。

不过这里仍是要结合小缘的回答,简单总结一下:之因此能获得上面的结论是由于咱们在 AppCompatActivity 里面的 layout.xml 文件里面使用原生控件,好比 TextViewImageView 等等,当在 LayoutInflater 中把 XML 解析成 View 的时候,最终会通过 AppCompatViewInflatercreateView() 方法,这个方法会把这些原生的控件都变成 AppCompatXXX 一类。包含了哪些 View 呢?

  • RatingBar
  • CheckedTextView
  • MultiAutoCompleteTextView
  • TextView
  • ImageButton
  • SeekBar
  • Spinner
  • RadioButton
  • ImageView
  • AutoCompleteTextView
  • CheckBox
  • EditText
  • Button

那么重点确定就是在 AppCompat 这些开头的控件了,随便打开一个源码吧,好比 AppCompatTextView

public AppCompatTextView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(TintContextWrapper.wrap(context), attrs, defStyleAttr);
    this.mBackgroundTintHelper = new AppCompatBackgroundHelper(this);
    this.mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr);
    this.mTextHelper = new AppCompatTextHelper(this);
    this.mTextHelper.loadFromAttributes(attrs, defStyleAttr);
    this.mTextHelper.applyCompoundDrawablesTints();
}

能够看到,关键是 super(TintContextWrapper.wrap(context), attrs, defStyleAttr); 这行代码。咱们点进去看看这个 wrap() 作了什么。

public static Context wrap(@NonNull Context context) {
    if (shouldWrap(context)) {
        Object var1 = CACHE_LOCK;
        synchronized(CACHE_LOCK) {
            if (sCache == null) {
                sCache = new ArrayList();
            } else {
                int i;
                WeakReference ref;
                for(i = sCache.size() - 1; i >= 0; --i) {
                    ref = (WeakReference)sCache.get(i);
                    if (ref == null || ref.get() == null) {
                        sCache.remove(i);
                    }
                }

                for(i = sCache.size() - 1; i >= 0; --i) {
                    ref = (WeakReference)sCache.get(i);
                    TintContextWrapper wrapper = ref != null ? (TintContextWrapper)ref.get() : null;
                    if (wrapper != null && wrapper.getBaseContext() == context) {
                        return wrapper;
                    }
                }
            }

            TintContextWrapper wrapper = new TintContextWrapper(context);
            sCache.add(new WeakReference(wrapper));
            return wrapper;
        }
    } else {
        return context;
    }
}

能够看到当,shouldWrap() 这个方法返回为 true 的时候,就会采用了 TintContextWrapper 这个对象来包裹了咱们的 Context。来看看什么状况才能知足这个条件。

private static boolean shouldWrap(@NonNull Context context) {
    if (!(context instanceof TintContextWrapper) && !(context.getResources() instanceof TintResources) && !(context.getResources() instanceof VectorEnabledTintResources)) {
        return VERSION.SDK_INT < 21 || VectorEnabledTintResources.shouldBeUsed();
    } else {
        return false;
    }
}

很明显了吧?若是是 5.0 之前,而且没有包装的话,就会直接返回 true;因此也就得出了上面的结论:当运行在 5.0 系统版本如下的手机,而且 Activity 是继承自 AppCompatActivity 的,那么ViewgetConext() 方法,返回的就不是 Activity 而是 TintContextWrapper

还有其它状况么?

上面讲述了两种非 Activity 的状况:

  1. 直接构造 View 的时候传入的不是 Activity
  2. 使用 AppCompatActivity 而且运行在 5.0 如下的手机上,XML 里面的 ViewgetContext() 方法返回的是 TintContextWrapper

那不由让人想一想,还有其余状况么?有。

咱们直接从我前两天线上灰测包出现的一个 bug 提及。先说说 bug 背景,灰测包是 9.5.0,而线上包是 9.4.0,在灰测包上发生崩溃的代码是三个月前编写的代码,也就是说这多是 8.43.0 或者 9.0.0 加入的代码,在线上稳定运行了 4 个版本以上没有作过任何修改。但在 9.5.0 灰测的时候,这里却出现了必现崩溃。

Fatal Exception: java.lang.ClassCastException: android.view.ContextThemeWrapper cannot be cast to android.app.Activity
       at 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

// 获得等待对话框
public void openProgressDialog(String message, OnDismissListener listener, OnCancelListener mOnCancelistener) {
    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

// 领取优惠券
val dialog = 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

public Dialog(@NonNull Context context) {
    this(context, 0, true);
}

public Dialog(@NonNull Context context, @StyleRes int themeResId) {
    this(context, themeResId, true);
}

Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
    if (createContextThemeWrapper) {
        if (themeResId == ResourceId.ID_NULL) {
            final TypedValue outValue = new TypedValue();
            context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
            themeResId = outValue.resourceId;
        }
        mContext = new ContextThemeWrapper(context, themeResId);
    } else {
        mContext = context;
    }

    mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

    final Window w = new PhoneWindow(mContext);
    mWindow = w;
    w.setCallback(this);
    w.setOnWindowDismissedCallback(this);
    w.setOnWindowSwipeDismissedCallback(() -> {
        if (mCancelable) {
            cancel();
        }
    });
    w.setWindowManager(mWindowManager, null, null);
    w.setGravity(Gravity.CENTER);

    mListenersHandler = new ListenersHandler(this);
}

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(),咱们看看。

@Override
public LayoutInflater onGetLayoutInflater(Bundle savedInstanceState) {
    if (!mShowsDialog) {
        return super.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 就会直接经过 DialogContext 构造出来。咱们来看看 onCreateDialog() 方法。

public Dialog onCreateDialog(Bundle savedInstanceState) {
    return new Dialog(getActivity(), getTheme());
}

很简单,直接 new 了一个 DialogDialog 这样的构造方法上面也说了,直接会把 mContext 指向一个 Context 的包装类 ContextThemeWrapper

至此咱们能作大概猜测了,DialogFragment 负责 inflate 出布局的 LayoutInflater 是由 ContextThemeWrapper 构造出来的,因此咱们暂且在这里说一个结论:DialogFragment onCreatView() 里面这个 layout 文件里面的 View.getContext() 返回应该是 `ContextThemeWrapper。

可是!!!咱们出问题的是 Item,Item 是经过 RecyclerViewAdapterViewHolder 显示出来的,而非 DialogFragent 里面 DialogsetContentView() 的 XML 解析方法。看起来,分析了那么多,并无找到问题的症结所在。因此得看看咱们的 Adapter 是怎么写的,直接打开咱们的 MultiTypeAdapteronCreateViewHolder() 方法。

@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    if (typeMap.get(viewType, TYPE_DEFAULT) == TYPE_ONE) {
        return holders.get(viewType).createHolder(parent);
    }
    ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), viewType, parent, false);
    return new ItemViewHolder(binding);
}

oh,在这里咱们的 LayoutInflater.from() 接受的参数是 parent.getContext()parent 是什么?就是咱们的 RecyclerView,这个 RecyclerView 是从哪儿来的?经过 DialogFragmentLayoutInflaterinflate 出来的。因此 parent.getContext() 返回是什么?在这里,必定是 ContextThemeWrapper

也就是说,咱们的 ViewHolderrootView 也就是经过 ContextThemeWrapper 构造的 LayoutInflaterinflate 出来的了。因此咱们的 ProductReceiveCouponItem 这个 Item 里面的 binding.root.context 返回值,天然也就是 ContextThemeWrapper 而不是 Activity 了。天然而然,在 CommonDialog 里面直接强转为 Activity 必定会出错。

那为何在 9.4.0 上没有出现这个问题呢?咱们看看 9.4.0 上 MultiTypeAdapteronCreateViewHolder() 方法:

@Override
public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    ViewDataBinding binding = DataBindingUtil.inflate(mInflater, viewType, parent, false);
    return new ItemViewHolder(binding);
}

咦,看起来彷佛不同,这里直接传入的是 mInflater,咱们看看这个 mInflater 是在哪儿被初始化的。

public MultiTypeAdapter(Context context) {
    mInflater = LayoutInflater.from(context);
}

oh,在 9.4.0 的分支上,咱们的 ViewHolderLayoutInflaterContext,是从外面传进来的。再看看咱们 DialogFragment 中对 RecyclerView 的处理。

val rvAdapter = MultiTypeAdapter(context)
binding.recyclerView.run {
    layoutManager = LinearLayoutManager(context)
    val itemDecoration = DividerItemDecoration(context, DividerItemDecoration.VERTICAL_LIST)
    itemDecoration.setDividerDrawable(R.drawable.list_divider_10_white.toDrawable())
    addItemDecoration(itemDecoration)
    adapter = rvAdapter
}

是吧,在 9.4.0 的时候,MultiTypeAdapterViewHolder 会使用外界传入的 Context,这个 ContextActivity,因此咱们的Item 的 binding.root.context 返回为 Activity。而在 9.5.0 的时候,同事重构了 MultiTypeAdapter,而让其 ViewHolderLayoutInflater 直接取的 parent.getContext(),这里的状况即 ContextThemeWrapper,因此出现了几个月没动的代码,在新版本上灰测却崩溃了。

总结

写了这么多,仍是作一些总结。首先对题目作个答案: View.getContext() 的返回不必定是 Activity。

实际上,View.getContext()inflate 这个 ViewLayoutInflater 息息相关,好比 ActivitysetContentView() 里面的 LayoutInflater 就是它自己,因此该 layoutRes 里面的 View.getContext() 返回的就是 Activity。但在使用 AppCompatActivity 的时候,值得关注的是, layoutRes 里面的原生 View 会被自动转换为 AppCompatXXX,而这个转换在 5.0 如下的手机系统中,会把 Context 转换为其包装类 TintThemeWrapper,因此在这样的状况下的 View.getContext() 返回是 TintThemeWrapper

最后,从一个奇怪的 bug 中,给你们分享了一个简单的缘由探索分析,也进一步验证了上面的结论。任何 bug 的出现,老是有它的缘由,做为 Android 开发,咱们不只要处理掉 bug,更要关注到它的更深层次的缘由,这样才能在代码层面就发现其它的潜在问题,以避免带来更多没必要要的麻烦。本文就一个简单的示例进行了这次试探的讲解,但我的技术能力有限,惟恐出现纰漏,还望有心人士指出。

文章部分来源于:View.getContext() 里的小秘密

相关文章
相关标签/搜索