知識庫:PJ 的 iOS 開發之路python
搞事情繫列文章主要是爲了繼續延續本身的 「T」 字形戰略所作,同時也表明着畢設相關內容的學習總結。本文章是快速對接即時通信完成需求,主要是記錄在集成即時通信的過程當中遇到的一些問題和總結。git
接入即時通信是大一的比賽做品「大學+」,當時和另一個小夥伴一塊兒寫下第一行代碼,到靠着這個做品砍下了一些小獎,同時也讓當時的本身快速的入門了與 iOS 開發相關一部份內容。github
如今要在畢設中一樣接入 IM,調研了目前比較流行的 IM 服務提供商後,最終選擇了融雲負責即時聊天業務。在調研的過程當中除了可以提供穩定的基礎聊天服務,最好還要有個 UIKit
,由於本身的時間並很少,想着直接在 IM 服務提供商所帶的 UIKit
作二次開發。數據庫
不知爲什麼,我對阿里雲的產品老是提不起來興趣。最開始是接入了阿里雲短信作驗證碼,在對接的過程當中我不是很喜歡阿里雲的作法,阿里雲短信的 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 全家桶,但粗略的文檔看過去怎麼好像都跟其雲數據庫綁定到了一塊兒,跟以前大一時我和另一個小夥伴不會寫數據庫,使用了當時比較火的雲數據庫提供商 Bmob 作法相似,再加上被前面騰訊雲搞得有些疲憊了,對全新事物已經很難提起興趣了,只想着可以越快解決這個問題就好。
最後再三思考後,仍是回到了融雲上。剛開始也確實打算直接使用融雲的 UIKit
,但仔細對比了融雲 UIKit
可以提供定製化的地方和 UI 設計圖最終的效果差距甚遠,遂放棄,準備只接入融雲的核心通訊庫,使用第三方 IM UI 庫完成。
最開始我是想省事直接用 IM 服務提供商的 UIKit
,但在看過了騰訊雲和融雲提供的 UI 定製太侷限了,並且無論怎麼作,都很難復刻出跟設計圖同樣的效果。
github 地址:github.com/steve228uk/…。
一開始看上了這個庫,基本上把大部分功能都實現了,可是跟設計圖上的一些細節仍是有差距,好比說須要本身的作拓展支持語音、地圖等自定義消息體、消息體框特殊圓角。這部分工做是清明節回家作的,總體上對接完成後其實還算 OK。
直到有一天中午,忽然看到了 MessageKit 這個庫!幾乎完成了全部功能,把我開心壞了!立馬着手開始所有切換。
等到調好了一切細節後,發現這個庫有一個坑爹的地方,點擊輸入框整個聊天界面的 collectionView
會上移一個固定距離,無論我怎麼調,甚至把官方 demo 放到個人工程裏也一樣會出現這個問題,繼續折騰了將近一個小時後,放棄了。平白無故用戶在點擊輸入框的時候整個聊天界面多往上移動大概 40px 的距離,不能忍。
嗯,我又換回來了 😅,最終決定仍是用回第一次的庫。來來回回將近三四天的時間都在切換這兩個 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.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 庫的選擇上文已經說明通過了幾番折騰後,最終的選擇是 MessengerKit。由於 UI 實現都很普通,沒什麼能夠作拓展的地方,如下是一些我任何值得關注的地方:
融雲提供的 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」 的開發流程,小步快跑,一期工做主要是先跑起來,讓其它小夥伴聊起來。