11、通知与消息同步:NotificationListenerService、通知投屏到车机、消息双向同步、免打扰模式

车机互联里,通知同步这个功能,说实话,是用户感知最强的一个点。

你想想看,开车的时候手机震了一下,是微信消息还是外卖到了?低头看手机?太危险。所以把通知投到车机屏幕上,让用户瞄一眼就知道怎么回事,这才是车机互联该有的体验。

我最早做这个功能的时候,以为不就是读个通知嘛,简单。结果一上手才发现,坑不少。权限、保活、双向同步、还有那个烦人的免打扰逻辑……今天咱们就把这些事掰扯清楚。

11.1 NotificationListenerService 基础

Android 系统里,想监听所有通知,得用 NotificationListenerService。这个服务说白了就是一个系统级的监听器,它能捕获所有应用发出的通知。

注册方式很简单,在 AndroidManifest.xml 里声明:

<service
    android:name=".CarNotificationListener"
    android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
    android:exported="true">
    <intent-filter>
        <action android:name="android.service.notification.NotificationListenerService" />
    </intent-filter>
</service>

注意那个 BIND_NOTIFICATION_LISTENER_SERVICE 权限,这是系统级权限,普通应用拿不到。用户得手动去「设置 -> 无障碍 -> 通知监听」里开启。嗯,这一步经常被忽略,我在项目里就遇到过用户反馈说「怎么收不到通知」,结果一看,权限没开。

核心回调方法就两个:

  • onNotificationPosted(StatusBarNotification sbn) —— 通知来了
  • onNotificationRemoved(StatusBarNotification sbn) —— 通知被移除

拿到 StatusBarNotification 之后,你可以提取包名、标题、内容、时间戳,甚至还能拿到通知的 PendingIntent,这样点击车机上的通知就能直接跳转到手机 App。

核心要点: NotificationListenerService 是车机获取手机通知的唯一官方途径。别想着用 AccessibilityService 去抓,那个太 hacky,而且 Google 现在越来越严。

11.2 通知投屏到车机

通知拿到手了,怎么投到车机上?

我习惯的做法是:手机端把通知序列化,通过蓝牙或 Wi-Fi Direct 发送给车机。车机端收到后,解析成自己的 UI 组件展示。

数据格式我用的是 JSON,简单直接:

{
  "packageName": "com.tencent.mm",
  "appName": "微信",
  "title": "张三",
  "content": "晚上一起吃饭?",
  "postTime": 1698123456789,
  "notificationId": 42,
  "key": "0|com.tencent.mm|42|null|10123"
}

这里有个坑:notificationIdkey 必须带上。为什么?因为后续要做消息更新和移除。同一个通知如果内容变了(比如微信消息被撤回),系统会重新发一个 onNotificationPosted,但 key 是一样的。车机端得根据 key 去更新已有的通知,而不是再创建一条新的。

我曾经踩过这个坑——没传 key,结果车机上同一个人的微信消息出现了好几条,用户差点以为手机中毒了。

小技巧: 通知的图标也可以传过去。用 sbn.getNotification().getSmallIcon() 拿到图标,转成 Base64 字符串,车机端解码显示。不过注意大小,别传太大的图。

11.3 消息双向同步

通知投过去只是第一步。用户可能在车机上操作,比如「标记已读」或者「回复消息」。这就涉及到双向同步了。

双向同步的核心逻辑其实就一句话:车机端的操作,要能回传给手机,手机再调用系统 API 去执行。

举个例子,用户在车机上点了一个微信通知的「已读」按钮:

  1. 车机发送指令给手机:「标记通知 key=xxx 为已读」
  2. 手机收到指令,调用 NotificationManager.cancel() 移除通知
  3. 手机再通过 ContentResolver 或者直接调微信的接口(如果有)去标记已读

这里有个现实问题:微信、QQ 这些大厂 App,并没有公开的「标记已读」API。怎么办?

我当时的方案是:利用 NotificationListenerService.requestCancelNotification() 方法。这个方法可以移除通知,但不会真的标记已读。不过对于用户来说,通知消失了,心理上就觉得「已处理」了。

如果你对接的是自己的 App,那就好办了。定义一套 AIDL 接口,车机端直接调:

interface INotificationCallback {
    void onMarkAsRead(String notificationKey);
    void onReply(String notificationKey, String replyText);
    void onDismiss(String notificationKey);
}
注意: 双向同步的延迟问题。蓝牙传输本身就有几百毫秒的延迟,如果用户连续操作,可能会出现指令乱序。我建议在指令里加一个序列号,车机端按序列号顺序执行。

11.4 免打扰模式

开车的时候,不是所有通知都适合弹出来。比如游戏成就解锁、App 更新提醒这些,投到车机上纯属干扰。

免打扰模式,说白了就是做一层过滤。

我一般分三个层级:

层级 说明 示例
白名单 只允许这些 App 的通知投屏 微信、电话、短信、地图
黑名单 这些 App 的通知永远不投 游戏、工具类、系统更新
智能过滤 根据通知类别或关键字判断 只显示「对话」类通知,不显示「推广」类

Android 8.0 之后,通知有了「渠道」(Channel)的概念。每个通知都属于一个渠道,渠道有重要性等级。我们可以利用这个来做智能过滤:

// 获取通知渠道
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
int importance = channel.getImportance();

// 只投递 IMPORTANCE_HIGH 及以上的通知
if (importance >= NotificationManager.IMPORTANCE_HIGH) {
    // 投屏到车机
} else {
    // 忽略
}

另外,车机端也要提供一个「驾驶模式」开关。用户开车时自动开启免打扰,停车后恢复。这个状态变化也要同步给手机,手机端根据状态决定是否继续投递通知。

我的经验: 免打扰模式一定要给用户自定义的空间。有些人觉得工作消息比微信更重要,有些人只想收电话通知。别替用户做决定,给他们一个设置界面。

11.5 整体架构图

下面这张图,是我做这个功能时画的架构图。它把通知从手机到车机的完整链路串起来了:

手机端 车机端 NotificationListenerService 通知解析 & 序列化 免打扰过滤引擎 传输层 (蓝牙/Wi-Fi Direct) 传输层 (蓝牙/Wi-Fi Direct) 通知解析 & UI 渲染 用户交互 (点击/回复/已读) 指令回传 通知数据流 操作指令流 图:通知同步双向架构

从图里能看出来,手机端和车机端是对称的。手机负责监听和过滤,车机负责展示和交互。中间两条箭头,一条是通知数据流,一条是操作指令流,互不干扰。

我实际做的时候,还加了一个心跳机制。每隔 30 秒手机和车机互相确认一下连接是否还在。如果断连了,车机端要清掉所有通知,避免显示过时的信息。

一个细节: 通知的图片附件怎么处理?比如微信里的图片消息。我的做法是只传一个缩略图,车机端点击后,手机端再传原图。这样既保证了流畅度,又节省了带宽。

好了,通知同步这块的核心内容就这些。从监听、投屏、双向同步到免打扰,每一步都有不少细节。做的时候多想想用户的实际场景,别光顾着技术实现。毕竟,车机互联的最终目的,是让驾驶更安全、更便捷。