如何实现通知的点击跳转、删除回调和撤销功能?
解读
国内面试中,这道题表面问“通知交互”,实则考察四大维度:
- 对 Android 通知体系(NotificationManager、NotificationChannel)的完整掌握;
- 对 PendingIntent 匹配与启动模式的深度理解;
- 对系统回调(deleteIntent、BroadcastReceiver、Service)的灵活运用;
- 对“撤销/回退”这一业务诉求的技术落地能力(本地缓存、延迟回收、二次通知)。
面试官通常以“你在项目中做过消息撤销吗?”作为追问,若候选人只能答出 setContentIntent 与 deleteIntent,会被视为“API 记忆型”;若能结合国内 ROM 保活、通知权限、折叠屏、厂商推送兼容性与 Android 13 运行时权限,给出可灰度、可回滚、可监控的完整方案,则直接拉高评分。
知识点
- NotificationChannel:8.0+ 必须创建渠道,重要性级别决定响铃、震动、角标,国内需适配厂商自定义级别。
- PendingIntent:点击跳转用 getActivity/Service/Broadcast,flag 必须显式声明 FLAG_IMMUTABLE/FLAG_MUTABLE,否则 12+ 抛异常;Intent 需加包名限定向,防止意图劫持。
- deleteIntent:用户滑动清除或点击“清除全部”时触发,系统回调运行在 UI 进程,耗时>10 s 会 ANR,需转交给 IntentService/WorkManager。
- 撤销模型:本地缓存已发通知的 id + 业务 payload + 时间戳;撤销指令走长连接或厂商推送,到达后构造相同 id 的“已撤销”通知并调用 cancel(id);若需用户二次确认,可发“可恢复通知”并设置超时自动删除。
- 国内兼容:华为、小米、OPPO 对通知渠道有白名单,需调用厂商 SDK 的专用 API 才能展示横幅、全屏意图;部分 ROM 默认关闭“通知权限”,需在引导页提前弹窗申请。
- 安全与性能:Intent 中禁止传递 Serializable 大对象,使用 PersistableBundle 或数据库;deleteIntent 回调中只做轻量级记录,耗时任务丢到线程池;撤销逻辑需幂等,防止重复 cancel 导致 NPE。
- 折叠屏/大屏:通知栏宽度可变,RemoteViews 需使用 ConstraintLayout 并做 160/240/320 dpi 三份布局,否则系统会拒绝加载。
- 灰度监控:通过 UMeng/Rangers 埋点上报“通知下发—点击—删除—撤销”四段漏斗,结合 Battery Historian 观察 deleteIntent 是否频繁唤醒。
答案
以下代码基于 Kotlin + Android 13,覆盖国内主流 ROM,可直接搬进面试白板。
- 统一通知管理类
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())
}
}
- 删除回调接收器
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) }
}
}
- 撤销接收器
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) }
}
}
- 权限与保活
- Android 13 新增 POST_NOTIFICATIONS 权限,必须在启动屏使用 ActivityResultLauncher 申请,否则 notify 直接静默失败。
- 国内 ROM 后台限制:撤销指令若走自家长连接,需把 UndoReceiver 注册为 “exported=false” 并加入厂商自启动白名单;若走厂商推送,则调用华为 Push Kit 或小米 MiPush 的“撤回消息”接口,避免应用进程被杀导致收不到。
- 线上灰度
- 通过 Firebase Remote Config 或国内 MobPush 标签,按 10% 用户开启“撤销按钮”。
- 监控指标:撤销点击率 = undo 点击 / 通知展示;误触率 = 撤销后 5 分钟内用户再次发送消息占比;目标误触率 < 2%。
拓展思考
- 多用户/多设备:撤销指令如何同步到平板或车机?可引入 MQTT + AccountManager 的 uid 级主题,或直接使用 Google 的“跨设备通知取消”API(国内需替换为厂商联盟接口)。
- 内嵌图片/进度条:若原通知为 PictureMessageStyle 或 ProgressBar,撤销时需手动回收大图内存,防止 RemoteViews 泄漏。
- 合规与隐私:撤销日志属于“用户操作轨迹”,需纳入《个人信息保护法》范畴,上报前做 MD5 脱敏并写入隐私清单。
- AI 预撤销:结合端侧 MLKit,识别用户“频繁删除同一联系人消息”行为,下次通知直接静默投递到通知历史而不弹窗,实现“无感撤销”。
- 折叠屏适配:展开状态下通知栏宽度 960 dp,RemoteViews 最大内存 1 MB,图片需用 WebP 限定 512 KB,否则系统直接丢弃撤销按钮。
掌握以上细节,可在面试中从“会用 API”升级为“能扛千万日活”,顺利拿到 SP。