文章目录
  1. 1. 客户端架构
    1. 1.1. 进程切分
    2. 1.2. 保活
  2. 2. 与服务器通信
    1. 2.1. 通信协议选择
    2. 2.2. 建立安全连接
    3. 2.3. 心跳
    4. 2.4. 断线重连
  3. 3. 多媒体数据管理
    1. 3.1. 图片
    2. 3.2. 语音

做了3年的易信,又搞了大半年 IM SDK。最近开始搞另外一个项目,虽然还是基于原来的 SDK 的, 但总觉得会和 IM 拉开一些距离了,所以,在这里给 IM 开发做一个小结,留备查看。

客户端架构

作为一个 IM 软件,最重要的一个特性就是保证消息的达到率和实时性。达到率受服务器性能和设计协议影响,后面再谈。而实时性则主要取决于客户端进程是否长期存活,连接是否一致保持。由于墙的存在,墙内的 Android 系统是没法用到 GCM 服务的,要实时收到消息推送,就只能靠 App 自己了。也幸好 Android 是一个 “多任务操作系统.真”,我们才能够在墙内也能实时收到微信易信的消息。

进程切分

在 Android 系统中,App 对于自己应用的生命周期是基本没有控制力,系统能在任意时候将你的进程杀死,且不会发出任何通知,也会在它认为合适的时候把你叫起来。进程前后台切换也同样不会给出任何通知。不过进程的生死控制也还是有一些规矩的,大体上来说就是进程占的资源越多(内存,CPU 时间等等),对于用户越不重要(前台进程->可视进程->服务进程->后台进程->空进程),越容易被干掉。因此,进程应当尽量小巧,且具有高的优先级。

如果一个应用本身就很小巧的话,一个进程就完全足够了,主线程负责 UI,另起一个后台线程跑一个服务。而如果应用比较庞大的话,将推送服务独立出来则是一个更好的选择。主进程负责用户交互和主要的业务逻辑,占用庞大的资源,当退到后台后,随时被杀死都无所谓。推送进程则仅仅负责与服务器交互,保持最小限度的业务逻辑处理。

网络连接和登录状态是绑在一起的,登录之后,同步数据也是必须的操作。因此,登录和同步数据都需要在推送进程中完成,除此之外,其他的业务都交给 UI 进程处理。推送进程收到自己不属于自己的协议时,就将数据扔给 UI 进程处理。

两个进程之间通信方式没有别的选择,只有 AIDL,难点在于接口的设计。IM 业务逻辑复杂,我们不可能为每一个调用实现一个 AIDL 接口,因此肯定会把接口调用打包成控制命令传递。而标识控制命令比较容易想到的方式,是采用类似于 Message 的 what,由我们为每一个控制命令分配一个命令号(或者再加一个子命令号),并指定对应的命令数据格式,接收端根据命令号再将数据反解出来处理。这种方式比较麻烦,且可维护性很差。更优雅的方式是使用远程过程调用,发送端申明业务的调用接口,并在远端实现这些接口,当发送端调用这些接口时,远端直接调用对应接口的实现。除了使用各种第三方框架外,Java 自身的 Proxy 也能实现这个功能。而从推送进程到 UI 进程还有一点不同,UI 进程随时可能会被干掉,AIDL 调用可能会返回失败,此种情况可选择 Intent 方式传递数据,并兼具唤起 UI 进程的功能。

保活

保活分为三个方面,一是系统API提供了接口,应用自己就能做的,这是”合法“的,二是利用系统的缺陷,躲开系统的审查,这算是”非法“的,或者是”灰色“的,三就是多个 App 结盟,互相唤醒,这是耍流氓,谁的阵营庞大谁就赢。

第一种主要有系统闹钟,各种事件的 BroadcastReceiver,任务被移除的回调通知等。

第二种已知的就是在 4.4 及以前版本上,使用 native 进程,并将该进程从 davilk 父进程中脱离,挂接到 init 进程上,以此避开系统的查杀。然后在这个 native 进程中,定时唤起应用。为了让这个 native 进程更轻巧,可以使用 exec 的方式启动一个可执行文件,以除掉直接 fork 带入的 Zygote 进程环境。另外,这种方式也被用在监听自己应用被卸载时弹出调查窗口。

第三种方式现在各大互联网公司都在使用,方式很简单,互相调用指定的 Service,或发指定得广播即可。只要你起一个阿里系的 App,其他阿里系的 App 都会被跟着唤起。你启动一个装了友盟 SDK 的 App, 其他装了友盟 SDK 的 App,以及阿里系的 App 都会被跟着唤醒。

通常,第一种是必备,第二种和第三种则会结伴出现,流氓到底。

与服务器通信

通信协议选择

消息的实时性的另一个保证是长连接。当然,你也可以用短连接轮询,但这个一般只在网页端短时聊天使用,在 Android 后台无限时轮询没有人能受的了。长连接类型可以选传统的 TCP,也可以使用 比较新的 WebSocket。 使用后一种的好处主要是服务器的,他们一套连接就可以服务好 App 端和 Web 端。

IM 的通信协议选择性很多,开源的有 XMPP,MQTT等,使用开源协议的优势在于上手快,资料多。而大部分主流 IM 则一般会设计私有的通信协议。使用私有协议,可以针对自己的业务逻辑,设计出更省流量,效率更高的协议,同时,还能有效保护自己的生态圈,就像 Android 手机装不了苹果系统,易信用户不能给微信用户发消息一样。

私有协议的协议内容和开源协议差不多,可以包含通用的协议头,然后加上负载包体。打包时,为了追求可读性,可以使用文本协议,为了追求省流量,则一般使用二进制协议。

在设计私有协议时,消息必达是一个需要侧重考量的地方。由于移动网络的复杂性,消息在客户端和服务器之间传递是有很大可能被传丢的。当客户端发送消息给服务器时,客户端并不能确保消息一定就会被服务器收到,需要服务器在收到消息后给客户端一个回馈,如果客户端没有收到回馈,就需要在一定超时后重新发送。这里存在一个问题就是有可能服务器已经收到了,但回馈的包被丢掉了,这时就会造成消息重复,为了去重,我们需要为相同的消息分配相同的 uuid,供接收方去重。同样,当服务器将消息转发给接收端时,服务器也不能保证接收端就一定能收到,需要接收端给服务器一个回执,告诉服务器这条消息我已经收到了,你就不要再给我发了。

建立安全连接

安全性是 IM 软件的另一个硬需求。消息传递时如果通信数据如果被第三方截取,要能保证别人不能获取到真实内容。安全连接的过程可以参考 HTTPS 的方式,由服务器将证书下发给客户端,客户端产生一个对称的密钥,并通过服务器证书加密后交给服务器,之后的通信就全部使用这个对称的密钥来加密。当然,这里有两点需要和 HTTPS 有所区别,第一是证书的获取方式,HTTPS 中是由专门机构去验证证书合法性的,IM 的客户端肯定不会这么去做,为了防止获取证书的过程被人截获,然后篡改证书,可行的方式是直接在客户端安装包中直接把证书打进去,该证书可以随着客户端软件升级一起升级,也可以在加密连接之后通过协议升级。第二个问题是对称加密算法的选择,因为密钥的生命周期是跟随一次连接的,时间并不长,而移动 App 对于电量消耗非常敏感,因此加密算法应尽量选择较为简单的类型,例如 RC4。

心跳

心跳可以分为 TCP 的协议层心跳和 App 的应用层心跳。一般我们都使用应用层心跳,一来便于服务器扩展(比如哪天我们可以换成 UDP 来传),二则是可以更灵活控制心跳间隔。

心跳协议仅仅是用来连接保活,其内容应当尽量精简,除了包头中必要的部分,包体的可选包头都不存在。

对于不同的网络环境,心跳可以采用不同的时间间隔。在不同网络环境下,间隔的选择可以参考微信智能心跳方案

断线重连

客户端掉线的原因无非两种,客户端网络挂了,服务器挂了。客户端网络挂了也分两种,一种是本机就能感知到的网络连接断开,另一种是本机网络是好的,但互联网连接是不同的,对应到 Android API上,就是 NetworkInfo 的 isAvailable 和 isConnected。当然这个地方的 isConnected 不一定可靠,因为它是靠连制定服务器来确定的,那个服务器谁知道有没有问题。

掉线后,根据不同的状态需要选择不同的重连间隔。如果是本地网络出错,并不需要定时去重连,这时只需要监听网络状态,等到网络恢复后重连即可。如果网络变化非常频繁,特别是 App 处在后台运行时,对于重连也可以加上一定的频率控制,在保证一定消息实时性的同时,避免造成过多的电量消耗。

而如果掉线是因为本机网络连不通互联网,或者是服务器挂了,重连间隔的选择就非常重要了。
首先,如果程序是在前台,用户正在使用我们的 App,重连间隔应更加频繁,使得用户反馈更加及时,如果程序处于后台运行,则为了省电,可以适当延长重连间隔。
其次,随着重连次数的增加,说明服务器短时间内恢复的可能性逐渐降低,重连间隔也应随之延长(倍数增长)。但应该设置一个最大的重连间隔,当到达最大间隔时,不再增加。
第三,重连间隔的增加不应当是固定的,而应该增加一个随机退避策略。以免如果是服务器宕机造成掉线,所有客户端的重连时间点都是一样的,当服务器恢复后,同一个时间点所有客户端同时连接服务器,造成服务器不堪拥堵,再次宕机。活生生的例子请参考环信去年的宕机时间。
总结起来,重连间隔可表述如下:

1
2
3
4
5
6
7
8
int interval(int count) {
int maxCount = isForeground() ? MAX_COUNT_FOREGROUND : MAX_COUNT_BAKGROUND;
count = Math.min(count, maxCount);

int minInterval = BASE * 2^(count - 1);

return minInterval + new Random().nextInt(minInterval);
}

多媒体数据管理

IM 系统中另一个重头戏是多媒体数据。由于移动网络比较慢,流量又贵,在移动端针对这些问题必须要做一些处理。在上传时,尽量减少上传时间,在下载时,能让用户尽快看到内容。同时,尽量节省流量,减少不必要的流量消耗。

文本消息因为比较小,可以直接通过长连接传输。但对于多媒体文件,通过长连接来传输则不合适,长连接服务器不会对大文件传输做针对性优化,大量的多媒体文件数据会直接抢占其他信令消息和文本消息的贷款资源。因此,多媒体消息会通过另外的通道,到专门的文件服务器存取。

在下载时,对于不同的网络环境,可以采用不同的预取策略。在 WiFi 环境下,由于无需考虑流量问题,在收到消息后,我们就能立即把包含的多媒体文件下载下来。而在移动网络中,则应当等到用户真正看到该多媒体消息时,才去下载。

图片

上传时,现在手机摄像头的像素动辄上千万,一张图片随随便便就好几M,然而,通过 IM 软件传输的图片,通常对于质量要求并不会太高,如果我们直接将好几M的图片直接上传,往往费力又不讨好。在上传之前,将图片像素降低,并进行压缩,可以明显的减少上传转菊花的时间,减少用户流量消耗。如果用户确实要求图片质量,则提供一个原图选项。
如果是使用 http 上传,大文件会被分成多个数据块上传,前一个数据块传输成功后,再传输下一个。断线重传时,也是以数据块作为最小重传单元。针对不同的网络类型,数据块大小不同。在较好网络下(wifi/4g/3g),数据块可以比较大,这样可以减少交互时间,加快传输熟读,而在弱网环境,数据块应当设置的比较小,以降低传输失败概率,减少重传流量消耗。
使用 http 上传的另一个优化技术是使用 pipeline。在不使用 pipeline 时,上传一个数据块需要等到前一个数据块传输成功才行,数据通道是单工的。使用pipeline则可以将数据通道变为双工的,一个数据块传输完成后,不必等到回包,就能直接上传下一个数据包,能节省一次数据回包延时。

下载时,在消息展示区显示的通常只是一个很小的图片,这时候只需要下载对应大小的缩略图即可,无需下载原图。甚至,这里可以将比缩略图更小的图片二进制数据直接放到消息体中下发,并展示给用户一个高斯模糊后的效果图,在保证最低可用的情况下,减少用户等待时间,提高用户体验。

语音

对于不同的网络环境,采取不同质量的语音编码算法,在较好网络时,使用高质量语音,而在弱网环境下,则使用较低质量,优先保证可用性。
为了减少用户等待时间,还可以采取边录边传的策略。由于录音时间比较长,在录制的过程中,我们就可以将录好的部分先传到服务器,等到录音完成,只需要上传最后一个数据包,并告知服务器录音完成即可,基本上可以做到录完即传完,无需等待。

语音消息没有缩略图,因此语音下载基本就只能实打实的将原始文件下载下来。

文章目录
  1. 1. 客户端架构
    1. 1.1. 进程切分
    2. 1.2. 保活
  2. 2. 与服务器通信
    1. 2.1. 通信协议选择
    2. 2.2. 建立安全连接
    3. 2.3. 心跳
    4. 2.4. 断线重连
  3. 3. 多媒体数据管理
    1. 3.1. 图片
    2. 3.2. 语音