iOS 聊天 IM 消息收发管理工具
连续疯狂加班告一段落,趁着离职前夕的空闲时间,整理一下重构相关的文档。之前写过两篇文章 iOS 客户端 IM 以及列表 UI 框架 、iOS 客户端 IM 消息卡片插件化,突然发现时间过的真的很快,这都已经是两年多以前的事情了,我居然没再写点什么,自责三秒钟。iOS 客户端 IM 以及列表 UI 框架 总体讲了探探这边 IM 整体框架架构 ,消息的收发、存储基本流程 以及 UI框架的接口设计 ,iOS 客户端 IM 消息卡片插件化讲的比较微观,是说消息列表中的一条消息,如何通过插件支持交互以及与其他消息联动。
本篇想讲的是消息的收发、存储基本流程 的实现,算是给 IM 部分收个尾。
1. 简介
之前提过消息列表是由数据驱动的,这次说的聊天消息收发管理工具就是驱动列表展示的数据引擎。与iOS 客户端 IM 以及列表 UI 框架 里提到的 ChatContext 是同一个,它作为新聊天消息列表的数据引擎,内部无业务属性,可以接入到多种聊天场景,比如单聊、群聊、临时会话等等。
2. 消息收发基本流程
这里在iOS 客户端 IM 以及列表 UI 框架 中已经写过,为方便阅读,姑且粘过来一份吧
2.1 消息发送过程
消息的发送是基于 HTTP 请求,主要包含两种类型:一种是同步参数类型 (简单文本)、一种是异步参数类型的消息(多媒体,或者文本消息增加各种检查等)。
- 同步参数类型,是组装好参数后,直接通过 POST 请求发送到服务器;
- 异步参数类型消息,在调用发送消息的接口之前,需要先将多媒体数据上传到 CDN 服务器,然后把返回的地址链接拼到发送消息的请求参数中,再请求发送消息接口,实现消息的发送;
- 异步参数类型消息还包括一些特殊的多媒体信息,比如第三方服务提供的大表情消息,在调用发送消息接口之前,就需要把第三方获数据另保存一份到自己的 CDN 服务器,然后把返回的数据拼接到发送消息参数中,请求发送消息接口,实现消息的发送。
2.2 消息接收过程
消息的接收是基于 WebSocket 长连接和 HTTP 请求相互配合来实现的。WebSocket 是 App 内用于服务端向客户端发送通知消息的单向通讯服务工具
- 有了新的消息,服务端会通过 WebSocket 向接收方主动发送通知消息;
- 做为主动通知的补充,客户端端上还有轮询任务,以固定时间间隔来通过 HTTP 请求服务端询问是否有新的消息;
- 客户端收到通知消息之后,再通过 HTTP 请求新消息数据,来展示到对应的 UI;
2.3 消息的存储
消息的存储底层采用 sqlite 和第三方 FMDB 开源框架,再此基础之上开发了一套支持 ORM 以及 SQL 语句生成工具的基础库。每个支持数据库保存的 Model 类内部保存了具体表名、不同字段与表字段的映射,进行数据库操作的时候可以根据这些信息生成对应的 SQL 语句来保存到本地数据库。本地消息的保存就是直接通过框架保存到数据库即可,网络部分是请求到数据后,通过前后端约定的 envelop key 确定到具体数据 Model 的类,从而根据 ORM 信息来保存到数据库,大概就介绍到这里吧,不再做过多的介绍了。
3. ChatContext
ChatContext 内部主要有以下三个模块:
1、ChatMessenger,负责消息发送,失败重试等
2、ChatMessageListLoader,负责加载新消息以及历史消息,不负责存储,内部会根据锚点使用 LocalLoader(本地数据库加载)或者RemoteLoader(远程加载)来加载数据
3、ChatDataManager 负责存储管理内存中的消息数据,内部
通过协调这三个模块来实现消息收发、消息加载、消息数据变更回调。消息列表 会监听消息数据变更来实现UI更新,从而达到数据驱动的目的
3.1 架构设计图

3.2 接口设计
swift
/// 消息数据
private(set) var messages: [Message]
/// 消息发送工具
let messenger: ChatMessenger
/// 是否有更多历史消息
var hasMorePreviousMessages: Bool
/// 注册消息数组数据变更监听者
func registerMsgDataObserver(observer: ChatContextMsgDataObservable)
/// 清空消息历史消息
func clearAllMessages() async
/// 拉取历史消息
/// TODO: 从当前消息拉取到指定时间的历史消息
func fetchMorePreviousMessages() async -> [Message]
3.3 ChatMessenger
该模块主要负责消息发送,以及发送流程控制,其中发送流程控制是指:
1、发送消息前 拼装各业务提供的基础参数
2、将要发送时 询问业务方是否拦截
3、发送成功 回调或者发送失败时修改返回给业务的错误信息
接口设计
swift
/// 发送同步类型参数消息
public func sendMessage(withSynParameterBuilder builder: BaseMessageParameterBuilder & MessageSynParameterBuilder) async -> (PUGMessage?, TXTResponse?, PUGError?) {
// 询问业务组装基础参数
setup(messageParameterBuilder: builder)
return await sendMessage(synParameterBuilder: builder)
}
/// 发送异步类型参数消息
public func sendMessage(withAsynParameterBuilder builder: BaseMessageParameterBuilder & MessageAsynParameterBuilder) async -> (PUGMessage?, TXTResponse?, PUGError?) {
setup(messageParameterBuilder: builder)
return await sendMessage(asynParameterBuilder: builder)
}
private func sendMessage(synParameterBuilder: MessageSynParameterBuilder) async -> (PUGMessage?, TXTResponse?, PUGError?) {
// 通知业务消息即将发送
observableTable?.allObjects.forEach({ observer in
observer.chatMessenger?(self, willSendMessageWithBuilder: synParameterBuilder)
})
let ret = await basicMessenger.sendMessage(synParameterBuilder: synParameterBuilder,
willSendMessageChecker: { [weak self] localMessage in
// 业务检查消息是否可以进入正常发送流程
return self?.checkSending(localMsg: localMessage)
}, failedMessageModifier: { [weak self] result in
// 消息发送失败,通知业务修改错误信息
return self?.modify(failedMsg: result.message, error: result.error)
})
// 通知业务消息发送完成(包括成功、失败)
publish(didSentMessageWithResult: ret)
return ret.toTuple()
}
消息发送流程图
3.4 ChatMessageListLoader
该模块负责消息数据的加载,并不负责存储,ChatContext 的拉取消息接口内部调用 ChatMessageListLoader 加载消息,并存储在 ChatDataManager 中,如下所示
swift
func loadNewMessages() asyc {
let msgs = await loader.loadNewMessages()
dataManager.append(msgs)
...
}
下面是 ChatMessageListLoader 支持的功能以及接口设计
swift
/// 是否可以加载更旧的消息 = canLoadOldRemoteMessages || canLoadOldLocalMessages
var canLoadOldMessages: Bool
/// 是否可以从本地数据库加载更旧的消息
var canLoadOldLocalMessages: Bool
/// 是否可以从远端加载更旧的消息
var canLoadOldRemoteMessages: Bool
/// 加载新消息,只从后端拉取
func loadNewMessages() asyc -> Result
/// 加载旧消息,优先从数据库获取,然后从后端获取旧消息
func loadOldMessages() asyc -> Result
/// 清空消息时调用
func clear() asyc
3.5 ChatDataManager
消息数据的存储工具,负责数据去重、排序,提供增删改查功能
swift
class ChatDataManager<Item> {
/// 当前存储的数据
private(set) var items: [Item]
/// 初始化方法,sorter: 用于排序
init(sorter: Comparator)
/// 头部添加
func prepend(_ items: [Item])
/// 尾部添加
func append(_ items: [Item])
/// 更新
func update(_ items: [Item])
/// 删除
func deletedItems(_ itemIDs: [String])
/// 移除所有元素
func removeAllItems()
}
4. 发送消息部分,接入 ChatContext 前后对比
4.1 使用 ChatContext 发送消息
重构后不在需要了解消息发送具体原理即可快速上手,仅需要关注与后端新增的参数字段即可,下面看一下新增一种消息发送类型所需要做:
4.1.1 创建发送参数
c
@interface PUGLocationMessageParameterBuilder : PUGBaseMessageParameterBuilder<PUGMessageSynParameterBuilder>
@property (strong) PUGLocationMessageInfo *locationInfo;
@end
@implementation PUGLocationMessageParameterBuilder
// 用于保存到数据库的消息
- (nullable PUGMessage *)buildMessage {
PUGMessageBuilder *builder = [self generateBaseMessageBuilder];
builder.locationDictionary = self.locationInfo.toDictionary;
builder.retryParameter = [self buildParameter];
return builder.build;
}
// 发送给后端的参数
- (nonnull NSDictionary *)buildParameter {
NSMutableDictionary *para = [self generateBaseParameter].mutableCopy;
// 仅需要关注与后端新增的参数字段即可
[para txt_setObjectSafely:self.locationInfo.toDictionary forKey:@"location"];
return para.copy;
}
@end
2.1.2 调用发送消息接口
- 可以在全局获取发送工具
- 消息发送失败重试逻辑,不需要额外处理
- 调用完发送后,所有相关业务处理以及消息刷新都会处理,不需要额外调用
c
PUGLocationMessageParameterBuilder *paraBuilder = [PUGLocationMessageParameterBuilder alloc] init];
paraBuilder.locationInfo = locationInfo;
[PUGChatContext context:@""].messageSender sendMessageWithSynParameterBuilder:paraBuilder];
4.2 重构前的发送消息
重构前需要关注消息发送流程中的各种细节,包括所有网络请求的基础参数、何时保存数据库、何时处理通用错误、消息重试发送等等。下面是重构前需要开发的五个部分
4.2.1 在 MessageNetworkInterface 中新增一个发送消息请求
并且很多比较难懂的公共参数不能丢失
c
+ (nullable TXTRESTApiRequest *)postTextMessage:(NSString *)content conversaitonID:(NSString *)conversationID primaryKeyID:(NSString *)primaryKey referenceDictionary:(NSDictionary *)referenceDictionary parameters:(nullable NSDictionary *)parameters completion:(void(^)(PUGError *error, PUGMessage *data, NSDictionary *retryParameter))completion {
NSAssert(parameters[@"msgType"] != nil, @"Has no relevant key: 'msgType'");
if (content.length < 1 || completion == NULL || conversationID.length == 0) { // 文本消息没有内容直接return
if (completion != NULL) {
completion([PUGError errorWithCode:PUGCommonErrorInvalidParameters description:@"Invalid Parameters"], nil, nil);
}
return nil;
}
TXTRESTApiRequest *req = [TXTRESTApiRequest requestOfPOSTMethod];
req.urlString = [self messagesForConversationWithID:conversationID withWithParameters:@[@(PUGBusinessKeyLiterature)]];
NSDictionary *params;
NSDictionary *idempotentInfo = @{
@"id" : mNonnilString(primaryKey)
};
if (referenceDictionary) {
params = @{@"xx": content,
@"xxx": referenceDictionary,
@"xxx": idempotentInfo
};
} else {
params = @{@"xx": content,
@"xxx": idempotentInfo
};
}
if (parameters.count > 0) {
NSMutableDictionary *tmp = params.mutableCopy;
[tmp addEntriesFromDictionary:parameters];
params = tmp;
}
req.paramsDictionary = params;
req.responseSerializerType = TXTResponseSerializerTypeCustomize;
req.responseSerializer = [self postMessageParser:primaryKey];
[req startWithResponseBlock:^(TXTResponse *response) {
PUGError *error = [PUGErrorParser errorFromResponse:response];
completion(error, error == nil ? response.responseObject : nil, params);
}];
return req;
}
3.2.2 新建一个 PUGLocationMessagePlugin 实现发送新消息以及重试发送消息
需要熟悉消息发送流程,进行保存数据库,网络请求,刷新UI 等,包括同步数据库,调用 PUGMessageNetworkInterface 发送接口,还要关注通用逻辑的细节,以及刷新列表等
c
- (void)sendLocationInfo:(PUGLocationMessageInfo *)locationInfo {
@weakify(self);
[self sendLocation:locationInfo addCompletion:^(PUGError *error, PUGMessage *message, NSInteger index) {
@strongify(self);
[self.controller showNewMessage];
} completion:^(PUGError *error, PUGMessage *message, NSInteger index) {
@strongify(self);
[self.controller sendMessageCompletion:message];
if ([self processSendMessageError:error message:message]) {
//common error
} else {
[self.controller reloadMessages];
}
}];
}
- (void)sendLocationMessage:(PUGMessage *)locationMessage completion:(void (^)(PUGError *error,PUGMessage *message,NSInteger index))sentCompletion {
void (^messageSendStatusCheckCompletion)(PUGError *error, PUGMessage *message) = [self.useCase messageSendStatusCheckCompletion];
/**
NOTIC: 需要关注各种细节
经后端确认,发消息接口发送的内容如果携带xxx字段并且满足xxx或者sticker或者media三字段中任意字段不为空,
会被判定为回答xxxx对应的那个真心话,此时如果value不为空,会被认为是一条普通的文字消息,
收消息一方收到的message消息体内将不包含xxx字段。
*/
NSDictionary *networkParametersDictionary = @{
@"xxx": [NSDictionary dictionaryWithDictionary:locationMessage.locationDictionary],
@"xxx": mNonnilString(locationMessage.msgType),
@"xxx": mNonnilString([TXTStatistics currentPageID])
};
[PUGMessageNetworkInterface postTextMessage:@"location" conversaitonID:self.useCase.conversationID messageReferenceID:nil primaryKeyID:[(PUGMessage *)locationMessage primaryKeyID] parameters:networkParametersDictionary completion:^(PUGError *error, PUGMessage *data, NSDictionary *retryParameter) {
if (messageSendStatusCheckCompletion) {
messageSendStatusCheckCompletion(error, data);
}
[self.useCase updateSentMessage:data error:error sentCompletion:sentCompletion originalMessage:locationMessage retryParameter:retryParameter];
if ([self.controller respondsToSelector:@selector(messageDidSend:)]) {
[self.controller messageDidSend:self.useCase];
}
}];
}
- (void)sendLocation:(PUGLocationMessageInfo *)location addCompletion:(void (^)(PUGError *error, PUGMessage *message, NSInteger index))addCompletion completion:(void (^)(PUGError *error, PUGMessage *message, NSInteger index))sentCompletion {
PUGMessage *reference = [self.useCase findLatestQuestionMessage];
PUGMessage *locationMessage = [PUGMessageDatabaseInterface locationMessage:location referenceMessage:reference otherUser:self.useCase.otherUser conversationID:self.useCase.conversationID];
NSInteger index = [self.useCase addOrReplaceMessage:locationMessage toFront:NO];
[self.useCase updateCurrentConversation:locationMessage addedMessagesCount:1];
if (addCompletion) {
addCompletion(nil,locationMessage,index);
}
@weakify(self);
[PUGMessageDatabaseInterface asyncSaveMessage:locationMessage completion:^(BOOL result) {
@strongify(self);
if (![self.useCase checkIfUnmatchedByOtherUserWithMessage:locationMessage completion:sentCompletion]) {
[self sendLocationMessage:locationMessage completion:sentCompletion];
}
}];
}
- (void)retryFailedMessage:(PUGMessage *)message updated:(void (^)(PUGMessage *message,NSInteger index))updatedBlock completion:(void (^)(PUGError *error, PUGMessage *message, NSInteger index))sentCompletion {
PUGMessage *updatedMessage = [message copyWithBlock:^(PUGMessageBuilder * _Nonnull builder) {
builder.apiObjectState = PUGObjectStatePending;
builder.createdTime = [NSDate pug_serverDate];
}];
NSInteger index = [self.useCase updateMessage:updatedMessage];
if (updatedBlock) {
updatedBlock(updatedMessage,index);
}
void (^messageSendStatusCheckCompletion)(PUGError *error, PUGMessage *message) = [self.useCase messageSendStatusCheckCompletion];
void (^completion)(PUGError *error, PUGMessage *message, NSInteger index) = ^(PUGError *error, PUGMessage *message, NSInteger index) {
if (sentCompletion) {
sentCompletion(error, message, index);
}
};
@weakify(self);
[PUGMessageDatabaseInterface asyncSaveMessage:updatedMessage completion:^(BOOL result) {
@strongify(self);
if ([updatedMessage.msgType isEqualToString:PUGMessageMsgTypeLocation]) {
[self sendLocationMessage:updatedMessage completion:^(PUGError *error, PUGMessage *message,NSInteger index) {
if (completion) {
completion(error, message, index);
}
if (messageSendStatusCheckCompletion) {
messageSendStatusCheckCompletion(error, message);
}
}];
}
}];
}
3.2.3 只能在消息详情页,或者能获取到消息详情页的类中,调用发送请求
在 PUGMessageViewController、PUGGroupMessageViewController 等消息详情页暴露发送位置消息的接口,内部调用 PUGLocationMessagePlugin 实现发送方法
c
// 在 PUGGroupMessagesViewController 内部实现
- (void)sendLocationMessageInfo:(PUGLocationMessageInfo *)locationMessageInfo {
[self.tableViewPlugin.locationPlugin sendLocationInfo:locationMessageInfo];
[self.commercializeService messageVCDidSendMessageAction];
}
3.2.4 管理维护传递调用链
让后将 PUGMessageViewController、PUGGroupMessageViewController 等消息详情页传给 PUGChooseLocationHandler 类,通过这些消息详情页暴露的发送接口进行调用发送
c
// 在 PUGChooseLocationHandler 中实现
- (void)sendLocation {
if(![self checkLocationPermissionAlert]) {
return;
}
PUGChooseLocationViewController *chooseLocationVC = [[PUGChooseLocationViewController alloc] init];
chooseLocationVC.shouldGetMapItemsFromBackground = PUGDeviceInfo.isChineseMobileNumber;
if ([self.useCase isGroupChat]) {
chooseLocationVC.sourceName = @"GROUP_CHAT_VIEW";
} else {
chooseLocationVC.sourceName = @"CHAT_VIEW";
}
@weakify(self);
chooseLocationVC.sendLocationHandler = ^(MKMapItem *mapItem) {
@strongify(self);
[self.controller sendLocationMessageInfo:[PUGLocationMessageInfo locationMessageInfoFromMapItem:mapItem]];
};
UINavigationController *navi = [PUGThemeManager createNavigationController];
if (!navi) {return;}
navi.navigationBar.translucent = YES;
navi.viewControllers = @[chooseLocationVC];
[self.controller presentViewController:navi animated:YES completion:nil];
chooseLocationVC.title = PUGChatLocalizedString(@"LOCATION_CHOOSE_VIEW_TITLE");
}
3.2.5 处理自动发送以及重试逻辑
c
#pragma mark - 重传消息
- (void)retrySendMessage:(PUGMessage *)message {
if ([message.msgType isEqualToString:PUGMessageMsgTypeImage] || [message.msgType isEqualToString:PUGMessageMsgTypeExchangePhoto]) {
[self retrySendImageMessage:message];
} else if ([message.msgType isEqualToString:PUGMessageMsgTypeAudio]) {
[self retrySendAudioMessage:message];
} else if ([message.msgType isEqualToString:PUGMessageMsgTypeVideo]) {
[self retrySendVideoMessage:message];
} else if ([message.msgType isEqualToString:PUGMessageMsgTypeRealShot]) {
PUGPictureInfo *pictureInfo = [message realShotPictureInfos].firstObject;
if (pictureInfo.hasVideo) {
[self retrySendVideoMessage:message];
} else {
[self retrySendImageMessage:message];
}
} else {
[self retrySendNormalMessage:message];
}
}
#pragma mark - 重传普通消息
- (void)retrySendNormalMessage:(PUGMessage *)message {
@weakify(self);
[PUGMessageRetryNetworkInterface retrySendMessage:message completion:^(PUGError * _Nonnull error, PUGMessage * _Nonnull data) {
@strongify(self);
[self done];
if (error) {
[self uploadMediaFailedWithMessage:message error:error];
} else if (data) {
NSDictionary *info = @{
@"newMessage" : data,
@"originalMessage" : message
};
[[NSNotificationCenter defaultCenter] postNotificationName:PUGRetrySendMessageSuccessNotification object:nil userInfo:info];
[self logRetryMessageSCWithMessage:message];
}
}];
}
总结
这里介绍了消息收发以及数据管理部分的基本设计,并对重构前后做了对比,可以明显看出来良好架构对于开发效率以及维护成本方面的优势,由于个人水平有限,以及篇幅限制,这里没有列出一些巧妙设计的细节,包括参数构造器设计,这个与具体业务场景有关,有兴趣可以私信