iOS 聊天 IM 消息收发管理工具

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 请求,主要包含两种类型:一种是同步参数类型 (简单文本)、一种是异步参数类型的消息(多媒体,或者文本消息增加各种检查等)。

  1. 同步参数类型,是组装好参数后,直接通过 POST 请求发送到服务器;
  2. 异步参数类型消息,在调用发送消息的接口之前,需要先将多媒体数据上传到 CDN 服务器,然后把返回的地址链接拼到发送消息的请求参数中,再请求发送消息接口,实现消息的发送;
  3. 异步参数类型消息还包括一些特殊的多媒体信息,比如第三方服务提供的大表情消息,在调用发送消息接口之前,就需要把第三方获数据另保存一份到自己的 CDN 服务器,然后把返回的数据拼接到发送消息参数中,请求发送消息接口,实现消息的发送。

2.2 消息接收过程

消息的接收是基于 WebSocket 长连接和 HTTP 请求相互配合来实现的。WebSocket 是 App 内用于服务端向客户端发送通知消息的单向通讯服务工具

  1. 有了新的消息,服务端会通过 WebSocket 向接收方主动发送通知消息;
  2. 做为主动通知的补充,客户端端上还有轮询任务,以固定时间间隔来通过 HTTP 请求服务端询问是否有新的消息;
  3. 客户端收到通知消息之后,再通过 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 调用发送消息接口
  1. 可以在全局获取发送工具
  2. 消息发送失败重试逻辑,不需要额外处理
  3. 调用完发送后,所有相关业务处理以及消息刷新都会处理,不需要额外调用
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];
        }
    }];
}

总结

这里介绍了消息收发以及数据管理部分的基本设计,并对重构前后做了对比,可以明显看出来良好架构对于开发效率以及维护成本方面的优势,由于个人水平有限,以及篇幅限制,这里没有列出一些巧妙设计的细节,包括参数构造器设计,这个与具体业务场景有关,有兴趣可以私信

相关推荐
Book_熬夜!4 分钟前
CSS—补充:CSS计数器、单位、@media媒体查询
前端·css·html·媒体
几度泥的菜花1 小时前
如何禁用移动端页面的多点触控和手势缩放
前端·javascript
狼性书生1 小时前
electron + vue3 + vite 渲染进程到主进程的双向通信
前端·javascript·electron
肥肠可耐的西西公主1 小时前
前端(AJAX)学习笔记(CLASS 4):进阶
前端·笔记·学习
拉不动的猪1 小时前
Node.js(Express)
前端·javascript·面试
Re.不晚1 小时前
Web前端开发——HTML基础下
前端·javascript·html
几何心凉2 小时前
如何处理前端表单验证,确保用户输入合法?
前端·css·前端框架
浪遏2 小时前
面试官😏: 讲一下事件循环 ,顺便做道题🤪
前端·面试
Joeysoda2 小时前
JavaEE进阶(2) Spring Web MVC: Session 和 Cookie
java·前端·网络·spring·java-ee
小周同学:3 小时前
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
前端·npm·node.js