如何实现通知的点击跳转、删除回调和撤销功能?

解读

国内面试中,这道题表面问“通知交互”,实则考察四大维度:

  1. 对 Android 通知体系(NotificationManager、NotificationChannel)的完整掌握;
  2. 对 PendingIntent 匹配与启动模式的深度理解;
  3. 对系统回调(deleteIntent、BroadcastReceiver、Service)的灵活运用;
  4. 对“撤销/回退”这一业务诉求的技术落地能力(本地缓存、延迟回收、二次通知)。

面试官通常以“你在项目中做过消息撤销吗?”作为追问,若候选人只能答出 setContentIntent 与 deleteIntent,会被视为“API 记忆型”;若能结合国内 ROM 保活、通知权限、折叠屏、厂商推送兼容性与 Android 13 运行时权限,给出可灰度、可回滚、可监控的完整方案,则直接拉高评分。

知识点

  1. NotificationChannel:8.0+ 必须创建渠道,重要性级别决定响铃、震动、角标,国内需适配厂商自定义级别。
  2. PendingIntent:点击跳转用 getActivity/Service/Broadcast,flag 必须显式声明 FLAG_IMMUTABLE/FLAG_MUTABLE,否则 12+ 抛异常;Intent 需加包名限定向,防止意图劫持。
  3. deleteIntent:用户滑动清除或点击“清除全部”时触发,系统回调运行在 UI 进程,耗时>10 s 会 ANR,需转交给 IntentService/WorkManager。
  4. 撤销模型:本地缓存已发通知的 id + 业务 payload + 时间戳;撤销指令走长连接或厂商推送,到达后构造相同 id 的“已撤销”通知并调用 cancel(id);若需用户二次确认,可发“可恢复通知”并设置超时自动删除。
  5. 国内兼容:华为、小米、OPPO 对通知渠道有白名单,需调用厂商 SDK 的专用 API 才能展示横幅、全屏意图;部分 ROM 默认关闭“通知权限”,需在引导页提前弹窗申请。
  6. 安全与性能:Intent 中禁止传递 Serializable 大对象,使用 PersistableBundle 或数据库;deleteIntent 回调中只做轻量级记录,耗时任务丢到线程池;撤销逻辑需幂等,防止重复 cancel 导致 NPE。
  7. 折叠屏/大屏:通知栏宽度可变,RemoteViews 需使用 ConstraintLayout 并做 160/240/320 dpi 三份布局,否则系统会拒绝加载。
  8. 灰度监控:通过 UMeng/Rangers 埋点上报“通知下发—点击—删除—撤销”四段漏斗,结合 Battery Historian 观察 deleteIntent 是否频繁唤醒。

答案

以下代码基于 Kotlin + Android 13,覆盖国内主流 ROM,可直接搬进面试白板。

  1. 统一通知管理类
object NotificationCenter {
    private const val CHANNEL_ID = "im_important"
    private const val CHANNEL_NAME = "即时消息"
    private const val REQ_CLICK = 1
    private const val REQ_DELETE = 2
    private const val REQ_UNDO = 3

    fun init(ctx: Application) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH
            ).apply {
                enableLights(true)
                enableVibration(true)
                setShowBadge(true)
            }
            ctx.getSystemService<NotificationManager>()?.createNotificationChannel(channel)
        }
    }

    fun show(ctx: Context, msgId: Int, title: String, content: String) {
        // 1. 点击意图:跳转到会话页,携带 msgId 用于已读上报
        val clickIntent = Intent(ctx, ChatActivity::class.java).apply {
            putExtra("msg_id", msgId)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
        }
        val clickPi = PendingIntent.getActivity(
            ctx, REQ_CLICK + msgId, clickIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or
                    (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
                        PendingIntent.FLAG_IMMUTABLE else 0)
        )

        // 2. 删除回调:用于统计“用户清除”
        val deleteIntent = Intent(ctx, NotificationDeleteReceiver::class.java).apply {
            putExtra("msg_id", msgId)
        }
        val deletePi = PendingIntent.getBroadcast(
            ctx, REQ_DELETE + msgId, deleteIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or
                    (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
                        PendingIntent.FLAG_IMMUTABLE else 0)
        )

        // 3. 撤销动作:发送一条延迟广播,10 秒内可撤销
        val undoIntent = Intent(ctx, UndoReceiver::class.java).apply {
            putExtra("msg_id", msgId)
        }
        val undoPi = PendingIntent.getBroadcast(
            ctx, REQ_UNDO + msgId, undoIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or
                    (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
                        PendingIntent.FLAG_IMMUTABLE else 0)
        )

        val notification = NotificationCompat.Builder(ctx, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_msg)
            .setContentTitle(title)
            .setContentText(content)
            .setAutoCancel(true)
            .setContentIntent(clickPi)
            .setDeleteIntent(deletePi)
            .addAction(R.drawable.ic_undo, "撤销", undoPi)
            .setTimeoutAfter(10_000) // 10 秒后自动取消,国内 ROM 需≥8.1 才生效
            .build()

        NotificationManagerCompat.from(ctx).notify(msgId, notification)

        // 本地缓存,用于撤销
        NotificationRepo.save(msgId, title, content, System.currentTimeMillis())
    }
}
  1. 删除回调接收器
class NotificationDeleteReceiver : BroadcastReceiver() {
    override fun onReceive(ctx: Context, intent: Intent) {
        val msgId = intent.getIntExtra("msg_id", -1)
        if (msgId == -1) return
        // 轻量级写入,< 10 ms
        NotificationRepo.markUserDismiss(msgId)
        // 耗时统计丢线程池
        AppScope.launch { ApiReporter.track("notification_dismiss", msgId) }
    }
}
  1. 撤销接收器
class UndoReceiver : BroadcastReceiver() {
    override fun onReceive(ctx: Context, intent: Intent) {
        val msgId = intent.getIntExtra("msg_id", -1)
        if (msgId == -1) return
        // 幂等取消
        NotificationManagerCompat.from(ctx).cancel(msgId)
        // 发“已撤销”通知
        val undoDone = NotificationCompat.Builder(ctx, NotificationCenter.CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_done)
            .setContentTitle("消息已撤销")
            .setTimeoutAfter(2_000)
            .build()
        NotificationManagerCompat.from(ctx).notify(msgId + 10_000, undoDone)
        // 本地删除 & 上报
        NotificationRepo.remove(msgId)
        AppScope.launch { ApiReporter.track("notification_undo", msgId) }
    }
}
  1. 权限与保活
  • Android 13 新增 POST_NOTIFICATIONS 权限,必须在启动屏使用 ActivityResultLauncher 申请,否则 notify 直接静默失败。
  • 国内 ROM 后台限制:撤销指令若走自家长连接,需把 UndoReceiver 注册为 “exported=false” 并加入厂商自启动白名单;若走厂商推送,则调用华为 Push Kit 或小米 MiPush 的“撤回消息”接口,避免应用进程被杀导致收不到。
  1. 线上灰度
  • 通过 Firebase Remote Config 或国内 MobPush 标签,按 10% 用户开启“撤销按钮”。
  • 监控指标:撤销点击率 = undo 点击 / 通知展示;误触率 = 撤销后 5 分钟内用户再次发送消息占比;目标误触率 < 2%。

拓展思考

  1. 多用户/多设备:撤销指令如何同步到平板或车机?可引入 MQTT + AccountManager 的 uid 级主题,或直接使用 Google 的“跨设备通知取消”API(国内需替换为厂商联盟接口)。
  2. 内嵌图片/进度条:若原通知为 PictureMessageStyle 或 ProgressBar,撤销时需手动回收大图内存,防止 RemoteViews 泄漏。
  3. 合规与隐私:撤销日志属于“用户操作轨迹”,需纳入《个人信息保护法》范畴,上报前做 MD5 脱敏并写入隐私清单。
  4. AI 预撤销:结合端侧 MLKit,识别用户“频繁删除同一联系人消息”行为,下次通知直接静默投递到通知历史而不弹窗,实现“无感撤销”。
  5. 折叠屏适配:展开状态下通知栏宽度 960 dp,RemoteViews 最大内存 1 MB,图片需用 WebP 限定 512 KB,否则系统直接丢弃撤销按钮。

掌握以上细节,可在面试中从“会用 API”升级为“能扛千万日活”,顺利拿到 SP。