Quantcast
Channel: CSDN博客移动开发推荐文章
Viewing all articles
Browse latest Browse all 5930

58 同城 iOS 客户端 IM 系统演变历程

$
0
0

【编者按】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 所示。

图 1 新版 IM 架构设计

新的 IM 系统整体架构包含底层、接口服务层、业务层三个部分。底层主要进行数据收发、存储等相关处理,并抽象出通用底层接口,与接口服务层交互。接口服务层主要负责合理地将底层的数据传递到业务层,同样,业务层的数据能够通过接口服务层传递给底层。清晰明了的接口服务层不仅可以让业务层处理数据变得更简单,还能极大地降低业务层和底层的耦合。业务层主要针对具体需求场景,如何合理使用数据进行视图的展示。基于这样的设计,下面详细介绍一下各个层次之间的具体实现。

设计调用流程简洁的底层接口

新的 IM 底层采用了全新的设计思路,如图 2 所示。在底层,为了数据的可扩展性,放弃了之前 PB 的数据协议,而是采用传统的 JSON 格式作为 Socket 端数据的收发协议。

图 2 底层架构设计

在消息模型上,摒弃了之前只有一种消息模型的策略,而是根据消息类型划分出文本消息模型、图片消息模型等基本消息模型。

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 里的代码是一行都不用修改的,开发人员只要关和注新增的消息模型和视图即可。

图 3 承上启下的业务中间层设计

处理离线 Voip 消息的技术细节

实际开发过程中,我们遇到了一个问题,当 B 不在线时,B 的聊天对象可能向 B 发起音视频消息,服务器为了信令消息的完备性,会建立一个队列,将所有向 B 发消息的信令记录下来。过了一段时间,当 B 登录时,Server 会把 B 离线期间所有的通话信令发过来。由于刚开始设计时没有考虑到这一点,造成一个问题就是当 B 启动时,A 发送了一个视频消息过来时,B 接受到第一个视频信令是离线期间的视频消息信令(如果有)。这就造成了 B 尝试连接一个早已不存在的视频通道,而让 A-B 视频聊天连接不上。客户端为了也支持这种信令序列,利用条件锁技术有序地处理视频连接信令,如图 4 所示。

图 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《程序员》原创文章,未经许可,请勿转载,如需转载,请留言。

作者:Byeweiyang 发表于2017/8/25 9:55:26 原文链接
阅读:0 评论:0 查看评论

Viewing all articles
Browse latest Browse all 5930

Trending Articles