欢迎光临
我们一直在努力

从零实现一个支持跨进程(IPC)的自定义 Toast

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

本文基于一个真实 MVVM 项目(Kotlin + ViewBinding),完整讲解如何手写一个\”长得像 Toast、但完全可控、还能跨进程调用\”的消息提示组件。 全文采用 代码 + 逐行注释 的方式展开,所有代码均可直接运行。


一、为什么要自己写 Toast

系统 Toast 有几个绕不开的限制:

问题
说明
样式不可控 只能改文字,背景、圆角、字体都要靠反射或自定义 View 塞进去
动画不可控 系统固定淡入淡出,无法定制
Android 11+ 限制 后台弹出自定义 Toast 被限制,Toast.setView() 已废弃
多进程无解 后台进程(推送/下载/播放)没有 Activity,根本没法往主进程页面上弹东西

所以我们的目标是一个同时满足下面几点的组件:

  • 外观完全自定义(圆角、半透明、动画、时长全可控)
  • 不需要悬浮窗权限(不申请 SYSTEM_ALERT_WINDOW)
  • 支持跨进程调用(后台进程 → 主进程 UI)
  • 支持指定页面延迟显示(“等用户回到首页再提示”)

  • 二、整体架构

    ┌──────────────────────── 主进程(UI 进程)────────────────────────┐
    │ │
    │ Activity │
    │ └── DecorView(整个窗口的根 View) │
    │ └── ToastView ← 自定义提示,直接 addView 挂上来 │
    │ │
    │ ToastManager(单例,对外统一入口) │
    │ ├── ActivityLifecycleCallbacks → 记住\”当前前台是谁\” │
    │ ├── pendingMessage → App 在后台时的消息缓存 │
    │ ├── pendingByClass → 指定页面但页面不在前台时的缓存 │
    │ └── ToastIPCClient │
    │ ├── bindService(ToastService) │
    │ ├── IToastService.showToast(msg) ──────────────┐ │
    │ └── IToastCallback.onToastReceived(msg) ←──────┼─┐ │
    └────────────────────────────────────────────────────────────┼─┼───┘
    │ │
    Binder(双向 IPC) │ │
    │ │
    ┌──────────────────────── :remote 进程 ──────────────────────┼─┼───┐
    │ ToastService │ │ │
    │ └── RemoteCallbackList<IToastCallback> ←──────────────┘ │ │
    │ └── dispatch() 把消息广播给所有注册过的客户端 ──────┘ │
    └──────────────────────────────────────────────────────────────────┘

    设计上刻意模仿系统的 NotificationManagerService:消息不是直接画出来的,而是先发给一个\”服务\”,再由服务回调回来渲染。这样后台进程只需 show() 一下,UI 进程自然就弹了。


    三、第一层:自定义 View —— ToastView

    这是最基础的\”长得像 Toast 的东西\”。

    3.1 圆角背景

    <!– res/drawable/bg_toast_view.xml –>
    <?xml version=\”1.0\” encoding=\”utf-8\”?>
    <shape xmlns:android=\”http://schemas.android.com/apk/res/android\”
    android:shape=\”rectangle\”>

    <!– CC = 80% 不透明度,黑底白字,和系统 Toast 观感接近 –>
    <solid android:color=\”#CC000000\” />

    <!– 圆角半径,让提示看起来不那么生硬 –>
    <corners android:radius=\”20dp\” />

    </shape>

    3.2 View 本体

    package com.jzh.mvvm.ui.view

    import android.content.Context
    import android.graphics.Color
    import android.util.AttributeSet
    import android.view.Gravity
    import android.widget.FrameLayout
    import android.widget.TextView
    import com.jzh.mvvm.R

    /**
    * 自定义 Toast 提示 View
    *
    * 设计要点:
    * – 纯代码构建,不依赖任何 layout.xml,可以直接 addView 到任意 ViewGroup
    * – 圆角半透明黑底 + 白字,视觉与系统 Toast 接近
    * – 淡入 -> 停留 -> 淡出,动画结束后通过 onDismiss 通知外部移除自己
    * – 自身不消费触摸事件,不会挡住下层页面的点击
    */

    class ToastView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
    ) : FrameLayout(context, attrs, defStyleAttr) {

    private val tvMessage: TextView
    private var dismissRunnable: Runnable? = null

    /** 淡出动画结束的回调:外部在这里把自己从父容器移除 */
    var onDismiss: (() -> Unit)? = null

    init {


    // 关键:不拦截触摸事件。
    // 否则这块区域会吃掉点击事件,用户点不到下面的列表
    isClickable = false

    tvMessage = TextView(context).apply {


    setTextColor(Color.WHITE)
    textSize = 14f
    gravity = Gravity.CENTER
    setBackgroundResource(R.drawable.bg_toast_view)
    // 限制最大宽度:长文案会自动换行,不会撑满整屏
    maxWidth = (260 * resources.displayMetrics.density).toInt()
    val pad = (12 * resources.displayMetrics.density).toInt()
    setPadding(pad * 2, pad, pad * 2, pad)
    }
    addView(
    tvMessage,
    LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER)
    )
    // 初始透明,等 show() 时再做淡入
    alpha = 0f
    }

    fun setMessage(text: CharSequence) {


    tvMessage.text = text
    }

    /**
    * 开始显示:淡入 -> 停留 duration 毫秒 -> 淡出
    */

    fun show(duration: Long = DEFAULT_DURATION) {


    // 重复调用时,先把上一条的定时消失任务取消掉,避免叠加
    removeCallbacks(dismissRunnable)
    animate().cancel()

    // 每次都从\”透明 + 下移 40dp\”开始,保证动画起点一致
    alpha = 0f
    translationY = 40f
    animate()
    .alpha(1f)
    .translationY(0f)
    .setDuration(ANIM_DURATION)
    .start()

    // 定时执行淡出
    dismissRunnable = Runnable {

    dismiss() }
    postDelayed(dismissRunnable, duration)
    }

    private fun dismiss() {


    animate()
    .alpha(0f)
    .translationY(40f)
    .setDuration(ANIM_DURATION)
    // withEndAction:动画真正结束后才回调,此时移除 View 才安全
    .withEndAction {

    onDismiss?.invoke() }
    .start()
    }

    override fun onDetachedFromWindow() {


    // 页面销毁 / 被外部移除时,务必取消定时任务,
    // 否则 Runnable 会持有本 View 引用直到触发,造成短时间泄漏
    removeCallbacks(dismissRunnable)
    super.onDetachedFromWindow()
    }

    companion object {


    private const val ANIM_DURATION = 250L
    private const val DEFAULT_DURATION = 2

    赞(0)
    未经允许不得转载:171主机测评 » 从零实现一个支持跨进程(IPC)的自定义 Toast
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址