深入 Android 推送服务全链路:从 FCM 云端下发到厂商通道保活的消息可达性架构解析

去年在做一个海外社交 App 的国内版本适配时,遇到了一个棘手的问题:同样的消息推送逻辑,Google Play 版本到达率稳定在 95% 以上,国内版本却掉到了 70%,尤其在用户锁屏超过 15 分钟后几乎全军覆没。一条推送消息从服务端发出到用户手机亮屏,中间到底经历了什么?我把 FCM 和国内几家厂商的推送通道都拆了一遍。

FCM 的天生优势:Google 全家桶的底层特权

FCM(Firebase Cloud Messaging)的送达率高,不是因为技术多先进,而是因为它跑在 Google Play Services 的进程里。这恰恰是推送通道的核心矛盾:谁的进程优先级高,谁的消息就能先到。

FCM 长连接建立在 Google Play Services 进程内,这个进程有两个关键优势:

一是 persistent 标记豁免。Android 6.0 之后,普通 App 的 android:persistent 属性已被框架层忽略,但 Google Play Services 作为系统级应用仍然生效。即使用户从最近任务中划掉你的 App,FCM 连接依然存活。

二是 Doze 白名单。从 Android 6.0 引入 Doze 模式开始,非白名单应用在网络访问、WakeLock、Alarm 等方面受到严格限制。在 AOSP 源码 DeviceIdleController.java 中,Google Play Services 的包名被硬编码在 mPowerSaveWhitelistExceptIdleApps 列表中:

// frameworks/base/services/core/java/com/android/server/DeviceIdleController.java
private void addPowerSaveWhitelistApps() {
    mPowerSaveWhitelistExceptIdleApps.add("com.google.android.gms");
    mPowerSaveWhitelistExceptIdleApps.add("com.android.vending");
    // ...
}

FCM 的心跳长连接在 Doze 深度休眠阶段仍能维持。实测结论:FCM 消息在 Doze 模式下延迟通常在 3-5 秒,几乎无感。

厂商通道的生存博弈:在系统夹缝中维持心跳

国内手机没有 Google Play Services,小米推送(MiPush)、华为推送(HMS Push)、OPPO 推送、VIVO 推送各自建立独立长连接。它们的保活策略,本质上是在和系统的省电机制博弈。

两条不同的架构路线

华为和 OPPO 走的是系统服务内置路线。推送 SDK 不维护自己的长连接,而是通过 Binder 调用系统服务层的推送组件。以华为 HMS Push 为例,App 端的 SDK 只是一个轻量代理:

// App 端调用
HmsMessageService service = new HmsMessageService();
TokenResult token = service.getToken();

// 实际执行在 com.huawei.hwid 进程中
// 该进程拥有系统级常驻权限,不受 Doze 完全限制

这条路线的好处是推送通道由系统进程维护,存活能力接近 FCM。代价是 App 启动时如果系统推送服务还没就绪,需要等待异步初始化,首次获取 Token 会有 3-8 秒延迟。

小米和 VIVO 则走系统白名单 + 独立进程路线。推送 SDK 注册一个独立的 :push 进程维持长连接,依靠厂商在 ROM 层面给自家推送进程开后门——提升 OOM Adj 值、加入电池优化白名单、绕过 Doze 的网络限制。

这条路线的麻烦在于,厂商之间没有统一标准,白名单策略全靠自觉。早期版本中甚至出现过自家推送进程被自家省电功能杀掉的尴尬场面。

心跳策略的差异

长连接的心跳间隔直接决定了消息延迟和耗电的平衡。各通道的差异如下:

通道前台心跳间隔Doze 心跳间隔备注
FCM~60s~300sActivity Detection 自适应
MiPush~45s~180s保活强度最高,耗电偏大
HMS Push~120s~300s系统服务无心跳,按 Alarm 唤醒
OPPO Push~120s~300s与 HMS 策略接近

小米的 45 秒心跳频率在业内算激进策略。表面上看延迟更低,但在用户静止不操作时,高频心跳带来的电量消耗会让省电模块把它标记为高耗电应用,反而增加被杀的几率——典型的”越努力越不幸”。

Doze 模式的真实影响:比你想象的更复杂

不少开发者对 Doze 的理解停留在”应用进入后台后被限制网络访问”这个层面。实际机制要精细得多。

Doze 有多个阶段。轻量 Doze(Light Idle)在灭屏后几分钟内启动,此时网络访问被限速但不完全阻断。深度 Doze(Deep Idle)会在设备静置 30 分钟后触发,进入维护窗口(Maintenance Window) 机制——系统定时短暂恢复网络,应用趁机发送心跳和接收消息,窗口结束后继续休眠。

这里有一个容易被忽略的细节:维护窗口的周期是不固定的。Android 9 之前约 15 分钟一次,之后逐渐延长。到 Android 12 之后,设备静置 2 小时以上时,窗口周期可能拉长到 1-2 小时。对推送消息的时效性来说,这几乎是致命的。

实测数据:一台静置 3 小时的 Android 13 测试机,非白名单应用推送延迟在 30 分钟到 1 小时之间波动。

消息分级触达:不是所有消息都需要”立刻到达”

理解了通道差异和 Doze 限制后,回到工程实践。一个可落地的消息分级策略如下:

  • P0 即时消息(通话邀请、验证码):通过厂商通道的高优先级接口发送,允许触发系统唤醒。需要在服务端做超时降级——如果 5 秒内未确认到达,降级为 P1 走长连接重推。

  • P1 重要消息(私信、交易通知):走标准推送通道,服务端维护消息 ID 的去重和排序。到达后可被系统通知栏展示,但不应唤醒屏幕。

  • P2 静默消息(数据同步、配置更新):使用 FCM 的 Data Message 或厂商通道的透传消息,不展示通知。App 前台时立即处理,后台时写入本地队列等待下次活跃时批量处理。

我项目中落地的一个消息路由模块简化如下:

def route_push(user_id, message):
    channel = get_active_channel(user_id)  # "fcm" / "hms" / "mipush" ...
    priority = message.get("priority", 1)
    
    if priority == 0:
        # P0: 标记紧急,走系统级唤醒
        channel.push(message, wakeup=True, ttl=300)
        # 5 秒后检查 ACK,未收到则降级重推
        schedule_ack_check(message.id, delay=5, fallback_p1=True)
    elif priority == 1:
        channel.push(message, wakeup=False, ttl=3600)
    else:
        # P2: 透传,不在通知栏展示
        channel.data_message(message, ttl=7200)

这里有一个坑:华为和小米的 TTL 参数含义不完全一致。华为的 TTL 指消息在服务端的最大存储时间,超时丢弃;小米的 TTL 在此基础上还会影响消息的下发策略——过短的 TTL 可能导致消息在 Doze 维护窗口外到期。建议统一设为 3600 秒以上,由服务器侧做时效控制,不要依赖通道的 TTL。

一个实际踩过的坑

做完多通道适配上线后,灰度阶段发现 OPPO 机型在特定场景下到达率异常低。排查下来,原因出在 OPPO 的推送 SDK 要求 App 必须被用户手动开启”自启动”权限,否则后台推送服务不会建立连接。

这与华为和小米的逻辑不同——后者的系统推送服务不受”自启动”权限控制,只要 App 安装过就没问题。OPPO 的审查团队认为推送通道的建立属于”自启动行为”,需要用户显式授权。

解决方式分两层:第一层是在注册和登录流程中检测该权限状态,引导用户开启;第二层是服务端针对 OPPO 用户增加 SMS 回退通道,在推送失败 30 秒后走短信下发 P0 消息。

这个坑让我意识到:多通道适配不只是 SDK 集成就完事,每个厂商对权限边界的理解差异才是真正的业务风险。

几个可以带走的原则

做推送系统优化,与其纠结这个通道比那个快多少毫秒,我更建议把握几个原则:

优先用系统级通道,而非自建长连接。 面向海外用户,FCM 是不需要犹豫的选择;国内用户按设备品牌走对应厂商通道。自建长连接只应作为一种补充,承载 P2 级别的哑数据同步。

对 Doze 不要有侥幸心理。 不要假设你的心跳策略能跑赢系统省电机制。把消息分级做好、把 TTL 设足,比研究心跳算法更务实。

建立服务端的消息追踪闭环。 记录每条消息从下发到终端 ACK 的时间线,区分「通道延迟」「设备休眠延迟」「客户端处理延迟」,问题排查时一目了然。我在项目中用 message_id + event_type + timestamp 的三元组埋点,配合 ELK 做实时监控,上线半年捕获了 4 个通道侧的异常波动。