26、SystemUI的Toast与Dialog:Toast的自定义窗口,Dialog的WindowToken校验

说实话,Toast和Dialog这两个东西,看起来简单,但坑是真的多。

我早期做系统定制的时候,就因为在Dialog里弹了一个系统级Toast,结果窗口直接崩了。后来一查,原来是WindowToken校验没通过。嗯,今天我们就来彻底聊透这两个组件。

一、Toast的本质:一个带超时机制的窗口

很多人以为Toast是View,其实不是。Toast本质上是一个窗口(Window),只不过它自带了一个自动消失的定时器。

它的核心流程是这样的:

  1. 应用层调用 Toast.makeText(),传入上下文和文本。
  2. Toast内部通过 INotificationManager 跨进程通知SystemUI。
  3. SystemUI的 ToastManager 负责调度显示。
  4. 最终通过 WindowManager.addView() 添加一个悬浮窗。

说白了,Toast就是SystemUI帮你托管的一个小窗口。它用的窗口类型是 TYPE_TOAST,这个类型有个特殊权限——不需要 SYSTEM_ALERT_WINDOW 权限也能弹。

关键点:Toast的窗口类型是 TYPE_TOAST,优先级低于 TYPE_APPLICATION_OVERLAY,但高于普通应用窗口。

二、自定义Toast窗口:我踩过的坑

我在项目中遇到过这样一个需求:需要弹出一个带图标的Toast,而且图标要动态变化。当时我第一反应是自定义Layout,然后直接 setView()

代码大概长这样:

Toast toast = new Toast(context);
LayoutInflater inflater = LayoutInflater.from(context);
View view = inflater.inflate(R.layout.custom_toast, null);
ImageView icon = view.findViewById(R.id.toast_icon);
icon.setImageResource(R.drawable.ic_success);
toast.setView(view);
toast.setDuration(Toast.LENGTH_SHORT);
toast.show();

看起来没问题对吧?但实际跑起来,在某些Android 11以上的设备上,Toast直接不显示。

原因是什么?

Android R之后,Google对Toast做了限制:非系统应用的自定义Toast视图,不能直接使用 setView() 设置复杂布局。系统会检查你的View是否包含 ImageViewButton 等可交互控件,如果有,直接拦截。

避坑指南:我曾经在Android 12上调试了整整两天,才发现是自定义Toast被系统静默丢弃了。解决方案是改用 Snackbar 或者直接使用系统默认Toast样式。

如果你非要自定义,可以试试这个思路:

  • 使用 Toast.makeText() 获取默认Toast
  • 通过 getView() 拿到默认的TextView
  • 在TextView外面包一层自定义布局(但这个方法在Android 13上也被限制了)

嗯,说白了,Google就是不希望你乱改Toast的样子。

三、Dialog的WindowToken校验:一个绕不开的门槛

Dialog和Toast最大的区别是什么?

Dialog需要WindowToken。没有Token,Dialog弹不出来。

WindowToken是什么?你可以把它理解成「窗口的通行证」。每个Activity在创建时,WMS(WindowManagerService)会分配一个唯一的Token。Dialog必须持有这个Token,才能被添加到窗口层级中。

我画了一张图,帮你理解这个流程:

Dialog WindowToken 校验流程 应用层 Dialog.show() WMS WindowManagerService Token校验 检查Token有效性 请求Token 传递Token ✅ Token有效 Dialog正常显示 ❌ Token无效 抛出异常 / 窗口不显示 ⚠️ 常见Token失效场景: 1. Activity已销毁但Dialog未dismiss 2. 使用ApplicationContext创建Dialog 3. 跨进程传递Token(Token不可序列化)

四、Token校验失败的典型场景

我遇到过最典型的场景是这样的:

某个后台服务收到推送后,想弹一个Dialog提醒用户。开发同学直接写了:

// ❌ 错误写法
Dialog dialog = new Dialog(getApplicationContext());
dialog.setContentView(R.layout.dialog_notification);
dialog.show();

结果呢?直接崩溃:

android.view.WindowManager$BadTokenException: 
Unable to add window -- token null is not valid; is your activity running?

为什么会这样?因为 ApplicationContext 没有Activity的WindowToken。Dialog需要依附在Activity的窗口上,没有Token,WMS直接拒绝。

我的建议:Dialog一定要用Activity的Context来创建。如果确实需要在非Activity场景弹窗,可以考虑使用 TYPE_APPLICATION_OVERLAY 类型的窗口,但需要 SYSTEM_ALERT_WINDOW 权限。

五、SystemUI中的Dialog特殊处理

在SystemUI里,Dialog的使用方式又不太一样。因为SystemUI本身是一个系统进程,它拥有更高的权限。

SystemUI中的Dialog通常使用 mContext 直接创建,这个Context是SystemUI的ApplicationContext。但SystemUI的Dialog能正常显示,原因在于:

  • SystemUI的窗口类型通常是 TYPE_SYSTEM_DIALOGTYPE_NAVIGATION_BAR
  • 这些类型不需要Activity的Token,WMS会直接放行
  • 但要注意,这类窗口会覆盖在所有应用之上

我整理了一个对比表格,方便你理解:

特性 普通App Dialog SystemUI Dialog Toast
窗口类型 TYPE_APPLICATION TYPE_SYSTEM_DIALOG TYPE_TOAST
需要Token ✅ 必须 ❌ 不需要 ❌ 不需要
权限要求 系统权限
生命周期 跟随Activity 跟随SystemUI 定时自动消失
可自定义程度 低(受限)

六、实战:如何优雅地处理Dialog的Token校验

如果你在开发中遇到了 BadTokenException,别慌。我一般这样处理:

// 安全弹窗的通用写法
public static void showDialogSafely(Activity activity, Dialog dialog) {
    if (activity == null || activity.isFinishing() || activity.isDestroyed()) {
        // 嗯,Activity已经没了,别弹了
        return;
    }
    try {
        dialog.show();
    } catch (BadTokenException e) {
        // 捕获异常,避免崩溃
        Log.w("DialogUtils", "WindowToken无效,无法显示Dialog");
    }
}

这个写法虽然简单,但很实用。我在多个项目中都用到了它。

另外,如果你用的是 DialogFragment,它会自动处理Token问题,因为 DialogFragment 的生命周期和FragmentManager绑定在一起。我个人比较推荐这种方式。

核心总结:

  • Toast是带定时器的窗口,自定义受限
  • Dialog必须持有有效的WindowToken
  • SystemUI的Dialog不需要Token,但需要系统权限
  • 弹窗前一定要检查Activity状态

好了,关于Toast和Dialog的窗口机制,今天就聊到这里。这些东西看起来基础,但真正遇到问题的时候,还是挺折腾人的。希望今天的分享能帮你少踩几个坑。


公众号:蓝海资料掘金营,微信deep3321