搞事情之如何快速完成 IM

知識庫:PJ 的 iOS 開發之路python

搞事情繫列文章主要是爲了繼續延續本身的 「T」 字形戰略所作,同時也表明着畢設相關內容的學習總結。本文章是快速對接即時通信完成需求,主要是記錄在集成即時通信的過程當中遇到的一些問題和總結。git

前言

接入即時通信是大一的比賽做品「大學+」,當時和另一個小夥伴一塊兒寫下第一行代碼,到靠着這個做品砍下了一些小獎,同時也讓當時的本身快速的入門了與 iOS 開發相關一部份內容。github

如今要在畢設中一樣接入 IM,調研了目前比較流行的 IM 服務提供商後,最終選擇了融雲負責即時聊天業務。在調研的過程當中除了可以提供穩定的基礎聊天服務,最好還要有個 UIKit,由於本身的時間並很少,想着直接在 IM 服務提供商所帶的 UIKit 作二次開發。數據庫

IM 服務提供商調研

阿里雲

不知爲什麼,我對阿里雲的產品老是提不起來興趣。最開始是接入了阿里雲短信作驗證碼,在對接的過程當中我不是很喜歡阿里雲的作法,阿里雲短信的 server SDK 只提供一個跟運營商的通道,至於短信驗證碼的內容,須要咱們本身作維護,包括驗證碼的生成、匹配和過時。json

而相對我以前一直在使用 mob 來講,一樣能夠選擇 client 觸發短信驗證碼的發送,而 server 要作的事情僅僅只是匹配而已,不須要對驗證碼的生成和過時作處理。swift

固然這一點見解智者見智,對於我我的來講,短信驗證碼並非核心業務,雖然整個對接過程也不復雜,但總體狀況對比來看我不是很舒服。最重要的是若是你要測試阿里雲短信必須得先充錢,這其實就陷入了一個死循環「個人邏輯還沒跑通,憑什麼先交錢?不交錢怎麼開通服務?」,一條短信雖然也沒幾個錢,但確實會讓人不太爽。反觀 mob 提供了開發環境天天 20 條免費短信用於測試。api

通過接入阿里雲短信的過程後,我對阿里雲系產品就失去了興趣,包括阿里雲通訊。緩存

騰訊雲

在調研騰訊雲 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 地址:github.com/steve228uk/…

一開始看上了這個庫,基本上把大部分功能都實現了,可是跟設計圖上的一些細節仍是有差距,好比說須要本身的作拓展支持語音、地圖等自定義消息體、消息體框特殊圓角。這部分工做是清明節回家作的,總體上對接完成後其實還算 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 餵食。

發送者和接收者

MessengerKit 聊天氣泡的切換是根據「發送者」和「接收者」的 id 進行的,咱們須要處理好從融雲拉取過來的消息列表,根據「發送者」 id 和「接受者」 id(也即 targetId)進行分割爲不一樣的 section,如下是個人處理過程:

private func didSetMessageCell() {
  // 若是有未讀消息數,進入聊天后就所有已讀
  let badge = RCIMClient.shared()!.getUnreadCount(.ConversationType_PRIVATE, targetId: messageCell!.uid)
  if (badge != 0) {
      RCIMClient.shared()!.clearMessagesUnreadStatus(.ConversationType_PRIVATE, targetId: messageCell!.uid)
      UIApplication.shared.applicationIconBadgeNumber -= Int(badge)
  }
  
  titleString = messageCell!.nickName
  friendUser = ChatUser(displayName: messageCell!.nickName,
                        avatar: UIImage(named: "\(messageCell!.avatar)"),
                        isSender: false)
  meUser = ChatUser(displayName: PJUser.shared.userModel.nick_name!,
                    avatar: UIImage(named: "\(PJUser.shared.userModel.avatar!)"),
                        isSender: true)
  
  func update(_ ms: [RCMessage]) {
      var m_index = 0
      var tempMsgs = [MSGMessage]()
      var tempMsgUserId = messageCell?.uid
      messages.append(tempMsgs)
      
      // 便利全部消息,按照 sendId 和 targetId 進行分離
      for m in ms {
          let text = m.content as! RCTextMessage
          if tempMsgUserId != m.senderUserId {
              tempMsgs.removeAll()
              messages.append(tempMsgs)
              m_index += 1
              tempMsgUserId = m.senderUserId
          }
          
          let c_m: MSGMessage?
          if m.senderUserId != PJUser.shared.userModel.uid! {
              c_m = MSGMessage(id: m.messageId,
                                body: .text(text.content),
                                user: friendUser!,
                                sentAt: Date(timeIntervalSince1970: TimeInterval(m.sentTime)))
          } else {
              c_m = MSGMessage(id: m.messageId,
                                body: .text(text.content),
                                user: meUser!,
                                sentAt: Date(timeIntervalSince1970: TimeInterval(m.sentTime)))
          }
          // 設置消息已讀狀態
          RCIMClient.shared()?.setMessageSentStatus(m.messageId, sentStatus: .SentStatus_READ)
          
          tempMsgs.insert(c_m!, at: 0)
          messages.insert(tempMsgs, at: m_index)
          messages.remove(at: m_index + 1)
      }
      
      messages.reverse()
  }
  
  let ms = RCIMClient.shared()?.getLatestMessages(.ConversationType_PRIVATE, targetId: messageCell?.uid, count: 30) as? [RCMessage]
  // 若是本地無消息,從融雲服務器上拉取
  if ms != nil {
      update(ms!)
      DispatchQueue.main.async {
          // reloadData 時主線程被佔用,scrollToBottom 等待,reloadData 完成後,再執行 scrollToBottom
          self.collectionView.reloadData()
          DispatchQueue.main.async {
              self.collectionView.scrollToBottom(animated: false)
          }
      }
  } else {
      // TODO: 這部分有問題,須要交錢才能拉取到服務器上的歷史消息
      RCIMClient.shared()?.getRemoteHistoryMessages(.ConversationType_PRIVATE, targetId: PJUser.shared.userModel.uid!, recordTime: 0, count: 20, success: { (messages: [RCMessage]) in
              update(messages)
          } as? ([Any]?) -> Void, error: { (errorCode) in
              print(errorCode.rawValue)
      })
  }
}
複製代碼

消息推送

這部分融雲文檔寫得很好了~記得在 Xcode 中把 Background Modes 的「遠程推送」打開。·

總結

以上是我完成了一期 IM 過程當中的思考和總結,解決問題的方法還有些許不足,吸收了以前的作其它產品的教訓,本次嚴格遵循 「MVP」 的開發流程,小步快跑,一期工做主要是先跑起來,讓其它小夥伴聊起來。

相關文章
相關標籤/搜索