iOS IM 记录开发中的要点

3,294 阅读6分钟

前言

去年一整年的工作主要是进行一款移动办公类产品的框架开发。核心功能点的话,框架层涉及到的全局字号的调整、App主题颜色的调整、路由;核心业务涉及到IM即时通讯、Hybird 移动开发平台、工作流、高仿朋友圈。
网页端是使用的一款叫 LayIM 的聊天系统,但很可惜,LayIM 没有提供原生的移动SDK,所以好家伙,只能自己造轮子了😂。
接下来主要是对 IM即时通讯 开发中遇到的难点进行记录。

选择聊天协议

刚开始开发的时候,也确实是没有比较好的思路,在网上查询了一些资料 iOS即时通讯实现IM,最后也敲定了具体的实现方案。

WebScoket

WebSocket协议是基于TCP的一种新的网络协议。 我们在应用层,使用socket,轻易的实现了进程之间的通信。避免直面TCP/IP协议。
在这边,我没有直接基于OS底层Scoket去实现自定义封装,而是使用了一个第三方框架 SocketRocket

通讯类

IMWebsocketClient

作为即时通讯客户端基类,主要实现:
1、SRWebSocket 的初始化。
2、实现 SRWebSocketDelegate 并向外部提供,当前状态打开连接断开连接收发消息

LayIMManager

聊天业务的基类,主要实现:
1、连、断、重连、当前状态、用户信息。
2、提供单聊、群聊等业务方法。
3、封装约定的消息格式,给服务端发送 LayIM 消息。
4、接收来自服务器端的消息,解析。

IMConversionManager

首先,我们来看看 服务端与客户端进行交互的消息格式:

LKLayIMServeMsg

@property (strong, nonatomic) LKLayIMServeMsgMine *mine; /**<发送方*/
@property (strong, nonatomic) LKLayIMServeMsgTo *to;  /**<接收方*/
LKLayIMServeMsgMine
@property (copy, nonatomic) NSString *id;       /**<发送方ID*/
@property (copy, nonatomic) NSString *messNo;   /**<消息主键*/
@property (copy, nonatomic) NSString *avatar;   /**<发送方的头像*/
@property (copy, nonatomic) NSString *content;  /**<内容*/
@property (copy, nonatomic) NSString *username; /**<发送者名字*/
@property (assign, nonatomic) NSTimeInterval timestamp; /**<时间戳*/
LKLayIMServeMsgTo
@property (copy, nonatomic) NSString *avatar;   /**< 接受者的头像 */
@property (copy, nonatomic) NSString *name;     /**< 接受者名字 */
@property (copy, nonatomic) NSString *id;       /**< 接受者ID */
@property (copy, nonatomic) NSString *type;     /**< 消息类型:group | friend | business */

App端在接收到Serve端的消息后,会解析成App端使用的数据格式:

无论是我自己发送的消息,还是别人发的消息,都会经过接收消息这个方法处理中,我们在解析中需要注意以下几个点:
1、mine是消息发送方,to消息接收方。
2、如果发送方的id和当前用户的cliendid 一致, 那么说明是我自己的消息。
3、群聊和我发送的 消息msg 的 conversionId 要换成 to(接收方) 中的 id

LayIMConversation

会话类

@property (copy, nonatomic, nullable) NSString *conversationId; /**<会话ID*/
@property (copy, nonatomic) NSString *avatar;/**<群聊头像*/
@property (copy, nonatomic) NSString *name;/**<会话标题*/
@property (copy, nonatomic) NSString *type;/**<类型,group|friend|business|可能是notification*/
@property (assign, nonatomic) LKChatConversationType conversationType; /**<type属性的具现化*/
@property (assign, nonatomic) NSUInteger unread;/**<未读数*/
@property (strong, nonatomic,nullable) LKLayIMMessage *lastMessage; /**<最新一条消息*/
@property (copy, nonatomic,nullable) NSString *lastMessageNoWhenClean;
@property (assign, nonatomic) BOOL muted; /**<是否免打扰*/
@property (nonatomic, copy) NSString *draft; /**<草稿*/
@property (assign, nonatomic) BOOL mentioned; /**<是否有人提到了你,配合 @ 功能。不能看最后一条消息。因为可能倒数第二条消息提到了你,所以维护一个标记。*/ // 暂时不处理
@property (assign, nonatomic) BOOL isWholeMsg; /**<消息链是否完全*/
@property (assign, nonatomic) NSTimeInterval stickTime; /**<设置置顶的时间*/
@property (assign, nonatomic) BOOL isHidden; /**<是否被关闭了群聊,关闭了的,不会在列表显示*/
@property (strong, nonatomic) NSArray *members; /**<群成员,暂时无值*/

其他一些辅助方法:

1、展示 最新一条消息 的 本地时间, 类似 xxx分钟前。
2、根据消息主键messNo,对消息进行升、降序。
3、获取该会话的最近 limit 条消息。 刚进页面第一次调用的方法。
4、查询历史消息,获取某条消息或指定时间戳之前的 limit 条消息。
5、返回全部的本地缓存的消息

LayIMMessage

消息类

@property (nonatomic, copy) NSString *conversationId;/**<iOS客户端,自己创建的通用会话主键*/
@property (copy, nonatomic) NSString *chatNo;  /**<会话主键 */
@property (copy, nonatomic) NSString *type;/**<会话的类型,group|friend|business|可能是notification*/
@property (assign, nonatomic) LKChatConversationType conversationType;/**<会话类型*/
@property (copy, nonatomic, nullable) NSString *messNo;/**<消息主键*/
@property (copy, nonatomic, nullable) NSString *uuid;/**<本地消息uuid*/
@property (copy, nonatomic) NSString *sendUsr; /**<消息发送人主键*/
@property (copy, nonatomic) NSString *avatar;/**<发送人头像*/
@property (copy, nonatomic) NSString *username;/**<发送人的名字*/
@property (copy, nonatomic) NSString *content;/**<消息内容*/
@property (assign, nonatomic) NSTimeInterval sendDat;/**<时间戳*/
@property (assign, nonatomic) BOOL isMineMsg;/**<是否为我发出的消息*/
@property (assign, nonatomic) LKChatMessageType messageType;/**<消息类型*/
@property (assign, nonatomic) LKChatMessageSendState messageStatus;/**<消息状态*/
@property (assign, nonatomic) LKChatMessageOwnerType ownerType;/**<消息的拥有者类型*/
@property (copy, nonatomic) NSMutableAttributedString *attributedContent; /**<TEXT消息的富文本:暂时无法存到本地*/
@property (strong, nonatomic) NSDictionary *fileDic; /**<文件消息的FileDic*/
@property (strong, nonatomic) NSDictionary *voiceDic; /**<语音Dic*/
@property (nonatomic, copy) NSString *voiceDuration;  /**<语音长度*/
@property (strong, nonatomic) NSDictionary *locationDic; /**<位置Dic*/
@property (strong, nonatomic) NSDictionary *cardDic; /**<业务Dic*/

// 本地自己发的:
@property (strong, nonatomic, nullable) UIImage *previewImage; /**<预览图:视频 | 图片*/
@property (strong, nonatomic) NSValue *imageSize; /**<图片的比较适中的大小*/

其他一些辅助方法:

1、是否显示时间轴Label2、创建时间戳这种系统消息。
3、获取消息的唯一标识uuid。
4、获取业务消息的具体内容(比如附件的主键,业务信息的数据字典)。

交互消息设计完之后,再来看看 IMConversionManager 主要实现的点:

1、当前正在聊天的会话对象:currentConversation。
2、数据库操作,将磁盘缓存转化为内存缓存。之后的增删改查都是对内存缓存的操作之后,开启子线程对磁盘缓存进行处理。
3、会话业务:查找全部的会话、设置已读、增加未读数、插入/更新会话、会话置顶、会话静音、设置 draft 草稿、取出会话的草稿、删除最近会话。
4、消息业务:取出早/晚于msgId的消息、根据会话id查询消息、根据消息主键查找消息对象、消息模糊查询、插入消息、更新消息、异步插入/删除消息。

工厂模式

消息 Cell 注意点:

1、使用工厂模式,进行cell的注册和初始化。
2、将一些通用的view封装到基类中,便于调整,比如消息状态,是否已读,背景,头像等等。
3、由于自己发送的消息和别人发送的消息,只有背景颜色和头像位置等一些稍小差别,所以在注册时给Identifier添加前缀来区分(_SELF,_OTHER,_SYSTEM,_BUSINESS等。),从而避免在cellForRow中对布局约束不断改动。
4、由于系统设置中存在全局字号调整的功能,所以在布局时全部使用的Masonry布局,支持自适应高度。

键盘

语音

lame 的使用

业务

选图、选视频

TZImagePickerController

地图、定位

百度地图

表情

网上还是有许多实现的方案的,我的话,是参照了 PPStickerKeyboard

优点

市面上App和Serve端约定的表情格式的话,均类似 [开心]、[愤怒] 这些,LayIM的话,使用的是face[开心]、face[愤怒],所以在App端,处理时,还是要展示表情。
实现方案: 我们设置到输入框的NSAttributedString中的每一个NSTextAttachment都有一个"隐藏的"属性-—表情的文本描述,这里对NSAttributedString进行拓展就能实现。lk_setTextBackedString可以对NSAttributedString的指定range设置一个LKTextBackedString类型的属性,而lk_plainTextForRange能拿到NSAttributedString指定range的纯文本。

NSMutableAttributedString(LKAddition)

- (void)lk_setTextBackedString:(LKTextBackedString *)textBackedString range:(NSRange)range
{
    if (textBackedString && ![NSNull isEqual:textBackedString]) {
        [self addAttribute:LKTextBackedStringAttributeName value:textBackedString range:range];
    } else {
        [self removeAttribute:LKTextBackedStringAttributeName range:range];
    }
}

业务消息

LayIM的消息格式都是String,针对图片、视频、定位这些媒体信息,我们实现这些业务消息的实现方案是:

1、将图片这些附件,先上传,服务端返回主键。
2、构造对应消息格式:img[%@],video[%@],location[%@]。
3、JSONString之后就是简单的文本信息了。

消息状态

由于Serve端在处理消息是否已经发送接收上存在技术难点,目前App端的实现方案是,在发送消息之后,设置消息状态为 消息发送中,记录发送时间,展示Loading状态框。如果接收到了Serve端发送过来的自己的消息,那么设置消息状态为 消息发送成功,隐藏Loading状态框,关闭定时器。如果一直没有收到了Serve端的消息,那么当前时间减去发送时间超过3分钟后,展示Failed状态框。

胖瘦图片

UIImage (LKChatExtension)

- (CGSize)lkchat_getScaledSize{
    
    CGFloat ow = CGImageGetWidth(self.CGImage);
    CGFloat oh = CGImageGetHeight(self.CGImage);
    
    CGSize kMaxImageViewSize = {.width = 240, .height = 240};
    CGFloat aspectRatio = ow / oh;
    CGFloat width;
    CGFloat height;
    CGSize limitSize = kMaxImageViewSize;
    
    if (ow < limitSize.width && oh < limitSize.height) {
        width = ow;
        height = oh;
        return CGSizeMake(width, height);
    }
    
    //胖照片
    if (limitSize.width / aspectRatio <= limitSize.height) {
        width = limitSize.width;
        height = limitSize.width / aspectRatio;
    } else {
        //瘦照片
        width = limitSize.height * aspectRatio;
        height = limitSize.height;
    }
    return CGSizeMake(width, height);
}

将优化后的size记录在消息中,缓存到本地。
其次,由于加载图片是耗时的,故,针对图片也要进行缓存。
具体查看之前的 LayIMMessage
当图片加载完成前展示的是一份展位图,加载完成后,通过代理方法告知聊天室VC去刷新页面UI,滚动到页面底部等等,告知数据类 IMConversionManager 去更新数据。

文件下载

使用的是框架中的下载工具类。针对 LayIM 做了一个单例的分类,单独缓存聊天中的下载文件,不做最大下载并发量的设置,缓存时对文件进行前缀标注,方便设置中心清除缓存。
之后单独拿出来,整理一下。

PDF、Word等文档预览

QLPreviewController的使用

待改进的点

消息链优化

功能点:保证App端的消息链是最完整的。
目前处理历史消息链的时候,是有网状态下,每次都是从服务端取消息,然后存储到本地。无网状态下,从本地数据库中出取。
想要实现好方案的话:是在有网状态下,会判断本地消息链是否是连续的,非连续会去从服务端请求,再整合。

将业务功能抽离,支持可拓展化。

目前所有的业务功能都是写在 聊天键盘类(ChatBar) 中的,没有很好的做抽离解耦,后续增加功能,比如视频会议等等这些业务功能的话,只能在 ChatBar 中添加代码。需要考虑如何做出拓展。

文件

键盘语音
IM

业务流程图

image.png