有段日子我在项目里做业务抽象,把和界面无关的逻辑逐步往自定义 Manager 类里搬。搬完没多久就接到同事求助:自定义类里想弹 Toast,直接Toast.makeText(mContext, ...)还好说,可一旦要弹带图片的提示图,就有点无从下手。默认 Toast 只能显示一行文字,图标这种东西系统压根不给你留口子。更麻烦的是,如果代码写在自定义类里,经常连Context都没有,this用不了,getApplicationContext()也不是随便能调的。
我跟他讲,这个问题说穿了就两件事:第一,自定义类里怎么拿到可靠的Context;第二,怎么把一张图塞进 Toast 的 View 层级里。把这两件事想清楚,剩下的就是布局写法和踩坑经验。这篇文章就把我当时的完整思路和实现过程展开说说,适合正在做代码分层、想把业务工具类补全,或者在非 Activity 环境弹提示图的开发者参考。
1. 自定义类里操作 Toast 的症结:Context 从哪来
1.1 为什么 Toast 非要持有 Context
很多人一开始绕不过弯:我就在 Application 内部,弹个提示而已,为什么一定要Context?因为 Toast 底层要通过Context获取系统服务NotificationManager或者经INotificationManager与系统进程通信,同时还要读取主题、资源、包名等信息。说白了,Toast 本质上不是一个“组件”,它是向系统注册一条临时消息视图的请求,必须知道“我是谁”才能在屏幕对应位置展示。
Toast.makeText的第一个参数是Context,但它的内部代码会立刻把传入的Context拿去获取包名和资源信息。你要是传null,编译期过不去,运行期也一定崩。自定义布局里的LayoutInflater.from(context)同样离不开 Context,所以在自定义类里处理 Toast,第一步永远不是写 Toast,而是确认手里的 Context 是什么、生命周期怎样。
1.2 自定义类里能拿 Context 的三种途径
方法我基本都试过,优先顺序是:构造器传入、方法参数传入、Application 全局持有。
第一种,自定义类写成普通类,构造器显式接收Context:
class ToastHelper(private val context: Context) { fun showWithIcon(message: String, iconRes: Int) { // 内部使用 context } }第二种,不存字段,每次方法调用时由调用方传入:
object ToastHelper { fun show(context: Context, message: String, iconRes: Int) { // 使用当前传入的 context } }第三种,如果自定义类是单例或工具类,可以在 Application 启动时保存全局 Context:
class App : Application() { override fun onCreate() { super.onCreate() appContext = this } companion object { lateinit var appContext: Context } }第一种最稳,第二种最灵活,第三种最方便但容易产生“隐藏依赖”。我自己的建议是:如果这个工具类只在特定模块内部用,构造器传入最合适;如果是全局的提示工具,建议使用方法参数传入,同时内部做空判断,避免后期别人拿着null调用直接崩。
1.3 用 ApplicationContext 还是 Activity Context
这里要特别说明:弹 Toast 推荐统一用applicationContext,不推荐用 Activity。
原因是 Toast 不会持有传入的Context做页面级别的生命周期绑定。如果你传 Activity,只要 Toast 还没有自动消失,就相当于间接持有了 Activity 的引用,极端情况下会导致 Activity 无法被回收,从而引发内存泄漏。虽然 Toast 展示时间很短,但连续弹、排队弹的时候,引用时长会被拉长。换成applicationContext后,它就是全应用共享的实例,生命周期天然比任何页面都长,弹多少次都不会拖住 Activity。
曾经有人担心用applicationContext会导致 Toast 样式跟系统主题不匹配,实测没有这回事。Toast 视图的样式由系统渲染,如果你用自定义布局,布局的主题跟随自己写的 drawable、背景和颜色,跟传入什么 Context 关系不大。
2. 给 Toast 加图片的三种实现思路
自定义 Toast 加图片,网上方案不少,但很多讲得乱七八糟,甚至误导。我梳理下来,真正可用的思路有三条:自定义 View 布局、Spannable 图片插入、以及系统 Toast 配合 drawable 的曲线方案。
2.1 自定义 View 承载图标和文字
这是最直接、最可控的方案。思路是:写一个 XML 布局,里面放一个ImageView和一个TextView,然后通过Toast.setView(view)把布局塞进 Toast 的视图容器中,再调用show()显示。
优点是非常直观,图片和文字可以自由控制间距、大小、对齐方式、圆角背景。缺点是需要额外处理布局和 drawable,自定义程度高时需要注意不同机型可能出现的适配问题,比如深色模式、字体缩放等。但从日常项目统治力看,这套方案是绝对的主流。
2.2 Spannable 方式并不适合 Toast
有人会想到在 Toast 的文字里拼一个ImageSpan,实现“文字带上小图标”的效果。比如:
val spannable = SpannableString(" 保存成功") val drawable = context.getDrawable(R.drawable.ic_success) drawable?.setBounds(0, 0, 32, 32) spannable.setSpan(ImageSpan(drawable), 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) Toast.makeText(context, spannable, Toast.LENGTH_SHORT).show()这样确实能把图片画进 TextView,但它有几个明显短板:图标大小只能通过setBounds控制,想精确垂直居中很麻烦;Toast 默认背景是灰黑色圆角矩形,图片如果带透明背景还好,如果是不规则图标会出现显示边界;而且你没法给文字和图标之间做独立间距控制,更没法把图标放在文字后面。做简单场景可以,做正式通用组件没必要。
2.3 用系统 Toast 配合自定义 drawable 的边界做法
还有一种是完全不动布局,只把系统 Toast 的 TextView 背景替换成图标加文字的 LayerDrawable,或者用setCompoundDrawables给 TextView 设置复合图标。系统 Toast 内部持有一个TextView,可以通过view.findViewById(android.R.id.message)拿到这个 TextView,再设置它的 drawable:
val toast = Toast.makeText(context, "保存失败", Toast.LENGTH_SHORT) val view = toast.view val textView = view?.findViewById<TextView>(android.R.id.message) textView?.setCompoundDrawablesWithIntrinsicBounds( context.getDrawable(R.drawable.ic_error), null, null, null ) toast.show()这个方式省去了自定义布局文件,但可控性同样有限:图标和文字之间的间距由系统 TextView 的 padding 决定,你调整起来非常别扭,而且不同 Android 版本的系统 Toast 布局内部 id 未必稳定。单看代码量它好像最少,但如果后续需求要调整背景圆角、布局方向、最大宽度,就会很吃力。结论就是:只要你是想在正式项目里做通用组件,老老实实走自定义 View 布局。
3. 建一个带图 Toast 的完整实现步骤
这一节直接给出可复用的步骤。以 Kotlin 为例,假定要弹出的场景是“操作成功”和“操作失败”,图标分别用ic_success和ic_error。
3.1 布局 XML 和关键属性
先创建res/layout/view_custom_toast.xml:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/toast_root" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@drawable/bg_custom_toast" android:gravity="center_vertical" android:orientation="horizontal" android:paddingStart="16dp" android:paddingEnd="20dp" android:paddingTop="12dp" android:paddingBottom="12dp"> <ImageView android:id="@+id/toast_icon" android:layout_width="24dp" android:layout_height="24dp" android:layout_marginEnd="8dp" android:contentDescription="@null" android:src="@drawable/ic_success" /> <TextView android:id="@+id/toast_message" android:layout_width="wrap_content" android:layout_height="wrap_content" android:maxWidth="280dp" android:textColor="#FFFFFF" android:textSize="14sp" /> </LinearLayout>注意三个细节:
ImageView用固定 24dp 是比较保守的尺寸,后续需要放大图标时,可以直接在代码里改布局参数,不影响文字。TextView的maxWidth一定要写,否则极端情况下一行文案超过屏幕宽度会出现奇怪的截断。- 背景 drawable 用独立文件,而不是直接写
android:background="@drawable/bg_custom_toast"之外的圆角属性。
背景文件res/drawable/bg_custom_toast.xml:
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:color="#CC333333" /> <corners android:radius="8dp" /> </shape>#CC333333是半透明深灰,比系统默认的纯黑在多数字体上有更好的视觉融合度。如果你项目里用的是 Material Design,可换成?attr/colorInverseSurface,但因为 Toast 是独立悬浮层,直接写死色值会更稳。
3.2 自定义类中的构建逻辑
核心工具类我一般写成object:
object ToastImageHelper { fun show(context: Context, message: String, iconRes: Int) { val appContext = context.applicationContext val toast = Toast(appContext) val view = LayoutInflater.from(appContext) .inflate(R.layout.view_custom_toast, null, false) val icon = view.findViewById<ImageView>(R.id.toast_icon) val text = view.findViewById<TextView>(R.id.toast_message) if (iconRes != 0) { icon.setImageResource(iconRes) icon.visibility = ImageView.VISIBLE } else { icon.visibility = ImageView.GONE } text.text = message toast.view = view toast.duration = Toast.LENGTH_SHORT toast.show() } }这里有一个关键选择:为什么不用Toast.makeText(appContext, message, Toast.LENGTH_SHORT),而是直接Toast(appContext)再用setView?
因为makeText内部会把传入的文字放入一个内部的默认 TextView 里,紧接着你再setView覆盖整个布局,无论如何都会有一次多余的视图构建。直接用Toast(appContext)则干净利落。不过要注意,Toast(Context)这个构造方法从 Android 11 开始虽然仍可用,但部分 lint 工具会提示建议改用Toast.makeText,这是因为未来系统可能收紧自定义 Toast 的权限,现阶段用自定义视图仍是官方允许的方式,只是要留意后台弹窗限制的问题,我在第四节细说。
调用的地方很简单:
ToastImageHelper.show(this, "文件已保存", R.drawable.ic_success) ToastImageHelper.show(requireContext(), "网络异常", R.drawable.ic_error)3.3 多尺寸图标和背景适配
图标资源建议准备 mdpi、hdpi、xhdpi、xxhdpi 多套尺寸,或者直接用 Vector Drawable。Vector 在 Android 5.0 以上原生支持,5.0 以下需要vectorDrawables.useSupportLibrary = true,项目本身一般也会开。使用 Vector 的好处是缩放不失真,在 Toast 这种 24dp 小尺寸场景尤其合适。
如果希望 Toast 背景能够自适应深色模式,在values-night下可以补一个同名 drawable,比如背景色改成更亮的半透明灰色。但实测中发现,Toast 悬浮在内容之上,系统自带的半透明黑在大多数界面已经够通用,强行区分昼夜反而容易让 UI 显得杂。因此我的做法是:业务工具类里保持深色背景;如果产品确实要求跟随主题,再改。
4. 实测里最容易翻车的几个场景
代码写出来不代表能顺利跑。真实项目里,我在以下四个场景都踩过坑,逐个说清楚。
4.1 未初始化的 context 导致空指针
如果工具类写成普通类并在构造器里接收Context,很容易因为调用方传入了null或者还没初始化好的 context 而闪退。最常见的场景是:某个单例工具类的初始化放在Application.onCreate()里,但某个 ContentProvider 或第三方 SDK 先执行了,最终导致工具类拿到的是未实例化的 context。
我的习惯是加一道防护:
private var appContext: Context? = null fun init(context: Context) { appContext = context.applicationContext } private fun checkContext(): Context { return appContext ?: throw IllegalStateException("ToastImageHelper 必须初始化") }虽然抛出异常很粗暴,但至少把错误提前暴露在调用栈里,总比在show()中间某个位置突然 NPE 好定位。
4.2 Android 11 及以上的显示限制
这个问题是最隐蔽的,也是最近几个版本才常见。从 Android 11(API 30)开始,系统对后台应用弹 Toast 做了更严格的限制:当应用处于后台时,标准 Toast 仍然可以显示,但自定义 View 类型的 Toast 有可能被系统忽略或延迟显示。Android 12 及以后,后台应用弹自定义 Toast 的行为进一步收紧,部分高分下自定义 Toast 甚至不会出现。
如果你只是在 Activity 通过按钮点击触发,完全不受影响。但如果你在后台 Service、广播接收器或者定时任务里弹自定义 Toast,就要特别小心。我自己遇到过一个案例:长连接的断线提醒放在 Service 里,结果应用退到后台后,自定义 Toast 死活不弹,换成系统默认 Toast 才正常。
解决思路有两个方向:
- 前台场景用自定义 Toast 保持视觉统一;
- 后台场景降级为系统默认 Toast,或者直接改用通知栏消息,甚至用 Snackbar 在回到前台时补提示。
如果不想写两套逻辑,可以把自定义 Toast 拆成两个方法,一个处理前台,一个处理后台:
fun showSystemToast(context: Context, message: String) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }至于“回到前台时再弹”的延迟队列方案,需要在前台回调里调用方法,本质上是在 Activity 或 Fragment 的生命周期方法里补一次show,而不是真的让后台 Toast 实现队列。
4.3 图片资源 id 和 Drawable 混淆
看起来是个很基础的错误,但真的会发生。比如从接口返回的图标是 URL,你打算异步加载图片。这时候有些代码会写成:
toastIcon.setImageDrawable(drawable)没问题,这是对的。但也有代码写成:
Glide.with(appContext) .load(url) .into(toastIcon)然后 Toast 显示时图标区一片空白。原因是 Glide 的异步加载默认会绑定到一个ViewTarget上,toastIcon此时虽然被 LayoutInflater 创建了,但它并不在真实 View 树中,而是被系统当作临时视图放到了 Toast 的窗口里。Glide 的加载请求完成后需要绑定到 attach 到窗口的 View 才能立即生效,但 Toast 的显示生命周期太短,加载慢一点就会错过显示时机。
我的建议是:如果图标来自网络,先把 Bitmap 缓存下来,再在 Toast 展示时同步设置。如果用 Glide,至少用asBitmap()配合CustomTarget拿到 Bitmap 后直接setImageBitmap,而不是把 ImageView 传给into。
4.4 弹出顺序错乱与重复 Toast 的抑制
快速连续点多个按钮,会看到 Toast 一条接一条排队弹出,有时显得很蠢,有时还会因为上一条 Toast 还没消失、下一条已经在排队而出现“卡顿感”。
控制这个问题的常见办法是:在工具类里维护一个 Toast 实例,新 Toast 弹出前先把上一条 cancel 掉。
object ToastImageHelper { private var currentToast: Toast? = null fun show(context: Context, message: String, iconRes: Int) { currentToast?.cancel() // 构建新 toast currentToast = toast toast.show() } }这里要注意:cancel()只能请求系统把当前 Toast 从队列里移除,但如果你在极短时间连续调用,系统队列可能还没来得及处理上一条的滞后回调,导致上一条延迟消失后,视觉效果上还是会出现“瞬闪两条”。更稳妥的做法是做节流:规定同一时刻只保留最近的一到两次调用,比如用一个AtomicBoolean或者Long记录上次弹出时间,低于 500ms 的重复请求直接丢弃。
5. 把临时方案整理成通用轻提示工具类
写到这里,已经能解决“在自定义类里展示带图片 Toast”的基本问题了。但如果只是在一个项目里写死几个固定资源,代码复用价值其实不高。更进一步的常规演进是把它做成通用轻提示组件。
5.1 静态方法封装与参数设计
我最终在项目里沉淀的接口长这样:
object ToastHelper { fun show(context: Context, message: String) { show(context, message, 0) } fun show(context: Context, message: String, @DrawableRes iconRes: Int) { showInternal(context, message, iconRes) } fun showError(context: Context, message: String) { show(context, message, R.drawable.ic_error) } fun showSuccess(context: Context, message: String) { show(context, message, R.drawable.ic_success) } }这个设计考虑了几点:
message单独成方法,不要求调用方总是传图标,这样使用门槛低;iconRes为 0 时隐藏 ImageView,而不是显示一个空白图标;showError/showSuccess是快捷入口,把最常见的两种业务场景交给调用方直接使用,不用他们关心资源 id。
5.2 支持网络图片延迟加载方案
上面说了,网络图片异步加载很容易错过 Toast 的显示时机,所以我的通用版本做了妥协:网络图片场景不直接走自定义 Toast,而是先把图片缓存下来。实现上可以定义一个 PictureCache:
object PictureCache { private val cache = LruCache<String, Bitmap>(5 * 1024 * 1024) fun put(key: String, bitmap: Bitmap) { cache.put(key, bitmap) } fun get(key: String): Bitmap? { return cache.get(key) } }在网络图片加载成功的同时调用PictureCache.put(url, bitmap),等到要弹 Toast 时同步从缓存取。这种方式的优点是显示时零延迟,缺点是首次加载可能还没有缓存。我的经验是:弹 Toast 的场景通常是用户操作后的即时反馈,真正需要展示复杂网络图标的频率不高,如果一定要展示,优先用本地资源或者预先下好的图标。
如果产品坚持要支持网络图片,并且允许延迟 100~300ms 显示,那么可以这样实现:
fun showNetworkIconToast(context: Context, message: String, imageUrl: String) { val appContext = context.applicationContext var toast: Toast? = null val placeHolder = LayoutInflater.from(appContext) .inflate(R.layout.view_custom_toast, null, false) toast = Toast(appContext) toast.view = placeHolder toast.duration = Toast.LENGTH_LONG Glide.with(appContext) .asBitmap() .load(imageUrl) .into(object : CustomTarget<Bitmap>() { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { placeHolder.findViewById<ImageView>(R.id.toast_icon) .setImageBitmap(resource) placeHolder.findViewById<TextView>(R.id.toast_message).text = message toast?.show() } override fun onLoadCleared(placeholder: Drawable?) = Unit override fun onLoadFailed(errorDrawable: Drawable?) { placeHolder.findViewById<ImageView>(R.id.toast_icon) .setImageResource(R.drawable.ic_placeholder) placeHolder.findViewById<TextView>(R.id.toast_message).text = message toast?.show() } }) }这里我建议用Toast.LENGTH_LONG,给网络加载多一点容错时间。但你要有心理准备:图片加载如果超过 1 秒,用户体验会很差,所以这个方案只适合权重很高的场景,不适合通用所有 Toast。
5.3 线程与生命周期安全
自定义 Toast 的show()方法严格来说只要在当前线程有Looper就能跑,但LayoutInflater和 UI 操作最好还是在主线程执行。你没法保证调用方一定在主线程里调用工具类,所以在工具类内部做一次线程切换比较稳:
private fun runOnMainThread(action: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) { action() } else { mainHandler.post(action) } }同时,也建议在show()里用applicationContext替代直接传入的context,这样即便调用方 Activity 已经销毁,Toast 也不会意外持有已被回收的 Context。如果拿到的是null,直接返回而不是抛异常,避免把问题放大。这里我把“返回”和“抛异常”的取舍给大家说透:开发阶段抛异常能让你尽早发现问题,但发布版本里由于某个第三方 SDK 传了 null 导致崩溃,得不偿失,所以我最终选择在 release 里静默失败、debug 里打日志。
5.4 扩展成支持轻提示的完整方案
做到这步,你可以把整个组件再往上做一层,让它兼容其他轻提示场景,比如页面内浮动提示、网络错误重试提示、表单校验失败提示。思路是同一个“自定义布局 + 文本 + 图标”模板,在不同场景采用不同容器:
- Toast:覆盖面广,适合短暂全屏提示;
- Snackbar:适合页面内带操作按钮的提示;
- 页面顶部悬浮:适合更醒目的通知。
如果项目用到 Jetpack Compose,也可以把同样的图标加文字组合封装成 Composable,然后接入不同的展示宿主。不过那又是另一套代码了,本文暂不展开。
回看整条链路,其实“自定义类里展示带图片的 Toast”这个需求,真正难的不是 Toast 本身,而是两件容易被忽略的事:第一是 Context 的生命周期设计,第二是系统对不同版本的 Toast 限制。只要把这两点从一开始就考虑进去,后续无论怎么重构,这个小组件都不会给你添乱。我在项目中最后留下的是两个小习惯,也分享给你:每个工具类方法第一行都先处理 Context 归一化,统一转成applicationContext;每个 Toast 都走同一个入口方法,绝不在业务代码里散落各种Toast.makeText。慢慢你会发现,为省这几行代码付出的整理成本,在排查线上问题时能成倍赚回来。