【编者按】58 同城 App 自 1.0 版本开始,便一直致力于自研 IM 系统。在这过程中,发现如何降低 IM 系统层次和页面间的耦合,减少 IM 系统的复杂性,是降低技术成本提高研发效率的关键。对此,本文作者对 iOS 客户端 IM 系统架构演变的过程以及经验进行了总结,希望能够给设计或改造优化 IM 模块的开发者提供一些参考。
对于 58 同城 App 这样以信息展示及交易为主体的平台而言,App 内的 IM 即时消息功能,相比电话和短信,在促成商品/服务交易上更有着举足轻重的地位。也正因如此,自 1.0 版本开始,便一直致力于自研 IM 系统。在自研过程中,我们发现如何降低 IM 系统层次和页面间的耦合,减少 IM 系统的复杂性,是降低技术成本提高研发效率的关键。
为此,本文将主要从两个方面阐述 58 同城 iOS 客户端 IM 系统架构的变迁过程。一是 IM 系统如何解除对数据库和 Socket 接口的依赖;二是 IM 聊天页面从传统的 MVC 模式走向面向协议的新型架构。希望给具有相似业务场景的开发者提供一些借鉴。
老版本 IM 系统遇到的问题
58 App 在项目早期就自研了 IM 系统,但只实现了文本消息、图片消息、音频等基本类型。虽然业务需求场景简单,却还是遇到了如下问题。
数据扩展性差
数据格式使用的是 Google ProtocolBuffer(以下简称 PB),是因为这种数据格式相比 XML 和 JSON 相同的数据形式,体积更小,解析更加迅速。但 PB 是用 C++实现的,使用起来相对繁琐。需要对不同的消息类型编写不同的 PB 数据结构,每种 PB 结构还需要单独的数据解析方法。由于 58 业务的发展,这种数据协议增加了系统的复杂性。
代码封装性差,研发成本高
在数据发送前,为了安全,还需要将待传输的数据通过特定的加密算法进行加密,再利用 AsyncSocket 做数据传输。相对应的,每接到一种消息类型,就需要解密,将 PB 格式转换成对象模型。这种方案,每次新增消息类型时都比较痛苦,要写加密算法,写 PB 模型解析器。这样不仅代码的扩展性很差,开发难度也比较大。
代码耦合性强
每次如果有新增消息类型,要在 DB 层写个接口对新消息的数据解析并存储。同样,在 Socket 传输层也要新增收发接口与之对应。这种设计方式,开发过程中耦合性很大。
代码可读性差
App 内只有一种消息类型,叫 WBMessagModel。在消息类型判断上,是通过 WBMessageModel 里的特定字段来进行区分的,比如根据 mtype、isOnlineTip、m_msgtype 等字段判断,方式相对混乱。
为了解决上面的问题,打造一款低耦合、可扩展性强的 IM 系统,我们决定重构。
新版本的 IM 系统
框架演进
老的 IM 系统由于代码耦合性严重,一旦遇到问题难于追查。并且扩展性差,每个版本的需求研发,都从底层修改到业务层,影响研发进度。结合之前 IM 开发过程中遇到的问题,新的 IM 系统亟需解决如下问题。
- 简化调用流程
业务开发过程中做到与“底层 DB+数据加密+数据加密+数据传输”的分离,通过调用底层接口就可以做到收发、存储消息。
- 设计低耦合的中间层接口
中间层接口要做到承上启下对接,业务层和底层接口无任何耦合。如果做到这些,以后在 IM 底层升级甚至更换时,只需调整业务接口与底层接口的重新对接,让顶层的业务无感知,做到无感知的迭代。
- 设计单一职能的模型和接口
在具体业务层处理上,要做到模型分离,设计统一。模型上,将之前的只有一个 IM 模型根据各自的类型拆分。接口上,通过底层、中间层业务层的结构划分,每层接口各司其职。
- 可扩展性强
利用面向协议方式抽象和组织代码,做到按照协议新增消息。利用 UITableView 的类别做到现有及新增的消息类型 Cell 能够自动计算高度。通过这种业务上的设计方式,能够快速定位问题。如有新增的消息类型,只需关注新增的消息模型和与之对应的消息界面即可,完全无需关注视图的填充时机以及如何计算视图的高度等。确定了这些设计原则,才能保证在业务研发过程中做到快速迭代,进而满足日益增长的用户需求。
基于上面的目标,重构后的 IM 整体架构图 1 所示。
新的 IM 系统整体架构包含底层、接口服务层、业务层三个部分。底层主要进行数据收发、存储等相关处理,并抽象出通用底层接口,与接口服务层交互。接口服务层主要负责合理地将底层的数据传递到业务层,同样,业务层的数据能够通过接口服务层传递给底层。清晰明了的接口服务层不仅可以让业务层处理数据变得更简单,还能极大地降低业务层和底层的耦合。业务层主要针对具体需求场景,如何合理使用数据进行视图的展示。基于这样的设计,下面详细介绍一下各个层次之间的具体实现。
设计调用流程简洁的底层接口
新的 IM 底层采用了全新的设计思路,如图 2 所示。在底层,为了数据的可扩展性,放弃了之前 PB 的数据协议,而是采用传统的 JSON 格式作为 Socket 端数据的收发协议。
在消息模型上,摒弃了之前只有一种消息模型的策略,而是根据消息类型划分出文本消息模型、图片消息模型等基本消息模型。
58 App 将 DB 和 Socket 的内部处理封装成 SDK,对外只暴露 IMClient 底层接口。顶层所有消息相关的事件都是和底层 IMClient 的接口交互,内部流程完全不用关心。这样业务层完全感知不到数据是如何收发和存储的,极大地简化了接入和使用成本。
但是读者也许会有疑问,IM SDK 里内置了如此多的类型消息,那以后有新增 SDK 里没有的消息类型该怎么办?为了解决这个问题,58 App 采用了一种和 iOS 自定义对象归解档相似的策略——任意定义一种新的消息,只要它继承自基础的消息类型,并遵循 IMMessageCoding 协议。这个协议里定义了 encode 和 decode 方法,其中,encode 方法用于将新类型消息里的数据存储到数据库中(当然,这个过程并不需要上层开发者关注,他们只需在这个函数里返回待存储的数据即可);decode 方法用于将数据库中的数据恢复成相应的消息模型。现在,我们有了消息类型的定义方式,又如何使用呢?为了让底层能够感知到自定义的消息类型,需要在统一接口层 IMClient 初始化之后,立即注册给它,注册后 IM 底层就知道当前的消息类型,并且明白如何存储和恢复数据。基于这种设计方式,目前 58 App 的 IM 底层可以任意扩展其他消息类型,而底层的代码完全不用修改。
底层代码不仅有良好的扩展性,并且在设计时还为一些基础的场景提供了很多协议。这些协议都是可动态定制或移除的。例如,当联系人列表发生变化时,需要修改联系人头像,就可以订制底层IMClientConversationListUpdateDelegate
协议。使用时,业务方通过注册协议addUpdateConversationListDelegate:
,当监听到联系人更新回掉后,执行头像更新操作。当不需要时,可通过removeUpdateConversationListDelegate:
方式,解除监听。类似的场景还有消息接收协议、在线状态变化协议等。通过这种方式,就可灵活配置业务代码对 IM 的某些状态变化的监听。
目前,通过对底层代码的抽象,提供顶层接口与内部数据处理分离,且很多 IM 服务都可定制化实现,由此就做到了和具体业务无耦合。通过这样的底层设计,完全可以作为基础的 IM SDK,给其他 App 使用,快速集成 IM 功能。
设计低耦合、职责单一的中间层接口
为了业务层和底层能够通信,并且互不耦合,我们创建了中间接口层用以承上启下。根据实际的业务场景,中间接口层分了三种情况,即为登录相关的接口、消息收发相关的接口以及消息查询相关接口,分别和底层统一接口对接。通过业务场景的划分,开发过程中可以快速定位相关业务对应的模块。对于底层提供的消息模型,并没有直接使用,究其原因是底层的消息模型完全不关心视图展示属性,比如行高、重用标识等属性(下节会详细介绍)。而 MVVM 中 VM 部分属性需要和视图关联,因此将底层的消息模型转换成了聊天 Cell 直接可用的消息模型。通过这样的业务接口划分和消息模型的转换,即使之后底层统一接口或消息模型发生变化,只要做好中间接口的重新对接和消息模型的重新转换,顶层业务就完全感知不到下面的变化。
设计可扩展性强的业务层
由于老的 IM 系统项目是早期搭建的,处理的业务场景简单,扩展性不足。例如所有消息都使用同一个数据模型,就会造成随着业务场景的扩展,模型的代码体积越来越大,使用时好多属性冗余不堪。在设计上,老架构使用了 MVC 设计模式,由于在聊天场景下,VC 要处理的聊天视图类型较多,VC 内部十分臃肿。因为之前架构的局限性,这就对新的 IM 业务架构提出了要求,怎样设计出低耦合、扩展性强的业务层?接下来介绍一下具体的实现方案。
拆分 IM 消息模型:明确了上面的问题,现在 58 App 把之前只有一个消息模型,拆分成了文本、图片、语音、提醒、音频、视频等消息模型,它们统一继承基类消息的模型,基类消息模型存储了 IM 所需的必要数据,如聊天用户的信息、消息发送的状态等。
使用 MVVM 架构:为了降低 VC 和各个聊天视图之间的耦合。VC 管理各种消息模型,消息模型中存储视图展示时需要的数据。在消息视图和消息模型之间,实现了双向数据绑定。实现的方式是在聊天视图里存储与之对应的消息模型,这样当聊天视图变化并需要消息模型做数据更新时,直接对消息模型赋值即可。当聊天视图要根据消息模型属性变化而变化时,则通过 KVO 的方式实现这一功能。例如在 IM 场景中,我们发送一条消息,消息模型中的发送状态是发送中,当发送状态变化时(如发送成功或失败),聊天视图就可以根据改变后的值进行更新;
使用面向协议组织 IM 模型和视图:通过面向协议的方式,组织 IM 模型和视图,可以增强 IM 消息模型和视图的扩展性。下文会结合具体的技术细节,阐述面向协议的设计在 58 IM 系统中的重要作用。
技术细节
聊天列表页技术细节
由于 IM 模块的特点,伴随着业务需求的发展,IM 的类型会越来越多。为了避免在研发过程中每次都要花费很多精力计算 UITableView 中 Cell 的高度,为此我们在 App 内利用 XIB 创建不同的 Cell,并使用 AutoLayout 的方式给 Cell 中的视图布局。当然,你也可以通过手写代码的方式,然后利用 AutoLayout 布局。而 App 在 IM 中利用 XIB 布局,目的是为了让视图的布局更直观地展示,以及更好地让视图部分和 VC 分离。当 Cell 中所有布局合理完成后,就可以通过调用系统的 systemLayoutSizeFittingSize:方法,获得 Cell 的高度。基于这种思路,58 App 内部给 UITabelView 增加了自动计算 Cell 高度的能力,代码如下:
#import <uikit uikit.h="">
#import "WBAutoCalculateTableViewDelegate.h"
@interface NSObject (WBAutoCalculateTableView)
@property (nonatomic,assign) CGFloat kid_height;
@end
@interface UITableView(WBAutoCalculateTableView)
- (CGFloat)heightForRowWithReuseIdentifier:(NSString *indentifiercellEntity:(NSObject *)cellEntity;
@end</uikit>
首先我们给 NSObject 增加了类别,并在类别里添加了 kid_height 属性,目的是在计算完 Cell 的高度后,将其缓存好。这样下次重新加载 UITableView 时,就直接返回缓存过的高度。
其次,我们给 UITableView 添加了类别。利用heightForRowWithReuseIndentifier: cellEntity:
这个 API,在传入当前消息 Cell 的重用标识和当前的消息模型后,就返回当前 Cell 的高度。而调用者完全不用关心高度计算细节,计算完成后,立即将高度利用 NSObject 的类别属性缓存在消息模型中。
为了解决不同类型的消息 Cell 填充数据方式不一致的问题,我们引入了如下协议:
#import <foundation foundation.h="">
@protocol WBCellConfigDelegate<nsobject>
@required
- (void)setModel:(id)cellEntity;
@end</nsobject></foundation>
如此,让 UITableView 中所有的消息 Cell 都遵循此协议,此协议规范了不同的消息 Cell 之间填充数据的统一性。不同的消息 Cell 使用不同类型的消息模型, 但却可以使用相同的填充规范。
@protocol WBAutoCalculateCellViewModelProtocol <nsobject>
@required
- (NSString *)cellReuseIndentifier;
- (void)registerCellForTableView:(UITableView *)tableView;
@optional
- (CGFloat)cellHeight;
@end</nsobject>
为了解决消息视图在即将展示时,还要根据当前的消息类型,去判断该使用哪种视图的模板,58 App 采用让每个消息模型遵循上面的协议,每个消息模型都存储与之对应的重用标识。因为 Cell 的注册方式有多种,如通过类注册或 Nib 注册,这里设计成灵活的接口,注册 Cell 方式完全交由开发者决定。
下面的可选协议,在此还要着重在介绍一下- (CGFloat)cellHeight
。这个协议是这样的,虽然大部分场景能够自动计算某个 Cell 的高度,但有些消息类型的高度是固定的,根本无需计算。为了解决这个问题,我们给消息模型增加了可选的 cellHeight 协议,如果消息模型实现这个协议,则 Cell 的高度就不自动计算了,通过此方法的返回值决定。
做项目有时就像搭积木一样,通过上面的介绍,我们已经有了很多小的解决方案,就像有了很多积木零件,如何将这些方案组织在一起,下面到了将这些“积木”组装到一起的时候了。因为我们是通过 UITableView 组织和管理聊天页面视图的,而tableView:heightForRowAtIndexPath:
是其重要的代理方法,目前实现如下:
#pragma mark UITableViewDelegate
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
CGFloat cellHeight = 0;
id <wbautocalculatecellviewmodelprotocol>cellEntity = self.viewModel.dataSource[indexPath.row];
//向 tableview 中注册 cell 通过 cellindentifier
if ([cellEntity conformsToProtocol:@protocol(WBAutoCalculateCellViewModelProtocol)]) {
if(!self.tableViewRegisters[[cellEntity cellReuseIndentifier]]) {
[cellEntity registerCellForTableView:tableView];
self.tableViewRegisters[[cellEntity cellReuseIndentifier]] = @(1);
}
}
if ([cellEntity respondsToSelector:@selector(cellHeight)]) {
cellHeight = [cellEntity cellHeight];
}else{
cellHeight = [tableView heightForRowWithReuseIdentifier:[cellEntitycellReuseIndentifier] cellEntity:(NSObject *)cellEntity];
}
return cellHeight;
}</wbautocalculatecellviewmodelprotocol>
在这个方法中,我们看到了每个cellEntity
(消息模型),都遵循了上面介绍的 WBAutoCalculateCellViewModelProtocol。在此方法里,让每个消息模型去注册自己的 Cell 类型,然后计算 Cell 的高度,如果消息模型有 cellHeight 方法,则通过此方法计算高度,否则通过上面提到的自动算高的方式,返回 Cell 的高度。
在 Cell 的展示处理上,UITableView 的数据源方法tableview:cellForRowAtIndexPath:
是核心的方法,目前实现如下:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
id <wbautocalculatecellviewmodelprotocol>model = self.viewModel.dataSource[indexPath.row];
NSString *cellIndentifier = [model cellReuseIndentifier];
UITableViewCell<wbcellconfigdelegate> *cell = [tableView dequeueReusableCellWithIdentifier:cellIndentifier];
if (cell &&[cellconformsToProtocol:@protocol(WBCellConfigDelegate)]) {
[cell setModel:model];
}
if (!cell) {
cell = (UITableViewCell<wbcellconfigdelegate> *)[[UITableViewCell alloc]init];
}
return cell;
}</wbcellconfigdelegate></wbcellconfigdelegate></wbautocalculatecellviewmodelprotocol>
通过消息模型找出重用标识。因为已经在tableView:heightForRowAtIndexPath:
时注册过了 Cell,所有通过重用队列一定能返回该消息类型下的 Cell。而消息 Cell 都遵循WBCellConfigDelegate
协议,使得数据在填充时具有统一的方式。
通过面向协议的设计方式,我们在 VC 里 tableView 的代理和数据源方法就变得如此简单。而且以后如果在扩充新的消息类型时,继续遵循相应的协议,VC 里的代码是一行都不用修改的,开发人员只要关和注新增的消息模型和视图即可。
处理离线 Voip 消息的技术细节
实际开发过程中,我们遇到了一个问题,当 B 不在线时,B 的聊天对象可能向 B 发起音视频消息,服务器为了信令消息的完备性,会建立一个队列,将所有向 B 发消息的信令记录下来。过了一段时间,当 B 登录时,Server 会把 B 离线期间所有的通话信令发过来。由于刚开始设计时没有考虑到这一点,造成一个问题就是当 B 启动时,A 发送了一个视频消息过来时,B 接受到第一个视频信令是离线期间的视频消息信令(如果有)。这就造成了 B 尝试连接一个早已不存在的视频通道,而让 A-B 视频聊天连接不上。客户端为了也支持这种信令序列,利用条件锁技术有序地处理视频连接信令,如图 4 所示。
具体解决方案如下:
首先,我们创建一个 Concurrent Queue,当有信令信号传给客户端时,就放在 Concurrent Queue 里执行;
为了保证 Voip 信令能有序执行,我们引入了条件锁 NSCondition, 并行队列在处理 Voip 信号时,先获取条件锁,获取完毕后,我们将 isAvLockActive Bool 变量标记为 YES,然后对信号进行初步处理,初步处理完毕后 Unlock 条件锁;
由于 Unlock 了条件锁,队列里其他的 Voip 信令就有了处理的机会。处理时,检测 isAvLockActive 状态,如果为 YES,说明此前有 Voip 信令还没有处理完毕,则执行条件锁的 wait 方法;
当某个 Voip 信号事件完全处理完毕后,会触发条件锁 Signal, 这时,队列里其他等待条件锁的信号就可以得到处理。这时我们又返回步骤 2,直至队列里没有待处理的 Voip 信号。
总结
这次 IM 系统重构,通过底层接口分离使得 IM SDK 耦合性降低,利用面向协议设计方式使得聊天页面可扩展性增强,所以短时间内 App 内部扩展了富文本、图片、地理位置、简历、卡片等类型消息。希望通过 58 App IM 的重构历程,能给设计或改造优化 IM 模块的开发者提供一些参考。未来,我们会在如何提高页面性能和降低用户流量上进一步调优,继续完善 IM 的各个细节。
- 作者: 蒋演,58 同城 iOS 高级研发工程师,专注于 App IM 系统的架构研发以及性能优化,主导了 58 同城 App 的 IM 系统架构以及研发。
- 责编: 唐门教主(tangxy@csdn.net)
- 声明:本文为 CSDN《程序员》原创文章,未经许可,请勿转载,如需转载,请留言。