V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
pjhubs
V2EX  ›  程序员

搞事情之如何快速完成 IM

  •  
  •   pjhubs ·
    windstormeye · 2019-04-26 21:39:40 +08:00 · 997 次点击
    这是一个创建于 2020 天前的主题,其中的信息可能已经有所发展或是发生改变。

    知识库:PJ 的 iOS 开发之路

    搞事情系列文章主要是为了继续延续自己的 “ T ” 字形战略所做,同时也代表着毕设相关内容的学习总结。本文章是快速对接即时通讯完成需求,主要是记录在集成即时通讯的过程中遇到的一些问题和总结。

    前言

    接入即时通讯是大一的比赛作品“大学+”,当时和另外一个小伙伴一起写下第一行代码,到靠着这个作品砍下了一些小奖,同时也让当时的自己快速的入门了与 iOS 开发相关一部分内容。

    现在要在毕设中同样接入 IM,调研了目前比较流行的 IM 服务提供商后,最终选择了融云负责即时聊天业务。在调研的过程中除了能够提供稳定的基础聊天服务,最好还要有个 UIKit,因为自己的时间并不多,想着直接在 IM 服务提供商所带的 UIKit 做二次开发。

    IM 服务提供商调研

    阿里云

    不知为何,我对阿里云的产品总是提不起来兴趣。最开始是接入了阿里云短信做验证码,在对接的过程中我不是很喜欢阿里云的做法,阿里云短信的 server SDK 只提供一个跟运营商的通道,至于短信验证码的内容,需要我们自己做维护,包括验证码的生成、匹配和过期。

    而相对我之前一直在使用 mob 来说,同样可以选择 client 触发短信验证码的发送,而 server 要做的事情仅仅只是匹配而已,不需要对验证码的生成和过期做处理。

    当然这一点看法智者见智,对于我个人来说,短信验证码并不是核心业务,虽然整个对接过程也不复杂,但整体情况对比来看我不是很舒服。最重要的是如果你要测试阿里云短信必须得先充钱,这其实就陷入了一个死循环“我的逻辑还没跑通,凭什么先交钱?不交钱怎么开通服务?”,一条短信虽然也没几个钱,但确实会让人不太爽。反观 mob 提供了开发环境每天 20 条免费短信用于测试。

    经过接入阿里云短信的过程后,我对阿里云系产品就失去了兴趣,包括阿里云通信。

    腾讯云

    在调研腾讯云 IM 的过程中,官网上的这句宣传语真是直击内心。

    腾讯是国内最大也是最早的即时通讯开发商,QQ 和微信已经成为每个互联网用户必不可少的应用。现在,腾讯将高并发、高可靠的即时通讯能力进行开放,开发者可以很容易的根据腾讯提供的 SDK 将即时通讯功能集成入 App 中。

    这还有什么好挑的?当时决定立马接入,其它不调研了。

    文档???

    腾讯云通信的 iOS SDK 应该是去年 8 月份左右做了更新,感觉很踏实。当初始化完 AppKey 后准备接入“消息列表 VC ”时我死活找不到官网文档上描述的类。

    后来我怀疑估计是偶然问题,凭着自己的经验,猜出了正确的“消息列表 VC ”类,并成功的初始化,接着开始对接“会话界面 VC ”,也就是 AddC2CController,一开始 Xcode 并没有进行代码补全的提示,以为是 Xcode 本身的问题,开始的清缓存、重启 Xcode 等操作,把工程恢复到了最佳,可当我最后一次敲下 AddC2CController 时,依然没有提示。

    翻了一遍 pods 中 TUIKit 中的所有类,惊奇的发现居然没有 AddC2CController 这个类!反复从官方文档中上下求索,可最终的结果是,我又凭着自己的经验找到了相似的类名,但初始化完成后,并不是我想要的结果,总不能把所有类都初始化一遍吧?

    最后无法忍受,很不开心的发了工单。等待了一个星期后,文档依旧没有更新,我彻底放弃了。刚才又去看了一眼,嗯,依旧没有更新......

    网易云信

    个别大佬不推荐使用,据说要凉了,那我就算了吧。

    leanCloud

    之前就听说了 leanCloud 全家桶很香。本来也打算上 leanCloud 全家桶,但粗略的文档看过去怎么好像都跟其云数据库绑定到了一起,跟之前大一时我和另外一个小伙伴不会写数据库,使用了当时比较火的云数据库提供商 Bmob 做法类似,再加上被前面腾讯云搞得有些疲惫了,对全新事物已经很难提起兴趣了,只想着能够越快解决这个问题就好。

    融云

    最后再三思考后,还是回到了融云上。刚开始也确实打算直接使用融云的 UIKit,但仔细对比了融云 UIKit 能够提供定制化的地方和 UI 设计图最终的效果差距甚远,遂放弃,准备只接入融云的核心通信库,使用第三方 IM UI 库完成。

    UI 库调研

    最开始我是想省事直接用 IM 服务提供商的 UIKit,但在看过了腾讯云和融云提供的 UI 定制太局限了,而且不管怎么做,都很难复刻出跟设计图一样的效果。

    IM UI

    IM UI

    MessagerKit

    github 地址:https://github.com/steve228uk/MessengerKit

    一开始看上了这个库,基本上把大部分功能都实现了,但是跟设计图上的一些细节还是有差距,比如说需要自己的做拓展支持语音、地图等自定义消息体、消息体框特殊圆角。这部分工作是清明节回家做的,整体上对接完成后其实还算 OK。

    MessageKit

    直到有一天中午,突然看到了 MessageKit 这个库!几乎完成了所有功能,把我开心坏了!立马着手开始全部切换。

    等到调好了一切细节后,发现这个库有一个坑爹的地方,点击输入框整个聊天界面的 collectionView 会上移一个固定距离,不管我怎么调,甚至把官方 demo 放到我的工程里也同样会出现这个问题,继续折腾了将近一个小时后,放弃了。无缘无故用户在点击输入框的时候整个聊天界面多往上移动大概 40px 的距离,不能忍。

    MessagerKit

    嗯,我又换回来了 😅,最终决定还是用回第一次的库。来来回回将近三四天的时间都在切换这两个 UI 库上,基本上都是快写完了才发现有些奇怪的地方,然后全部推翻再重来。

    接入融云

    首先按照融云的官方文档进行账号的注册和应用的创建。拿到 Appkey,集成 RongCloudIM/IMLib 到工程中。

    登录

    官方文档并不推荐在客户端生成 token 进行融云 SDK 的登录,因为生成 token 的过程涉及到的 AppSecret 的固定,如果 app 被反编译则有极大可能导致泄漏。但是如果你心够大或者只是做个 demo 玩玩,在客户端本地请求生成 token 也不是不可以,以下是基于融云 server python sdk 的 token 生成代码:

    @decorator.request_methon('GET')
    @decorator.request_check_args([])
    def getRCToken(request):
        from rongcloud import RongCloud
    
        uid = request.GET.get('uid')
        nick_name = request.GET.get('nick_name')
    
        app_key = settings.RC_APP_KEY
        app_secret = settings.RC_APP_SECRET
        rcloud = RongCloud(app_key, app_secret)
    
        r = rcloud.User.getToken(userId=uid,
                                 name=nick_name,
                                 portraitUri='https://avatars0.githubusercontent.com/u/15074681?s=460&v=4')
    
        r_json = eval(str(r.response.content, encoding='utf-8'))
        if r_json['code'] == 200:
            json = {
                'token': r_json['token']
            }
            return utils.SuccessResponse(json, request)
        else:
            masLogger.log(request, 2333, str(r.response.content, encoding='utf-8'))
            return utils.ErrorResponse(2333, 'RCToken error', request)
    

    在客户端上进行请求生成 token 的接口即可

    发送消息

    发送消息主要是使用如下方法:

    - (RCMessage *)sendMessage:(RCConversationType)conversationType
                      targetId:(NSString *)targetId
                       content:(RCMessageContent *)content
                   pushContent:(NSString *)pushContent
                      pushData:(NSString *)pushData
                       success:(void (^)(long messageId))successBlock
                         error:(void (^)(RCErrorCode nErrorCode, long messageId))errorBlock;
    

    关于该方法的使用在注释中已经写的很明白,我们需要做的就是把它进行一个封装,使其对外更好的使用:

    /// 发送文本消息
    func sendText(textString: String,
                  userID: String,
                  complateHandler: @escaping ((Int) -> Void),
                  failerHandler: @escaping ((RCErrorCode) -> Void)) {
        let text = RCTextMessage(content: textString)
        RCIMClient.shared()?.sendMessage(.ConversationType_PRIVATE,
                                          targetId: userID,
                                          content: text,
                                          pushContent: nil,
                                          pushData: nil,
                                          success: { (mesId) in
                                            complateHandler(mesId)
        }, error: { (errorCode, mesId) in
            failerHandler(errorCode)
        })
    }
    

    以上为发送文本消息的方法。需要注意的是,在调用该方法之前必须确定要消息体的类型等前置条件,必须得先确定要发送的消息体类型来调用不同的方法,比如图片、语音和视频等,包括自定义消息体,地图等。

    接收消息

    关于消息的接收,融云并没有限制消息监听器的类型,只要你是 NSObject 子类就可以实现代理方法接收消息。所以,我把消息接收稍微封装了一下:

    extension PJIM: RCIMClientReceiveMessageDelegate {
        func onReceived(_ message: RCMessage!, left nLeft: Int32, object: Any!) {
            print(message.objectName)
            switch message.objectName {
            case "RC:TxtMsg":
                let text = message.content as! RCTextMessage
                let m = Message(type: .text,
                                textContent: text.content,
                                audioContent: nil,
                                sendUserId: message.senderUserId,
                                msgId: message.messageId,
                                msgDirection: message.messageDirection,
                                msgStatus: message.sentStatus,
                                msgReceivedTime: message.receivedTime,
                                msgSentTime: message.sentTime)
                getMsg?(m)
                print(m.textContent!)
            case "RCImageMessage": break
            default: break
            }
        }
    }
    

    其中 Message 是我根据业务自建的一个结构体,因为 RCMessage 的属性太多了,很多都用不到,当然你也可以选择不封装:

    extension PJIM {
        enum MessageType {
            case text
            case audio
        }
        
        struct Message {
            var type: MessageType
            var textContent: String?
            var audioContent: Data?
            var sendUserId: String
            var msgId: Int
            var msgDirection: RCMessageDirection
            var msgStatus: RCSentStatus
            var msgReceivedTime: Int64
            var msgSentTime: Int64
        }
    
        struct MessageListCell {
            var avatar: Int
            var nickName: String
            var uid: String
            var message: Message?
        }
    }
    

    至此,我们通过了两个方法就完成了消息的发送和接收~可以愉快的玩耍一番了!

    获取消息列表

    如果你是免费用户,那么从融云获取消息列表只是本地数据,如果用户更换了设备、重装了 app 等都会导致消息列表的丢失;如果你是收费用户,从融云服务器上拉取到的消息列表貌似只有区区七天(再长也是多几天而已),所以如果对消息列表有追求的同学需要注意了。

    我的消息列表还涉及到了用户信息的获取,这部分是异步请求,结合融云的同步获取本地消息列表,这就形成了一个异步操作保持顺序性的问题。为了到达“简洁”的操作,我只使用了“信号量”的方法完成。

    /// 获取本地会话列表
    func getConversionList(_ complateHandler: @escaping (([MessageListCell]) -> Void)) {
        let cTypes = [NSNumber(value: RCConversationType.ConversationType_PRIVATE.rawValue)]
        let cList = RCIMClient.shared()?.getConversationList(cTypes) as? [RCConversation]
        
        var msgListCells = [MessageListCell]()
        guard cList != nil else { return complateHandler(msgListCells)}
        
        if cList?.count != 0 {
            var cIndex = 0
            for c in cList! {
                let currentMessage = RCMessage(type: .ConversationType_PRIVATE,
                                                targetId: c.targetId,
                                                direction: c.lastestMessageDirection,
                                                messageId: c.lastestMessageId,
                                                content: c.lastestMessage)
                currentMessage?.sentTime = c.sentTime
                currentMessage?.receivedTime = c.receivedTime
                currentMessage?.senderUserId = c.senderUserId
                currentMessage?.sentStatus = c.sentStatus
                
                if currentMessage != nil {
                    
                    let message = getMessage(with: currentMessage!)
                    if message == nil { break }
                    
                    // 获取用户信息,可以替换为你的,如果不需要获取用户信息,可以删除
                    PJUser.shared.details(details_uid: c.targetId,
                                          getSelf: false,
                                          completeHandler: {
                                            let msgCell = MessageListCell(avatar: $0.avatar!,
                                                                          nickName: $0.nick_name!,
                                                                          uid: $0.uid!,
                                                                          message: message!)
                                            msgListCells.append(msgCell)
                                            
                                            if cIndex == cList!.count - 1 {
                                                var finalCells = [MessageListCell]()
                                                for cell in cList! {
                                                    _ = msgListCells.filter({
                                                        if $0.uid == cell.targetId {
                                                            finalCells.append($0)
                                                            return true
                                                        }; return false
                                                    })
                                                }
                                                
                                                complateHandler(finalCells)
                                            }
                                        
                                            cIndex += 1
                    }) { print($0.errorMsg) }
                }
            }
        } else {
            complateHandler(msgListCells)
        }
    }
    

    PJIM

    结合融云形成一个简单数据服务就写好了,通过单例在任何你想要进行消息的发送和接收,完整代码如下。其中有一部分是业务耦合较为严重的方法不方便展开,看着替换即可。

    //
    //  PJIM.swift
    //  PIGPEN
    //
    //  Created by PJHubs on 2019/4/9.
    //  Copyright © 2019 PJHubs. All rights reserved.
    //
    
    import Foundation
    
    @objc class PJIM: NSObject {
        var getMsg: ((Message) -> Void)?
        
        private static let instance = PJIM()
        class func share() -> PJIM {
            return instance
        }
        
        override init() {
            super.init()
            RCIMClient.shared()?.setReceiveMessageDelegate(self, object: nil)
        }
        
        /// 发送文本消息
        func sendText(textString: String,
                      userID: String,
                      complateHandler: @escaping ((Int) -> Void),
                      failerHandler: @escaping ((RCErrorCode) -> Void)) {
            let text = RCTextMessage(content: textString)
            RCIMClient.shared()?.sendMessage(.ConversationType_PRIVATE,
                                             targetId: userID,
                                             content: text,
                                             pushContent: nil,
                                             pushData: nil,
                                             success: { (mesId) in
                                                complateHandler(mesId)
            }, error: { (errorCode, mesId) in
                failerHandler(errorCode)
            })
        }
        
        /// 获取本地会话列表
        func getConversionList(_ complateHandler: @escaping (([MessageListCell]) -> Void)) {
            let cTypes = [NSNumber(value: RCConversationType.ConversationType_PRIVATE.rawValue)]
            let cList = RCIMClient.shared()?.getConversationList(cTypes) as? [RCConversation]
            
            var msgListCells = [MessageListCell]()
            guard cList != nil else { return complateHandler(msgListCells)}
            
            if cList?.count != 0 {
                var cIndex = 0
                for c in cList! {
                    let currentMessage = RCMessage(type: .ConversationType_PRIVATE,
                                                   targetId: c.targetId,
                                                   direction: c.lastestMessageDirection,
                                                   messageId: c.lastestMessageId,
                                                   content: c.lastestMessage)
                    currentMessage?.sentTime = c.sentTime
                    currentMessage?.receivedTime = c.receivedTime
                    currentMessage?.senderUserId = c.senderUserId
                    currentMessage?.sentStatus = c.sentStatus
                    
                    if currentMessage != nil {
                        
                        let message = getMessage(with: currentMessage!)
                        if message == nil { break }
                        
                        PJUser.shared.details(details_uid: c.targetId,
                                              getSelf: false,
                                              completeHandler: {
                                                let msgCell = MessageListCell(avatar: $0.avatar!,
                                                                              nickName: $0.nick_name!,
                                                                              uid: $0.uid!,
                                                                              message: message!)
                                                msgListCells.append(msgCell)
                                                
                                                if cIndex == cList!.count - 1 {
                                                    var finalCells = [MessageListCell]()
                                                    for cell in cList! {
                                                        _ = msgListCells.filter({
                                                            if $0.uid == cell.targetId {
                                                                finalCells.append($0)
                                                                return true
                                                            }; return false
                                                        })
                                                    }
                                                    
                                                    complateHandler(finalCells)
                                                }
                                            
                                                cIndex += 1
                        }) { print($0.errorMsg) }
                    }
                }
            } else {
                complateHandler(msgListCells)
            }
        }
        
        private func getMessage(with rcMessage: RCMessage) -> Message? {
            switch rcMessage.objectName {
                case "RC:TxtMsg":
                    let text = rcMessage.content as! RCTextMessage
                    let m = Message(type: .text,
                                    textContent: text.content,
                                    audioContent: nil,
                                    sendUserId: rcMessage.senderUserId,
                                    msgId: rcMessage.messageId,
                                    msgDirection: rcMessage.messageDirection,
                                    msgStatus: rcMessage.sentStatus,
                                    msgReceivedTime: rcMessage.receivedTime,
                                    msgSentTime: rcMessage.sentTime)
                    return m
                case "RCImageMessage": break
                default: break
            }
            return nil
        }
    }
    
    extension PJIM: RCIMClientReceiveMessageDelegate {
        func onReceived(_ message: RCMessage!, left nLeft: Int32, object: Any!) {
            print(message.objectName)
            switch message.objectName {
            case "RC:TxtMsg":
                let text = message.content as! RCTextMessage
                let m = Message(type: .text,
                                textContent: text.content,
                                audioContent: nil,
                                sendUserId: message.senderUserId,
                                msgId: message.messageId,
                                msgDirection: message.messageDirection,
                                msgStatus: message.sentStatus,
                                msgReceivedTime: message.receivedTime,
                                msgSentTime: message.sentTime)
                getMsg?(m)
                print(m.textContent!)
            case "RCImageMessage": break
            default: break
            }
        }
    }
    
    
    extension PJIM {
        enum MessageType {
            case text
            case audio
        }
        
        struct Message {
            var type: MessageType
            var textContent: String?
            var audioContent: Data?
            var sendUserId: String
            var msgId: Int
            var msgDirection: RCMessageDirection
            var msgStatus: RCSentStatus
            var msgReceivedTime: Int64
            var msgSentTime: Int64
        }
        
        struct MessageListCell {
            var avatar: Int
            var nickName: String
            var uid: String
            var message: Message?
        }
    }
    

    UI

    经过之前的一番调整,即时聊天的数据源都准备好了,接下来就是要画界面了。关于 UI 库的选择上文已经说明经过了几番折腾后,最终的选择是 MessengerKit。因为 UI 实现都很普通,没什么可以做拓展的地方,以下是一些我任何值得关注的地方:

    ViewModel

    融云提供的 RCMessage 类结构和 MessengerKit 所要求的数据类型不一样,需要我们单独针对 MessengerKit 做一个 ViewModel 喂食。


    超过字符限制了,详见原文 pjhubs.com

    2 条回复    2019-04-27 07:48:19 +08:00
    weakish
        1
    weakish  
       2019-04-27 02:54:03 +08:00
    > 本来也打算上 leanCloud 全家桶,但粗略的文档看过去怎么好像都跟其云数据库绑定到了一起

    「绑定」云数据库可以比较方便地查询会话、消息。即时通讯可以用 LeanCloud 云数据库的用户系统(「全家桶」),也可以接自己的用户系统(权限验证可以接云引擎,也可以接自己的服务器)。
    pjhubs
        2
    pjhubs  
    OP
       2019-04-27 07:48:19 +08:00 via iPhone
    @weakish 好,我知道了,谢谢
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2947 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 14:45 · PVG 22:45 · LAX 06:45 · JFK 09:45
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.