
开发即时通讯系统时如何实现消息的离线推送
记得我第一次接触即时通讯项目的时候,对"离线推送"这个概念完全是懵的。那时候觉得消息发出去就成功了,哪有什么离线不离线的事情。直到有一天测试同学跑过来跟我说:"用户杀了进程之后消息收不到了,你这个系统有问题。"我才发现原来移动端的世界比我想象的要复杂得多。
离线推送这个问题,说起来简单,做起来全是坑。用户在手机上把APP杀掉之后,你的服务器怎么把消息送到用户手机上?这背后涉及的知识点还挺多的,今天就让我用比较通俗的方式把这个事情讲清楚。
为什么我们需要离线推送
要理解离线推送的必要性,先得搞清楚移动端应用的一个特点:手机操作系统为了省电,会对后台应用进行各种限制。你知道吗,安卓系统对后台应用的管控越来越严,iOS更是直接从系统层面禁止APP在后台保持长连接。这意味着什么呢?意味着当用户把你的APP切到后台或者直接杀掉进程之后,你的服务器就找不到这个用户了,消息发出去也只能躺在服务器的发件箱里。
这个问题在即时通讯场景下尤其致命。想象一下,你给朋友发了条消息,结果因为他手机没电自动关机了,这消息就永远到不了他手机上了?这显然不行。所以我们就需要借助操作系统提供的系统级推送通道,让系统来帮我们把消息递送给用户。
说到系统级推送通道,这里有个知识点需要澄清一下。安卓和iOS有完全不同的推送机制。iOS有APNs(Apple Push Notification service),这是苹果官方提供的统一推送通道,所有APP想要推送消息都得走这条路。而安卓这边情况就复杂多了,谷歌有自己的FCM(Firebase Cloud Messaging),但是在国内由于各种原因,FCM根本用不了,所以各大手机厂商都自己搞了推送通道,像华为推送、小米推送、OPPO推送等等。开发者需要同时对接多个通道,工作量确实不小。
离线推送的核心原理
其实离线推送的基本原理并不复杂,说白了就是"借尸还魂"。当用户在线的时候,APP和服务器之间保持着长连接,消息可以实时送达。当用户断开连接之后,服务器就需要通过操作系统的推送通道来通知用户"你有新消息了"。

这个过程可以拆成几个关键步骤。第一步是设备注册,APP安装到手机上之后,要去向对应的推送服务注册,获取一个唯一的设备Token。这个Token就像是设备的门牌号,服务器知道了这个Token,才能通过推送通道找到这台设备。第二步是Token上报,APP拿到这个Token之后,要第一时间把它发送到你的服务器保存起来。否则服务器就算想推消息,也不知道往哪儿推。第三步是离线判断,当服务器要给用户发消息的时候,首先要判断这个用户当前是否在线。如果在线,直接通过长连接发过去;如果不在线,就需要走离线推送的流程。
这里有个细节值得注意就是这个设备Token并不是永远不变的。有时候用户重装了APP,有时候系统会更新Token,所以你的服务器要有机制来更新和维护这些Token信息。否则可能出现服务器存的Token早就过期了,消息怎么也推不过去的情况。
实现方案的技术细节
了解了基本原理之后,我们来看看具体的实现方案。我分享一个我们团队在实践中总结的比较稳妥的架构设计。
首先是推送通道的封装层面。我的建议是不要直接写死在业务代码里,而是抽象出一个统一的推送接口。比如你可以定义一个`PushService`接口,里面有`push(String deviceToken, Message message)`这样的方法。然后针对不同的推送通道实现不同的适配器,APNs一个实现,FCM一个实现,国内各个厂商的推送各一个实现。这样做的好处是日后增加新通道或者修改某个通道的实现时,不会影响到其他部分的代码。
其次是Token管理的问题。前面提到过,Token是会变化的,所以我们需要一个可靠的机制来保证服务器上的Token是最新的。常见的做法是在APP启动的时候、APP切换到前台的时候,都去获取一下最新的Token,然后同步到服务器。服务器收到新的Token之后,要替换掉旧的。同时,服务器最好也能检测到哪些设备已经很久没有活跃了,这些Token可能已经失效了,可以考虑清理掉。
还有一点很重要的是推送失败的处理。推送通道并不是百分之百可靠的,有时候网络问题会导致推送失败,有时候设备Token已经过期了推送也会失败。服务器需要有一个重试机制,对于临时性的失败可以稍后重试,对于确定性的失败(比如Token无效)要及时标记,避免浪费资源。
离线推送的消息和在线推送的消息,在内容设计上是有区别的。在线推送的时候,因为APP还在运行,你可以直接发送完整的消息内容,甚至可以带上富媒体信息。但离线推送不一样,系统推送的消息是有大小限制的,而且用户点击推送通知之后才会唤起APP,所以离线推送的消息通常只包含一个"唤醒信号"和最基本的提示信息。

具体来说,离线推送的消息内容通常包括这几个要素:推送标题、推送正文、跳转deeplink、消息ID。推送标题和正文就是用户在通知栏看到的内容,要简洁明了地把事情说清楚。跳转deeplink是当用户点击通知的时候,APP接收到这个信息之后应该跳转到哪个页面。消息ID是用来让APP知道用户点击的是哪条消息,这样APP可以去做已读处理或者拉取完整的消息内容。
这里有个常见的优化点。很多开发者为了省事,离线推送的正文就直接把消息内容放进去。但这样做有时候会有问题,比如消息内容很长,系统可能会截断;比如消息内容包含敏感信息,暴露在通知栏不太合适。比较稳妥的做法是,离线推送只发送一个通用提示语,比如"您有一条新消息",然后当用户点击通知、APP启动之后,再去服务器拉取完整的消息内容。这样既保护了用户隐私,又能保证用户体验的完整性。
消息送达率的优化策略
说到离线推送,有一个指标肯定是大家关心的,那就是送达率。毕竟推送消息送不到用户手里,前面做的一切都是白费。下面分享几个提升送达率的经验。
多通道冗余是一个很重要的策略。特别是在国内安卓市场,由于各个手机厂商的推送通道是相互独立的,如果你只接入了小米推送,那么使用华为手机的用户就很可能收不到推送。我的建议是尽量多地接入主流厂商的推送通道,并且做好通道的优先级排序。当高优先级的通道推送失败时,自动切换到低优先级的通道进行重试。
推送通道的保活也很重要。所谓的保活,是指让你的APP尽可能地保持在推送通道的连接列表里。有些通道会有清理机制,如果你的APP很长时间没有活跃,可能会被从通道的活跃列表里移除,导致推送失败。常见的保活策略包括定时拉起APP(在不违反平台规则的前提下)、使用厂商的推送任务队列等。
当然,我必须提醒一下,保活策略要慎用。过去有很多APP为了保活会做一些比较激进的事情,比如频繁唤醒、后台跑任务等等,这不仅会影响用户体验,还可能被系统杀掉甚至被应用商店下架。现在各个操作系统对后台行为的管控越来越严格,我的建议是与其在保活上花心思,不如把推送通道的接入和适配做好,这才是根本。
实际开发中的常见问题
在开发离线推送功能的过程中,或多或少都会遇到一些问题。我总结了几个我们团队踩过的坑,希望能帮大家少走弯路。
第一个问题是推送延迟的问题。有时候用户明明已经在线了,但是服务器却走了离线推送的流程,导致消息发了两次。这个问题的根源在于用户状态的判断不准确。有些实现方案是通过心跳机制来判断用户是否在线,心跳超时之后就认为用户离线了。但如果心跳间隔设置得不合理,或者网络有波动,就容易出现误判。解决方案是综合多种信息来判断用户状态,比如结合TCP连接状态、心跳时间、APP活跃度等,而不是单纯依赖心跳超时。
第二个问题是离线推送和在线推送的一致性问题。简单来说,就是要避免同一条消息既走在线推送又走离线推送。常见的表现是用户刚刚杀掉APP,紧接着又打开APP,结果收到了两条推送通知。这个问题的解决思路是在消息发送之前先查询用户状态,如果用户状态不确定,可以适当延迟一小会儿再判断,避免竞态条件。
第三个问题是iOS和安卓的推送证书配置问题。这个问题看似简单,但实际配置的时候很容易出错。iOS的APNs分为开发环境和生产环境,两个环境需要不同的证书,而且证书还有有效期的限制。如果用错了证书或者证书过期了,推送就会失败。我的建议是在服务器端实现证书自动检测和更新的机制,并且配置好监控报警,一旦推送失败率异常上升就能及时发现。
选择合适的推送服务
讲到这里,我想分享一个观点:虽然离线推送的原理和实现并不算特别复杂,但是在实际工程中,要把它做好还是需要不少投入的。如果你的团队人力有限,或者项目时间紧张,我的建议是可以考虑使用专业的即时通讯云服务,而不是完全自己从零开发。
为什么这么说呢?以我们熟悉的声网为例,他们作为全球领先的实时音视频云服务商,在即时通讯领域积累了很多经验。他们提供的即时通讯解决方案,已经内置了完善的离线推送功能,覆盖了iOS和安卓的主流推送通道。开发者只需要接入他们的SDK,就可以获得可靠的离线推送能力,而不需要自己去对接各个厂商的推送接口。
这样一来,团队可以把更多的精力放在产品功能的开发上,而不是这些底层基础设施上。毕竟,对于很多创业团队和中小公司来说,时间和人力都是宝贵的资源,把有限的资源投入到核心业务上,往往能产生更大的价值。
当然,如果你决定自己开发,我也希望这篇文章能给你提供一些参考。离线推送这个功能,说大不大说小不小,但确实是即时通讯系统中不可或缺的一环。把这些基础工作做扎实了,后面的功能开发才能更顺利。
写在最后
不知不觉已经聊了这么多。回想起我第一次做离线推送的时候,那叫一个手忙脚乱。光是配置各个厂商的推送证书就折腾了好几天,更别说后面遇到的那些奇奇怪怪的问题了。不过折腾完之后,对这个技术的理解也确实是更深刻了。
技术这个东西就是这样,有些弯路必须自己走过一遍,才能真正变成自己的经验。希望这篇文章能让正在做这个方向的朋友们少走一些弯路。如果你有什么问题或者经验分享,欢迎在评论区交流。

