29、通知的实战项目一:仿微信消息通知(多用户、群聊、消息聚合、快捷回复)

各位同学,欢迎来到实战环节。

前面我们聊了那么多通知的理论,什么渠道、优先级、分组、聚合……说实话,光看概念容易犯困。我自己当年学的时候也是这样,看完文档觉得“哦,原来如此”,一上手写代码就懵了。

所以这一章,咱们直接拿一个大家最熟悉的场景——微信消息通知,来把前面学的知识点串一遍。微信的通知有多复杂?你想想看:

  • 有单聊,有群聊
  • 同一个人的多条消息要聚合
  • 群聊里不同人说话,要按群聚合
  • 还能直接在通知栏回复

嗯,这几乎把 Android 通知系统的所有高级特性都用上了。我们今天就手撸一个简化版。

29.1 项目需求拆解

先别急着写代码。我习惯在动手前,先把需求理清楚。这个项目我们要实现:

  1. 多用户消息:模拟两个用户(张三、李四)发消息
  2. 群聊消息:模拟一个群(“Android 技术交流群”)
  3. 消息聚合:同一个人的多条消息折叠成一条通知
  4. 快捷回复:在通知栏直接输入文字回复,不用打开 App

说白了,就是让通知栏看起来像微信的锁屏通知一样。我当年第一次做这个功能时,踩了不少坑,尤其是聚合那一块,后面会跟大家细说。

核心知识点回顾:

  • NotificationChannel 的创建与配置
  • NotificationCompat.Builder 的 setGroup() 和 setGroupSummary()
  • RemoteInput 实现快捷回复
  • PendingIntent 的 FLAG_UPDATE_CURRENT 坑点

29.2 整体架构设计

先画一张图,让大家对整体流程有个直观认识。这个图是我自己项目里常用的分层思路:

仿微信通知架构图 消息来源层 单聊 / 群聊 / 系统 消息处理层 聚合逻辑 / 分组策略 通知展示层 NotificationManager 消息来源组件 • 用户消息生成器 • 群聊消息模拟器 • 消息 ID 生成策略 聚合与分组引擎 • 按 conversationId 分组 • 子通知 + 摘要通知 • 消息计数与折叠 快捷回复模块 • RemoteInput 配置 • 回复广播接收器 • 更新通知内容 最终效果:通知栏像微信一样智能聚合 + 快捷回复 单聊按人聚合,群聊按群聚合,支持直接回复

这个图里,我特意把“聚合逻辑”单独拎出来一层。为什么?因为很多同学做通知聚合时,直接在 Activity 里写一堆 if-else,后面维护起来想死的心都有。我建议你单独搞一个 NotificationAggregator 类,专门管聚合逻辑。

29.3 核心代码实现

29.3.1 创建通知渠道

微信的通知渠道分得很细。我们简化一下,只建两个渠道:一个给单聊,一个给群聊。

// 单聊渠道
val singleChatChannel = NotificationChannel(
    "single_chat",
    "单聊消息",
    NotificationManager.IMPORTANCE_HIGH
).apply {
    description = "来自好友的私聊消息"
    enableVibration(true)
    setShowBadge(true)
}

// 群聊渠道
val groupChatChannel = NotificationChannel(
    "group_chat",
    "群聊消息",
    NotificationManager.IMPORTANCE_HIGH
).apply {
    description = "来自群聊的@消息或普通消息"
    enableVibration(true)
    setShowBadge(true)
}

notificationManager.createNotificationChannel(singleChatChannel)
notificationManager.createNotificationChannel(groupChatChannel)

小提示:渠道创建后就不能再改重要性了,除非卸载重装。所以上线前一定要想清楚每个渠道的 IMPORTANCE 级别。我有个项目就因为上线后想降级,结果用户必须卸载重装,被骂惨了。

29.3.2 消息聚合——按 conversationId 分组

微信里,每个聊天都有一个唯一的 conversationId。单聊是对方的 userId,群聊是 groupId。我们发通知时,就用这个 id 作为 group key。

// 假设我们有一条新消息
data class ChatMessage(
    val conversationId: String,  // 聊天唯一标识
    val senderName: String,
    val content: String,
    val isGroup: Boolean
)

fun showChatNotification(message: ChatMessage) {
    val channelId = if (message.isGroup) "group_chat" else "single_chat"
    val groupKey = message.conversationId  // 这就是聚合的关键
    
    // 构建子通知(每条消息一个通知,但会被聚合)
    val childNotification = NotificationCompat.Builder(this, channelId)
        .setSmallIcon(R.drawable.ic_message)
        .setContentTitle(message.senderName)
        .setContentText(message.content)
        .setGroup(groupKey)  // 同一组 key 的消息会被折叠
        .setGroupAlertBehavior(GroupAlertBehavior.GROUP_ALERT_SUMMARY)
        .setAutoCancel(true)
        .build()
    
    // 每条子通知用不同的 notificationId,避免覆盖
    val childNotificationId = (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
    notificationManager.notify(childNotificationId, childNotification)
    
    // 更新摘要通知(显示“xx条消息”)
    updateGroupSummary(groupKey, channelId)
}

这里有个关键点:setGroupAlertBehavior。我建议设成 GROUP_ALERT_SUMMARY,这样只有摘要通知会响铃震动,子通知静默聚合。不然来一条消息响一次,用户会疯的。

29.3.3 摘要通知的更新策略

摘要通知负责显示“张三(3条消息)”这样的信息。它需要动态更新。

private fun updateGroupSummary(groupKey: String, channelId: String) {
    // 实际项目中,这里应该从数据库或内存中获取该会话的消息数量
    val messageCount = getMessageCountForConversation(groupKey)
    val latestSender = getLatestSender(groupKey)
    val latestContent = getLatestContent(groupKey)
    
    val summaryNotification = NotificationCompat.Builder(this, channelId)
        .setSmallIcon(R.drawable.ic_group)
        .setContentTitle("$latestSender($messageCount条消息)")
        .setContentText(latestContent)
        .setGroup(groupKey)
        .setGroupSummary(true)  // 标记为摘要通知
        .setGroupAlertBehavior(GroupAlertBehavior.GROUP_ALERT_SUMMARY)
        .setAutoCancel(true)
        .build()
    
    // 摘要通知使用固定的 notificationId,比如 groupKey 的 hash
    val summaryId = groupKey.hashCode()
    notificationManager.notify(summaryId, summaryNotification)
}

注意:摘要通知的 notificationId 必须固定!如果你每次都用随机 id,那就会变成多条独立的通知,根本不会聚合。我曾经在这个问题上 debug 了一下午,最后发现是 id 没固定。

29.3.4 快捷回复的实现

这个功能是微信通知的精髓。用户不用打开 App,直接在通知栏下拉,输入文字就能回复。

// 定义回复的 key
private const val KEY_TEXT_REPLY = "key_text_reply"

// 构建 RemoteInput
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY)
    .setLabel("回复消息")
    .build()

// 构建回复的 PendingIntent
val replyIntent = Intent(this, ReplyBroadcastReceiver::class.java).apply {
    putExtra("conversation_id", conversationId)
    putExtra("sender_name", senderName)
}
val replyPendingIntent = PendingIntent.getBroadcast(
    this,
    conversationId.hashCode(),
    replyIntent,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

// 构建回复 Action
val replyAction = NotificationCompat.Action.Builder(
    R.drawable.ic_reply,
    "回复",
    replyPendingIntent
).addRemoteInput(remoteInput)
 .build()

// 将 Action 添加到通知
val notification = NotificationCompat.Builder(this, channelId)
    .setSmallIcon(R.drawable.ic_message)
    .setContentTitle(senderName)
    .setContentText(content)
    .setGroup(groupKey)
    .addAction(replyAction)  // 添加快捷回复按钮
    .build()

然后在 BroadcastReceiver 里处理回复内容:

class ReplyBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val remoteInput = RemoteInput.getResultsFromIntent(intent)
        val replyText = remoteInput?.getCharSequence(KEY_TEXT_REPLY) ?: return
        
        val conversationId = intent.getStringExtra("conversation_id") ?: return
        val senderName = intent.getStringExtra("sender_name") ?: return
        
        // 这里模拟发送消息
        // 实际项目中,你应该调用网络接口发送
        val replyMessage = ChatMessage(
            conversationId = conversationId,
            senderName = "我",
            content = replyText.toString(),
            isGroup = false
        )
        
        // 更新通知,显示刚刚回复的内容
        showChatNotification(replyMessage)
        
        // 别忘了关闭通知栏
        val notificationManager = context.getSystemService(NotificationManager::class.java)
        notificationManager.cancel(conversationId.hashCode())
    }
}

避坑指南:我曾经在 PendingIntentFLAG_UPDATE_CURRENT 上栽过跟头。如果你不传这个 flag,多次点击回复时,intent 的 extra 可能不会更新,导致回复到错误的会话。所以一定要加上 FLAG_UPDATE_CURRENT

29.4 完整调用示例

把上面这些串起来,我们在 Activity 里模拟几条消息:

// 模拟张三发来两条消息
showChatNotification(ChatMessage("user_zhangsan", "张三", "在吗?", false))
Thread.sleep(500)
showChatNotification(ChatMessage("user_zhangsan", "张三", "晚上一起吃饭?", false))

// 模拟群聊消息
showChatNotification(ChatMessage("group_android", "李四", "有人用过 Compose 吗?", true))
Thread.sleep(500)
showChatNotification(ChatMessage("group_android", "王五", "我在用,挺好用的", true))

运行后,通知栏会显示两条聚合通知:一条是“张三(2条消息)”,一条是“Android 技术交流群(2条消息)”。点击展开,能看到每条子消息。再点击回复按钮,直接输入文字,通知栏就会更新显示你回复的内容。

29.5 总结与经验分享

这个项目虽然简单,但把通知的核心玩法都覆盖了。我个人觉得,做通知功能最重要的就三点:

  • 渠道规划要提前想好——上线后改不了
  • 聚合逻辑要独立封装——别跟 UI 代码混在一起
  • 快捷回复的 PendingIntent 要小心——FLAG_UPDATE_CURRENT 不能少

嗯,如果你能把上面这些代码跑通,并且理解每一行在干什么,那恭喜你,通知这块你已经超过 80% 的 Android 开发者了。剩下的 20%,就是处理各种厂商 ROM 的兼容性问题——那个我们后面章节再聊。

本章核心要点:

  • 使用 setGroup()setGroupSummary() 实现消息聚合
  • 固定 notificationId 是聚合成功的关键
  • RemoteInput + BroadcastReceiver 实现快捷回复
  • PendingIntent 必须使用 FLAG_UPDATE_CURRENT

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