iOS-聊天-IM-消息收发管理工具
iOS 聊天 IM 消息收发管理工具
iOS 聊天 IM 消息收发管理工具
连续疯狂加班告一段落,趁着离职前夕的空闲时间,整理一下重构相关的文档。之前写过两篇文章 、 ,突然发现时间过的真的很快,这都已经是两年多以前的事情了,我居然没再写点什么,自责三秒钟。 总体讲了探探这边 IM 整体框架架构 , 消息的收发、存储基本流程 以及 UI框架的接口设计 , 讲的比较微观,是说消息列表中的一条消息,如何通过插件支持交互以及与其他消息联动。
本篇想讲的是 消息的收发、存储基本流程 的实现,算是给 IM 部分收个尾。
1. 简介
之前提过消息列表是由数据驱动的,这次说的聊天消息收发管理工具就是驱动列表展示的数据引擎。与 里提到的 ChatContext 是同一个,它作为新聊天消息列表的数据引擎,内部无业务属性,可以接入到多种聊天场景,比如单聊、群聊、临时会话等等。
2. 消息收发基本流程
这里在 中已经写过,为方便阅读,姑且粘过来一份吧
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 接口设计
/// 消息数据
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、 发送成功 回调或者 发送失败 时修改返回给业务的错误信息
接口设计
/// 发送同步类型参数消息
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 中,如下所示
func loadNewMessages() asyc {
let msgs = await loader.loadNewMessages()
dataManager.append(msgs)
...
}
下面是 ChatMessageListLoader 支持的功能以及接口设计
/// 是否可以加载更旧的消息 = 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
消息数据的存储工具,负责数据去重、排序,提供增删改查功能
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 创建发送参数
@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 调用发送消息接口
- 可以在全局获取发送工具
- 消息发送失败重试逻辑,不需要额外处理
- 调用完发送后,所有相关业务处理以及消息刷新都会处理,不需要额外调用
PUGLocationMessageParameterBuilder *paraBuilder = [PUGLocationMessageParameterBuilder alloc] init];
paraBuilder.locationInfo = locationInfo;
[PUGChatContext context:@""].messageSender sendMessageWithSynParameterBuilder:paraBuilder];
4.2 重构前的发送消息
重构前需要关注消息发送流程中的各种细节,包括所有网络请求的基础参数、何时保存数据库、何时处理通用错误、消息重试发送等等。下面是重构前需要开发的五个部分
4.2.1 在 MessageNetworkInterface 中新增一个发送消息请求
并且很多比较难懂的公共参数不能丢失
+ (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 发送接口,还要关注通用逻辑的细节,以及刷新列表等
- (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 实现发送方法
// 在 PUGGroupMessagesViewController 内部实现
- (void)sendLocationMessageInfo:(PUGLocationMessageInfo *)locationMessageInfo {
[self.tableViewPlugin.locationPlugin sendLocationInfo:locationMessageInfo];
[self.commercializeService messageVCDidSendMessageAction];
}
3.2.4 管理维护传递调用链
让后将 PUGMessageViewController、PUGGroupMessageViewController 等消息详情页传给 PUGChooseLocationHandler 类,通过这些消息详情页暴露的发送接口进行调用发送
// 在 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 处理自动发送以及重试逻辑
#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];
}
}];
}
总结
这里介绍了消息收发以及数据管理部分的基本设计,并对重构前后做了对比,可以明显看出来良好架构对于开发效率以及维护成本方面的优势,由于个人水平有限,以及篇幅限制,这里没有列出一些巧妙设计的细节,包括参数构造器设计,这个与具体业务场景有关,有兴趣可以私信