IM的客户端之旅
[TOC]
引言
短短的一段消息、一句语音,就能实时的建立联系牵挂,远隔天边的彼此不但可以千里共婵娟,
还能互相诉说心里话~
IM是公司必不可少的基础技术设施,真正做了社交才知道,拥有一套自己的IM技术有多么的重要。市面上三方的IM不单有成本问题,而且还会有很多的限制,使用起来捉襟见肘,无法让产品同学放飞需求。
本文会以客户端角度,一步一步还原开发IM系统的历程,重点讲解设计和实现思路,并且把遇到的问题和难点一丝一缕的剖析出来。满满干货,希望读者能有所收获。
架构设计
万事开头难,很多事情,当迈出第一步的时候,最困难的部分就已经完成了。
第一步,不着急写代码,先设计出整体的架构
如图1,IM系统
中分为四大模块。
- 服务端提供消息、推送等服务;
- IM基础业务模块通过推拉结合方式,利用长连接和HTTP请求获取云端消息(考虑到大量推送问题添加消息缓冲区);
- IM数据处理模块拿到云端消息后会和本地消息合并来更新数据库和缓存;
- IM渲染模块中的适配器会根据不同的消息类型渲染到页面上;
客户端架构
IM系统是通用的,为了让客户端IM架构可以兼容任意的社交项目,做架构设计时需要考虑其通用性、扩展性和易用性。
- 长连接和网络等基础服务用来支持
IM系统
。 - 数据模块和渲染模块是通用的,抽出
IM-SDK
,对外暴露api协议。 - 桥接协议负责将业务逻辑、基础服务和
IM-SDK
串联起来。
如图2,IM-SDK
很纯粹,对现有APP侵入很低,通过桥接协议可以兼容各种业务场景。整体流程以收发消息为例,有更直观的体现:
如图3,整个消息收发流程很明确了,基础服务模块在成熟的应用项目中大都是健全的,我们本文的重点要讲的就是业务逻辑和IM-SDK
。而这其中最重要的是数据模块了,先看IMDataSDK
是怎样的设计。
IM数据模块
IMDataSDK
总共分了三层,存储层、逻辑层和协议层,每层都有自己子模块,每个子模块的功能如下:
- API:对外暴露的接口,符合单一职责原则和迪米特法则
- Callbacks:给业务方的回调
- NetAbstract:
IMDataSDK
是纯粹的数据处理层,不涉及到网络和长连接,所以将网络层抽象出来,供业务方实现 - Entity Param:实体类、协议接口和请求参数封装
- DataProvider:数据提供者,业务方调用API,
DataProvider
将处理好的消息或会话通过Callback
返回给业务方 - DataController:数据处理者,比如删除一条会话
- MsgBlockManager:消息区间管理,用来做消息漫游同步的,文章后面有一章节会讲到
- MergeHelper:同步本地和云端数据的工具类
- SortHelper:会话和消息的排序工具类
- DaoManager CacheManager:管理数据库和内存缓存
IMDataSdk
中对外暴露的仅仅是 API
、Callbacks
、entity param
和NetAbstract
,具体的数据操作对上层来说是无感知的,而且IMDataSdk
没有网络和长连接部分,那在完整的收发消息流程中,如何将与服务端相关的操作单元插入进来呢,这就涉及到了链式操作和观察者模式。
数据库设计
数据库设计在大部分系统中都是比较重要的部分,在IM系统中亦是如此。
数据库设计有以下几项需要注意:
- 一人一库,根据uid来创建不同的db路径
- 数据库升级后的数据迁移
- 数据库存储空间清理
- 数据库加密
- 合适的索引字段(索引查询基本上都是毫秒级)
Android和IOS都是SQLite数据库,客户端有很多ORM的库,IOS推荐
FFDB
,Android推荐GreedDao
,都支持数据迁移和加密。
SQLite数据库存储类型
- INTEGER – 整数,对应Java的byte、short、int 和long。
- REAL – 小数,对应Java的float 和double。
- TEXT – 字串,对应Java的String。
表设计阶段,我们做了充分调研,并研究了各种场景可供其兼容(群聊、撤回、已读、送礼等),反复推敲出来,当然没有{尽善尽美|wo zui niu bi}的设计,还是有优化空间的。
会话表
字段名 | 类型 | 备注 |
---|---|---|
id | long | 客户端自增id |
peer_id | long | 会话id |
peer_type | int | 会话类型 |
peer_info | String | 当前对话业务信息json字符串 |
version_id | long | 会话版本id,增量更新 |
unread_count | int | 未读数 |
last_msg_id | long | 会话最后一条消息id |
last_msg_info | String | 会话最后一条消息的内容 |
status | String | 会话状态 |
sort_key | long | 自定义排序 |
extra | String | 扩展字段 |
create_time | long | 会话创建时间 |
update_time | long | 会话更新时间 |
- 会话列表通常会展示最后一条消息的内容,所以设计了
last_msg
相关字段 -
sort_key
可用来自定义排序,比如置顶 -
version_id
是用来更新会话列表的,后文会详细讲到 -
extra
为业务扩展字段
消息表
字段名 | 类型 | 备注 |
---|---|---|
id | long | 客户端自增id |
peer_id | long | 会话id |
msg_id | long | 消息id |
type | int | 消息类型 |
content | String | 消息体 json字符串 |
version_id | long | 消息版本id,用于更新消息(撤回) |
has_read | int | 已读未读 |
is_send | int | 是否为消息发送者 |
seq_id | long | 本地生成的id,服务会透传下发,用于本地消息的更新 |
owner_id | long | 消息发送者的id |
msg_error | String | 错误信息 |
status | String | 消息状态(发送中、发送成功、发送失败、撤回等) |
sort_key | long | 自定义排序 |
extra | String | 扩展字段 |
create_time | long | 消息创建时间 |
update_time | long | 消息更新时间 |
msg_id
是递增非连续的,例如1001,1003,1005,1006,...,1010 ,消息更新时,msg_id不变,version_id
更新
因为服务是分布式的,会存在失败,如果++i去生成msg_id的话,也可能失败,
所以无法保证msg_id连续。目前的msg_id根据系统时间进行自增。
消息区间表
字段名 | 类型 | 备注 |
---|---|---|
id | long | 客户端自增id |
peer_id | long | 会话id |
blocks_content | string | 会话对应的消息区间,json字符串 |
blocks_content
的json格式为peer_id
对应的一组消息区间,用来同步云端消息的,文章下一节就会讲到。
支持群聊和群已读消息,需扩展群成员表和群消息表,后面的章节有讲述
消息漫游
消息漫游指云端的消息全部同步到本地上来,是IMDataSDK中很重要的一部分
关键问题和解决方案
消息漫游实现起来并不简单。本地维护的消息,云端也维护了一份,所以何时取本地,何时取云端的,这是关键问题。
场景:用户会间断的打开会话页面或者是用另外的设备登录,会导致存到本地的消息也是间断非连续的。当需要获取历史消息时,应尽量拉本地数据库的消息,若没有才会拉云端消息。
解决方案一:游标记录
服务端每个会话会记录一个客户端请求的时间点最靠前且能连续的历史消息msg_id
,客户端会将此msg_id
同步下来,如果小于此msg_id
则取本地,大于则取云端消息。
优点
- 客户端逻辑简单
缺点
- 服务需每个会话额外维护一个游标
- 不能充分利用客户端的本地数据
解决方案二:消息区块
消息区块:会话作为一个块,块中存储多个消息区间,这些消息区间是存到本地数据库的连续消息。消息区间其实就是一个
msg_id
的最小值和一个msg_id
的最大值。
消息区块方案是最终的解决方案,此方案是按照已有的消息协定而设计,先看看消息协定有哪些
-
msg_id
是全局唯一且递增的,但不连续 -
msg_id
不可变 - 云端返回的消息是连续的,例:获取历史消息会返回1009···1021这段消息
- 云端返回的消息都会存储到数据库里
- UI上展示的是连续消息
- UI上消息列表最前面的那条消息就是即将拉取的历史消息的前一条
再看一下关键问题:何时取本地的,何时取云端的。再看下关键解法:尽量拉本地数据库的消息,若没有才会拉云端消息。结合消息协定,不难推出,客户端是知道哪些消息存到本地了,如何知道呢,就此引申出了消息区块。将本地数据库的消息存到消息区块中,即将拉取的历史消息的前一条如果在消息区间里,则取本地,若无则拉服务。具体存储细节请看前面章节的消息区间表。
拉取到了服务后,消息区间还有合并的过程,图示如下:
如图5所示,聊天存在消息间隙,当下拉滑动获取历史消息会将消息间隙补上,每次从服务获取到消息并插入数据库时会触发merge操作,如果新插入的消息集合与已有的消息区块存在交集会进行区间合并操作,如果没有就创建新的区间。
比如已有的消息区间[40,60],[70,80],新插入的消息集合是[59,70],59在[40,60]区间,70在[70,80]区间,三者存在交集,那么[40,60],[70,80],[59,70]三个区间会合并为一个区间[40,80]。
区间合并算法
private List merge(List intervals) {
List result = new ArrayList<>();
if (intervals == null || intervals.size() < 1) {
return result;
}
//将区间集合以min_msg_id从小到大排序
sortBlock(intervals);
// 排序后,后一个元素(记为next)的start一定是不小于前一个(记为prev)start的,
// 对于新加入的区间,假设next.start大于prev.end就说明这两个区间是分开的,要添
// 加一个新的区间。否则说明next.start在[prev.start, prev.end]内。则仅仅要看
// next.end是否是大于prev.end,假设大于就要合并区间(扩大)
MessageBlock prev = null;
for (MessageBlock item : intervals) {
if (prev == null || prev.max_msg_id < item.min_msg_id) {
result.add(item);
prev = item;
} else if (prev.max_msg_id < item.max_msg_id) {
prev.max_msg_id = item.max_msg_id;
}
}
return result;
}
优点
- 充分利用客户端的本地数据
- 服务端无逻辑,只管存储消息
缺点
- 客户端额外维护一张表
消息泛化
消息泛化是指聊天支持多种消息类型,不限于文本、语音、图片、视频、礼物、其他自定义消息等。
消息泛化对IMDataSDK
来说没有感知,仅仅在消息表中的type作为区分,真正的渲染是在IMUISDK
。
如上图所示,消息列表渲染一条消息时,通过适配器根据type
取不同的ViewHolder
,而ViewHolder
是由工厂类提供。
IMUi-SDK
内置几个通用的消息view,支持语音、图片和文字,业务方可以通过注册方式扩展自定义消息view,符合开闭原则。
前面的几个章节讲述的是IM-SDK最基本的框架,是IM的基石,有了这些,IM系统的上层建筑才能越建越高。
IM场景问题解决
IM场景问题解决是不就技术团队在社交项目中的实战经验,本节特举几个经典问题,带读者身临社交前线,直面实战中遇到的种种问题
消息的实时性和完整性
实时聊天对消息的实时性和完整性要求很高。第一时间能收到对方消息,并且保证消息展示是连续的,不能中间丢消息,这是最基本的要求。
实时性
客户端与服务端实时通讯方案
- 方案一:客户端定时轮询请求服务,有新的消息就下发
- 方案二:长轮询,客户端发起请求,服务挂起,客户端收到数据返回再发起请求
- 方案三:客户端与服务端建立长连接通信,通讯可由服务发起,客户端接收
定时轮询和长轮询的方式缺点很明显,并发性差、浪费资源、通讯要由客户端发起。建立长连接是更好的选择。长连接技术要点很多,比如传输可靠性、保活、粘包问题等,本文不做深入探究。
Android各家厂商,像小米、华为等都会提供厂商推送,在app被杀死的情况下,也能收到消息。为了提高消息的到达率,可接入各厂家的推送服务。
完整性
长连接可靠性做的再好,也会有丢消息的情况,无法完全做到百分百的到达率。为了保证消息不丢失,采用了推拉结合的方案。
数据库设计章节有提到,会话表和消息表都有version_id
字段,客户端收到新消息通知后,拿本地最新的version_id
作为参数请求服务,服务会返回比请求的version_id
更新的数据,以此来完成增量更新。version_id
也是递增的,可作为排序条件。
除了推送更新消息以外,客户端还有其他时机同步服务最新消息,如打开消息页面直接同步最新消息、会话页面重新可见时能够同步。
长连接推送下来的消息内容很简单,只有最基本的信息,包括会话id、消息id和未读数。
新消息推送缓冲
消息的完整性通过推拉结合方式来解决,但也存在问题。假设在某一时刻收到了很多新消息推送(群聊场景),收到一条推送就拉一次的话,势必导致多次网络请求,但同一时刻一次请求就能拉到最新消息了,所以需要做推送缓冲和过滤,减少不必要的网络请求。
- 过滤:只关心本次会话的推送,其他会话的推送放弃同步,只更新未读数。
- 缓冲:维护当前会话推送缓冲区间,如果缓冲区间有待请求的推送,则不存入推送缓冲区。。
消息状态
前面两个小节都是讲的收消息的问题,而消息状态则是关乎发送端的。
消息状态有很多,已撤销、已读、发送中、发送失败等。消息表中的status有发送中、发送成功、发送失败等几个状态。已读由has_read
字段控制,后面会讲到。
每条消息都有唯一的
msg_id
,由服务生成,消息的排序通常也是按照msg_id
作为第一条件。
消息如果发送失败,会面临几个问题
- msg_id如何生成
- 如何排序
- 重新发送如何再次定位到此条消息
既然msg_id
客户端无法生成,可以先用已有的msg_id
(取最新的那一条msg_id
,刚好可以解决排序问题)建立一条消息存到数据库,此条临时本地消息需要根据时间戳和uid组合生成seq_id
(作为唯一id,服务会透传给客户端),而后将消息发到服务端,此时消息是发送中状态,如果发送成功,则通过seq_id
更新消息将msg_id
替换为服务下发的,并且将消息状态改为成功,如果发送失败则消息状态为失败,点击可再次发送。
IM进阶
IM最基本的功能构思与实现均已讲述,但是与成熟的IM系统还有差距,比如支持消息撤回、已读和群聊等。
本章节内容不代表IM最优解,且部分构思和想法处于实验阶段,如果读者有更好的技术方案,欢迎指出。
撤回和已读
撤回和已读也是消息的一种状态,如何在恰当的时机更新消息的状态是关键问题。
前文有提到以下两点
- 消息有变化时,
msg_id不
变,version_id
更新。 - 客户端收到新消息通知后,拿本地最新的
version_id
作为参数请求服务,服务会返回比version_id
更新的的数据
有了这些基础,就可以实现撤回和已读了。
- 用户A撤回消息和消息已读(消息在页面可见)时都上报消息服务
- 服务将此条消息状态更新,且
version_id
更新 - 服务推送新消息通知给用户B
- 用户B拿本地
version_id
请求服务,得到状态更新后的消息后,根据msg_id
和seq_id
(仅msg_id
可能重复)找到本地消息并更新
钉钉的群消息已读稍微复杂,需要将群中每个人的已读未读状态记录下来,群聊章节会讲到。
群聊
群聊的场景读者大都比较熟悉,那如何在IM基础框架上扩展呢,有几个关键问题
- 会话表兼容群聊
- 群成员信息的更新
- 群消息兼容
会话表兼容群聊
会话表中的peer_id
是会话的唯一id,设计为long类型,也就是int64
peer_id
的生成一般是根据对话方用户的id来生成。
为了方便查询,本IM系统的peer_id
直接为对话方用户的id,也保证了会话id的唯一性。但是也有一定的缺陷,如果是群聊会话是没有用户id的。为了区分普通单聊和群聊,服务只在int64后小半部分开辟一块空间用来生成群id,其他取值范围用来生成用户id,这样既可方便区分群聊和单聊,又能直接将群id作为会话id.
会话id搞定,其他的字段保持统一即可,peer_type
可新增群聊类型,peer_info
可存储群信息。如此会话表就可兼容群聊了。
如果为了追求极致,可以采用TEXT类型存储会话id,前面添加前缀,如"normal"、"group" 区分即可。
群成员信息持久化存储和更新
在群聊场景中,消息发送、消息接收、消息漫游和消息泛化等功能的实现和普通聊天一样。单聊中只有一个聊天对象,用户信息(头像昵称等)存到会话表中的peer_info
中,更新很简单,进入聊天时更新用户信息即可。但是群成员很多,怎么解决群成员的持久化存储和更新的问题呢?
本IM系统中在数据库建了一张群成员表来解决持久化存储问题。
群成员表
字段名 | 类型 | 备注 |
---|---|---|
id | long | 客户端自增id |
peer_id | long | 会话id |
uid | long | 用户id |
user_info | string | 群用户资料的json字符串 |
群成员的更新
当浏览本条消息的用户不在群成员表中时,即向服务请求用户的信息,存到表中即可;若群成员表中有用户信息,则直接取表内信息用来展示昵称、头像和群等级等。
为了更快地更新界面,可以添加缓存。
如图11,打开群聊天页面后,先拉取首屏消息,首屏消息中展示的成员中如果已经在群成员表中了,则存储到成员信息已完成的缓存中,并刷新消息列表。不在群成员表中的成员则存储到成员信息未完成的缓存中,同时批量请求成员信息,请求成功后将这一批成员从成员信息未完成的缓存移除,并且添加到成员信息已完成的缓存中,而后刷新消息列表并添加到数据库,
更新时机方案
方案一:
请求加入群成员信息时,开启异步任务,任务目标:异步更新群成员表。
此方案要求业务架构有统一的群成员信息管理模块。
缺点:不是实时更新。
方案二:
打开群聊页面同时开启异步任务,任务目标:批量请求屏幕可见群成员信息,并更新群成员表。
方案三:
服务返回成员的群消息中,添加字段:群用户信息是否有更新。当端上检测到有更新时则请求用户信息并更新群成员表。
此方案要求服务记录每个成员对应所有群成员的用户信息版本。
优点:实时更新。
以上三个方案均可实现,其中方案一最为简单易用,开销也小。
群消息兼容
群消息如果没有已读等特殊需要,普通消息表就能满足。
本IM系统设计了两张消息表,群消息表和普通消息表,目的有三:
- 将群消息和普通消息区分
- 群消息特殊字段
- 分表减轻消息表压力
群消息表
字段名 | 类型 | 备注 |
---|---|---|
id | long | 客户端自增id |
peer_id | long | 会话id |
msg_id | long | 消息id |
... | ... | 与普通消息表一致 |
read_uids | String | 本消息已读成员id的json数组字符串 |
群消息已读
群消息已读场景并不常见,本文提供一个实现思路。
由群消息表可知,表中添加了一个read_uids
字段,其存储的是已读成员id的json数组字符串。由此就可以得出,此条消息谁已读和未读了。更新时机同前文讲到的消息已读。
未来
本文讲的IM也存在一些不足,例如:IM-SDK
很纯粹,也导致了很多业务逻辑由上层来实现。若将业务逻辑中不变的部分抽成组件,可变的部分提供可扩展的接口,那么上层实现会更简单。
技术规划
- 业务逻辑部分抽成组件
- 优化IMDataSDK架构,层次更清晰
- 优化数据库,提高增删查改效率
- 区间合并优化,智能合并连续消息
- 群成员信息同步更实时
- 开发flutter版本的IM
最后
一个优秀IM系统远远比本文讲的复杂,没有讲到的部分有很多,比如图片视频的存储、数据库操作效率分析等。本篇通文讲的大都是设计和实现思路,具体的代码细节没有涉及,后续会有代码细节实现的相关文章。请关注映客技术公众号,会有更多优秀的技术文章公布。
美好的故事总有结局,彼此的牵挂却长在心间