起因
Snackbar相信大家都不陌生,Material Design样式的消息通知,简洁的使用方式,相信很多人都已经替换掉Toast,改投Snackbar了。但就是这简简单单的一句代码:
Snackbar.make(view, "This is the snackbar.", Snackbar.LENGTH_SHORT).show();
最近却让我焦头烂额。到底是怎么回事呢?我这里写了个Demo来重现一些当时的场景:
WindowManager windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
ViewGroup root = new RelativeLayout(MainActivity.this);
root.setBackgroundColor(getResources().getColor(R.color.colorPrimary));
WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.type = WindowManager.LayoutParams.TYPE_PHONE;
params.format = PixelFormat.RGBA_8888;
params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
params.gravity = Gravity.START | Gravity.TOP;
params.width = 400;
params.height = 300;
params.x = 70;
params.y = 300;
windowManager.addView(root, params);
Snackbar.make(root, "This is the snackbar.", Snackbar.LENGTH_SHORT).show();
代码很简单,就是在一个悬浮框里显示一个Sanckbar,本想着平时经常使用的Snackbar在这也不会出什么问题,可现实却给了我重重的一击:
这是怎么回事?平时在Activity用的好好的Snackbar怎么一到WindowManager就不行了呢?
问题的源头
既然报错了,那我们先到NullPointerException的源头看它一看:
private Snackbar(ViewGroup parent) {
mTargetParent = parent;
mContext = parent.getContext(); //就是这里报的NullPointerException
ThemeUtils.checkAppCompatTheme(mContext);
LayoutInflater inflater = LayoutInflater.from(mContext);
mView = (SnackbarLayout) inflater.inflate(
R.layout.design_layout_snackbar, mTargetParent, false);
mAccessibilityManager = (AccessibilityManager)
mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
}
看到这里我更迷惑了,这里的parent按道理应该是我们传入的view,怎么可能为空呢?难道这里的parent另有其人?看来我们还是得进入Snack.make()方法一探究竟:
public static Snackbar make(@NonNull View view, @NonNull CharSequence text,
@Duration int duration) {
Snackbar snackbar = new Snackbar(findSuitableParent(view));
snackbar.setText(text);
snackbar.setDuration(duration);
return snackbar;
}
可以看到,我们传入的view是先经过一个findSuitableParent()方法调用的,听名字就知道肯定是这个方法捣的鬼。我们看看这个findSuitableParent()到底做了什么:
private static ViewGroup findSuitableParent(View view) {
ViewGroup fallback = null;
do {
if (view instanceof CoordinatorLayout) {
// We've found a CoordinatorLayout, use it
return (ViewGroup) view;
} else if (view instanceof FrameLayout) {
if (view.getId() == android.R.id.content) {
// If we've hit the decor content view, then we didn't find a CoL in the
// hierarchy, so use it.
return (ViewGroup) view;
} else {
// It's not the content view but we'll use it as our fallback
fallback = (ViewGroup) view;
}
}
if (view != null) {
// Else, we will loop and crawl up the view hierarchy and try to find a parent
final ViewParent parent = view.getParent();
view = parent instanceof View ? (View) parent : null;
}
} while (view != null);
// If we reach here then we didn't find a CoL or a suitable content view so we'll fallback
return fallback;
}
方法的逻辑很简单,就是循环遍历view的父视图,如果某个父视图是CoordinatorLayout或者已经追溯到了Activity的content视图就直接返回,查找期间如果某个view是FrameLayout就将其设为fallback,作为备用,当查找到视图顶端还没有找到合适的ViewGroup时就返回fallback变量。
看了这段代码,再看看我们之前传入的view,整个视图层级里,既没有CoordinatorLayout,也没有FrameLayout,又因为是直接使用WindowManager显示的,所以也没有content视图,真是要啥啥没有,自然最后的fallback为null,导致了后面的NullPointerException。
解决方案
相信看到这大家都明白了,我们平时在Activity里不管什么视图都可以使用Snackbar,是因为Activity本身有content视图可以给Snackbar使用,所以就算我们本身的视图层级里没有CoordinatorLayout或者FrameLayout,Snackbar也是可以正常使用的。但这在WindowManager中就不好使了,为保证Snackbar的正常使用,我们的视图层级必须包含CoordinatorLayout或者FrameLayout。这里我选择使用FrameLayout作为根视图,其他代码不变:
ViewGroup root = new FrameLayout(MainActivity.this);
果然这么一改,Snackbar可以正常显示了:
进一步探究
代码虽然成功运行了,可我还是有些疑惑,为什么Snackbar必须要使用CoordinatorLayout或者FrameLayout,其他的ViewGroup怎么就入不了Snackbar的法眼呢?想到这,我觉得我有必要继续深究藏在Snackbar身后的秘密。
你Snackbar不允许我使用其他的ViewGroup,我倒要看看我用一用会怎么样!
当然,这里普通的调用是没法做到的,会直接报NullPointerException,我们必须采取一些小手段:
Constructor<Snackbar> snackbarConstructor = Snackbar.class
.getDeclaredConstructor(ViewGroup.class);
snackbarConstructor.setAccessible(true);
Snackbar snackbar = snackbarConstructor.newInstance(root);
snackbar.setText("This is the snackbar.");
snackbar.setDuration(Snackbar.LENGTH_SHORT);
snackbar.show();
这里我用反射直接构造一个Snackbar,将我们刚才的RelativeLayout根视图传入构造器。
运行!
原来如此,看来Snackbar的视图的布局一定用了一些只有CoordinatorLayout和FrameLayout支持的属性,如果使用其他的ViewGroup,Snackbar的显示就会出现错误。那我们再看看Snackbar的xml文件到底写了些什么:
<view xmlns:android="http://schemas.android.com/apk/res/android"
class="android.support.design.widget.Snackbar$SnackbarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:theme="@style/ThemeOverlay.AppCompat.Dark"
style="@style/Widget.Design.Snackbar" />
可以看到这里Snackbar使用了android:layout_gravity="bottom"
来保证Snackbar显示在视图底部,而这个layout_gravity
属性只有CoordinatorLayout和FrameLayout支持,这就是为什么Snackbar不能使用其他ViewGroup的原因。
另一个问题
这个问题是在我解决上面的问题之后出现的:
其实原因很简单,大家可以翻到前面再看一下Snackbar的构造方法,里面有一句:
ThemeUtils.checkAppCompatTheme(mContext);
这一句是检查Context是否使用的AppCompat主题,如果不是就会抛出IllegalArgumentException,因为当时是在Service里面创建的视图,root视图的Context自然也是用的Service,而Service是没有Theme的,于是就产生了这个异常。解决的方法也很简单:
ContextThemeWrapper wrapper = new ContextThemeWrapper(serviceContext, R.style.Theme_AppCompat);
ViewGroup root = new RelativeLayout(wrapper);
那为什么Snackbar一定要求AppCompat主题呢?其实也是因为xml文件内部使用了AppCompat的属性,我这里就不再展示了,如果感兴趣,可以自行查看。
结语
其实解决掉这个问题之后再回头看一看Snackbar的API介绍,解释的也还算清楚:
但知其然,也要知其所以然,这样才算真正的弄知识。