原文:https://www.raywenderlich.com/140836/firebase-tutorial-real-time-chat-2
貌似市場上的主流 app 都有聊天功能,所以,我們的 app 也應當添加聊天功能啦。
然而,開發一個聊天工具是一個令人畏懼的工作。除了要有專門用于聊天的本地 UIKit 控件,我們還需要一個服務器來協調用戶之間的消息和對話。
幸運的是,有一些不錯的框架可以幫助我們:在Firebase 的幫助下,我們可以不用寫一行后端代碼就可同步實時數據,而 JSQMessagesViewController 則給我們提供了一個與原生消息 app 相似的消息傳遞 UI 。
在這篇 Firebase 教程中,我們將開發一個 RIC (Really Instant Chat) -- 匿名聊天應用。如果你使用過 IRC 或者 Slack,這種 app 你應該很熟悉了。
在此教程,您將學習到如下內容:
- 使用 CocoaPods 設置 Firebase SDK 和 JSQMessagesViewController。
- 使用 Firebase 數據庫實時同步數據。
- Firebase 匿名身份驗證。
- 使用 JSQMessagesViewController 做為完整的聊天界面。
- 指示用戶何時輸入。
- 使用 Firebase 存儲。
開始
下載初始工程 the starter project here 。現在,它包含一個簡單的虛擬登錄界面。
我們使用 CocoaPods 下載 Firebase SDK 和 JSQMessagesViewController。如果你還不會使用 CocoaPods ,請先學習我們這篇教程 Cocoapods with Swift tutorial。
在項目目錄下,進入終端,打開根目錄下的 Podfile 文件,添加如下依賴代碼:
pod 'Firebase/Storage'
pod 'Firebase/Auth'
pod 'Firebase/Database'
pod 'JSQMessagesViewController'
保存文件,命令行執行如下命令:
pod install
完成依賴包下載后,在 Xcode 打開 ChatChat.xcworkspace 。在運行之前,先配置 Firebase 。
如果你從未使用過 Firebase,首先你需要創建一個賬號。不用擔心,這些是免費的。
注: Firebase 的操作細節,可以看這里 Getting Started with Firebase tutorial.
創建 Firebase 賬號
登錄 the Firebase signup site,創建賬號,然后創建一個工程。
按照指示將 Firebase 添加到 iOS 應用程序,復制 GoogleService-Info.plist 配置文件到你的項目。它包含與應用程序的 Firebase 集成所需的配置信息。
build and run ,你將看到如下界面:
允許匿名認證
Firebase允許用戶通過電子郵件或社交帳戶登錄,但它也可以匿名地對用戶進行身份驗證,為用戶提供唯一的標識符,而不需要了解他們任何信息。
要設置匿名驗證,打開 Firebase 應用程序的 Dashboard,選擇左側的 Auth 選項,單擊 "Sign-In" 方法,然后選擇“ Anonymous”,打開 “ Enable” 按鈕,然后單擊 "Save"。
像這樣,我們啟用了超級秘密隱形模式 ! 好吧,雖然這只是匿名身份驗證,但它仍然很酷。
登錄
打開 LoginViewController.swift,添加 import UIKit:
import Firebase
要登錄聊天,app 需要使用 Firebase 身份驗證服務進行身份驗證。將以下代碼添加到loginDidTouch(_:):
if nameField?.text != "" { // 1
FIRAuth.auth()?.signInAnonymously(completion: { (user, error) in // 2
if let err = error { // 3
print(err.localizedDescription)
return
}
self.performSegue(withIdentifier: "LoginToChat", sender: nil) // 4
})
}
注釋如下:
- 首先,確保 name field 非空。
2.使用 Firebase Auth API 匿名登錄,該方法帶了一個方法塊兒,方法塊兒傳遞 user 和 error 信息。 - 在完成方法塊里,檢查是否有認證錯誤,如果有,終止運行。
- 最后,如果沒有錯誤異常,進入 ChannelListViewController 頁面。
Build and run,輸入你的名字,然后進入 app。
創建 Channels 列表
一旦用戶登錄了, app 導航到 ChannelListViewController 頁面, 該頁面展示給用戶當前頻道列表, 給他們提供選擇創建新通道。該頁面使用兩個 section 的表視圖。第一個 section 提供了一個表單,用戶可以在其中創建一個新的通道,第二 section 列出所有已知通道。
本小節,我們將學到:
- 保存數據到 Firebase 數據庫
- 監聽保存到數據庫的新數據。
在 ChannelListViewController.swift 的頭部添加如下代碼:
import Firebase
enum Section: Int {
case createNewChannelSection = 0
case currentChannelsSection
}
緊隨導入語句之后的 enum 中包含兩個表視圖 section 。
接下來,在類內,添加如下代碼:
// MARK: Properties
var senderDisplayName: String? // 1
var newChannelTextField: UITextField? // 2
private var channels: [Channel] = [] // 3
注釋如下 :
- 添加一個存儲 sender name 的屬性。
- 添加一個 text field ,稍后我們會使用它添加新的 Channels。
- 添加一個空的 Channel 對象數組,存儲你的 channels。這是 starter 項目中提供的一個簡單的模型類,它只包含一個名稱和一個ID。
接下來,我們需要設置 UITableView 來呈現新的通道和可用的通道列表。在 ChannelListViewController.swift 中添加以下代碼:
// MARK: UITableViewDataSource
override func numberOfSections(in tableView: UITableView) -> Int {
return 2 // 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { // 2
if let currentSection: Section = Section(rawValue: section) {
switch currentSection {
case .createNewChannelSection:
return 1
case .currentChannelsSection:
return channels.count
}
} else {
return 0
}
}
// 3
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let reuseIdentifier = (indexPath as NSIndexPath).section == Section.createNewChannelSection.rawValue ? "NewChannel" : "ExistingChannel"
let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath)
if (indexPath as NSIndexPath).section == Section.createNewChannelSection.rawValue {
if let createNewChannelCell = cell as? CreateChannelCell {
newChannelTextField = createNewChannelCell.newChannelNameField
}
} else if (indexPath as NSIndexPath).section == Section.currentChannelsSection.rawValue {
cell.textLabel?.text = channels[(indexPath as NSIndexPath).row].name
}
return cell
}
對于以前使用過 UITableView 的人來說,這應該是非常熟悉的,但簡單地說幾點:
- 設置 Sections。請記住,第一部分將包含一個用于添加新通道的表單,第二部分將顯示一個通道列表。
- 為每個部分設置行數。第一部分設置為 1,第二部分設置個數為通道的個數。
- 定義每個單元格的內容。對于第一個部分,我們將 cell 中的 text field 存儲在newChannelTextField 屬性中。對于第二部分,您只需將單元格的 text field 標簽設置為通道名稱。
為了確保這一切正常工作,請在屬性下面添加以下代碼:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
channels.append(Channel(id: "1", name: "Channel1"))
channels.append(Channel(id: "2", name: "Channel2"))
channels.append(Channel(id: "3", name: "Channel3"))
self.tableView.reloadData()
}
這只是向通道數組添加了一些虛擬通道。
Build and run app ; 再次登錄,我們現在應該可以看到表單創建一個新的通道和三個虛擬通道:
太棒了! 接下來,我們需要讓它與 Firebase 一起工作了。 :]
Firebase 數據結構
在實現實時數據同步之前,首先讓我們花一會兒功夫想想數據結構。
Firebase database 以 NoSQL JSON 格式存儲數據。
基本上,Firebase數據庫中的所有內容都是JSON對象,而這個JSON對象的每個鍵都有自己的URL。
下面是一個說明我們的數據如何作為 JSON 對象的示例:
{
"channels": {
"name": "Channel 1"
"messages": {
"1": {
"text": "Hey person!",
"senderName": "Alice"
"senderId": "foo"
},
"2": {
"text": "Yo!",
"senderName": "Bob"
"senderId": "bar"
}
}
}
}
Firebase 數據庫支持非規范化的數據結構,因此可以為每個消息項包含 senderId。一個非規范化的數據結構意味著我們將復制大量的數據,但好處是可以更快的檢索數據。
實時 Channel 同步
首先,刪除上面添加的viewDidAppear(_:)代碼,然后在其他以下屬性中添加以下屬性:
private lazy var channelRef: FIRDatabaseReference = FIRDatabase.database().reference().child("channels")
private var channelRefHandle: FIRDatabaseHandle?
channelRef 將用于存儲對數據庫中通道列表的引用;channelRefHandle 將為引用保存一個句柄,以便以后可以刪除它。
接下來,我們需要查詢Firebase數據庫,并得到一個在我們的表視圖中顯示的通道列表。添加以下代碼:
// MARK: Firebase related methods
private func observeChannels() {
// Use the observe method to listen for new
// channels being written to the Firebase DB
channelRefHandle = channelRef.observe(.childAdded, with: { (snapshot) -> Void in // 1
let channelData = snapshot.value as! Dictionary<String, AnyObject> // 2
let id = snapshot.key
if let name = channelData["name"] as! String!, name.characters.count > 0 { // 3
self.channels.append(Channel(id: id, name: name))
self.tableView.reloadData()
} else {
print("Error! Could not decode channel data")
}
})
}
代碼解釋:
- 我們在通道引用上調用 observe:with: 方法,將句柄存儲到引用。每當在數據庫中添加新的通道時,就調用 completion block 。
- completion 后接收到一個 FIRDataSnapshot (存儲在快照中),其中包含數據和其它有用的方法。
- 我們將數據從快照中提取出來,如果成功,創建一個通道模型并將其添加到我們的通道數組中。
// MARK: View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
title = "RW RIC"
observeChannels()
}
deinit {
if let refHandle = channelRefHandle {
channelRef.removeObserver(withHandle: refHandle)
}
}
這將在 view controller 加載時調用新的 observeChannels() 方法。當 view controller 通過檢查 channelRefHandle 是否設置并調用 removeObserver(withHandle:) 來判斷是否結束生命周期時,我們同時停止觀察數據庫更改。
在看到從 Firebase 中提取出的通道列表之前,還有一件事需要做: 提供一種方法來創建通道! 在故事板中已經設置了 IBAction,所以只需向我們的類添加以下代碼就好了:
// MARK :Actions
@IBAction func createChannel(_ sender: AnyObject) {
if let name = newChannelTextField?.text { // 1
let newChannelRef = channelRef.childByAutoId() // 2
let channelItem = [ // 3
"name": name
]
newChannelRef.setValue(channelItem) // 4
}
}
下面是詳細解釋:
- 首先檢查 text field 是否擁有一個 channel name.
- 使用 childByAutoId() 唯一標志 key 創建一個通道引用。
- 創建一個字典,以此保存通道的數據。[String: AnyObject] 是類似 JSON 的對象。
- 最后,在這個新的通道上設置名稱,它將自動保存到Firebase !
Build and run 我們的 app ,創建一些 channels。
所有內容都應該按照預期運行,但我們還沒有實現當用戶點擊時可以訪問其中一個通道。讓我們添加以下代碼來解決這個問題:
// MARK: UITableViewDelegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if indexPath.section == Section.currentChannelsSection.rawValue {
let channel = channels[(indexPath as NSIndexPath).row]
self.performSegue(withIdentifier: "ShowChannel", sender: channel)
}
}
以上代碼,我們應該很熟悉了。當用戶點擊通道 cell 時,它會觸發 ShowChannel segue。
創建聊天界面
JSQMessagesViewController 是一個 UICollectionViewController 定制聊天控制類,所以我們不需要再創建自己的了!
這部分教程,我們將關注四點:
- 創建消息數據。
- 創建消息泡沫。
- 刪除頭像支持。
- 改變 UICollectionViewCell 的 文字顏色。
幾乎所有需要做的事情都需要覆蓋方法。JSQMessagesViewController 采用JSQMessagesCollectionViewDataSource 協議,所以我們只需要覆蓋默認的實現方法就好了。
注意:有關 JSQMessagesCollectionViewDataSource的更多信息, 請查看這里的 Cocoa 文檔。
打開 ChatViewController.swift ,添加如下引入:
import Firebase
import JSQMessagesViewController
將繼承類 UIViewController 改為 JSQMessagesViewController:
final class ChatViewController: JSQMessagesViewController {
在 ChatViewController 頭部,定義如下屬性:
var channelRef: FIRDatabaseReference?
var channel: Channel? {
didSet {
title = channel?.name
}
}
既然 ChatViewController 繼承自JSQMessagesViewController , 我們需要設置 senderId 和 senderDisplayName 的初始值,以使 app 可以唯一標識消息的發送者——即使它不知道那個人具體是誰。
這些需要在 view controller 首次實例化時設置。最好的設置時刻是當 segue 即將 prepare 時。回到ChannelListViewController, 添加以下代碼:
// MARK: Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
super.prepare(for: segue, sender: sender)
if let channel = sender as? Channel {
let chatVc = segue.destination as! ChatViewController
chatVc.senderDisplayName = senderDisplayName
chatVc.channel = channel
chatVc.channelRef = channelRef.child(channel.id)
}
}
這將在執行 segue 之前創建的 ChatViewController 上設置屬性。
獲得 senderDisplayName 的最佳位置是當用戶登錄時輸入他們的名字。
在 LoginViewController.swift,添加如下方法:
// MARK: Navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
super.prepare(for: segue, sender: sender)
let navVc = segue.destination as! UINavigationController // 1
let channelVc = navVc.viewControllers.first as! ChannelListViewController // 2
channelVc.senderDisplayName = nameField?.text // 3
}
注釋:
- 從 segue 獲取目標視圖控制器并將其轉換為 UINavigationController。
- 強制轉換 UINavigationController 的第一個view controller 為 ChannelListViewController。
- 設置 ChannelListViewController 的senderDisplayName 為 nameField 中提供的用戶名。
返回 ChatViewController.swift,在 viewDidLoad() 方法最下方添加如下代碼:
self.senderId = FIRAuth.auth()?.currentUser?.uid
這將基于已登錄的 Firebase 用戶設置 senderId。
Build and run 我們的 app 并導航到一個 channel 頁面。
通過簡單地繼承 JSQMessagesViewController,我們得到一個完整的聊天界面。:]
設置 Data Source 和 Delegate
現在我們已經看到了新的很棒的聊天 UI,我們可能想要開始顯示消息了。但在這么做之前,我們必須注意一些事情。
要顯示消息,我們需要一個數據源來提供符合 JSQMessageData 協議的對象,我們還需要實現一些委托方法。雖然我們可以創建符合 JSQMessageData 協議的類,但我們將使用已經提供的 JSQMessage 類。
在 ChatViewController 頂部,添加如下屬性:
var messages = [JSQMessage]()
messages 是應用程序中存儲 JSQMessage 各種實例的數組。
添加如下代碼:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageDataForItemAt indexPath: IndexPath!) -> JSQMessageData! {
return messages[indexPath.item]
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return messages.count
}
對于上述兩種委托方法,我們并不陌生。第一個類似于 collectionView(_:cellForItemAtIndexPath:),只是管理的對象是 message data。第二種是在每個 section 中返回messages 數量的標準方法;
消息氣泡顏色
在 collection view 中顯示的消息只是文本覆蓋的圖像。有兩種類型的消息:傳出和傳入。傳出的消息會顯示在右邊,傳入的消息顯示在左邊。
在 ChatViewController 中添加如下代碼:
private func setupOutgoingBubble() -> JSQMessagesBubbleImage {
let bubbleImageFactory = JSQMessagesBubbleImageFactory()
return bubbleImageFactory!.outgoingMessagesBubbleImage(with: UIColor.jsq_messageBubbleBlue())
}
private func setupIncomingBubble() -> JSQMessagesBubbleImage {
let bubbleImageFactory = JSQMessagesBubbleImageFactory()
return bubbleImageFactory!.incomingMessagesBubbleImage(with: UIColor.jsq_messageBubbleLightGray())
}
然后在頭部添加如下屬性:
lazy var outgoingBubbleImageView: JSQMessagesBubbleImage = self.setupOutgoingBubble()
lazy var incomingBubbleImageView: JSQMessagesBubbleImage = self.setupIncomingBubble()
JSQMessagesBubbleImageFactory 有創建聊天泡泡的圖片方法,。JSQMessagesViewController 甚至還有一個類別提供創建消息泡沫的顏色。
使用 outgoingMessagesBubbleImage (:with) 和incomingMessagesBubbleImage(: with)方法,我們可以創建輸入輸出圖像。這樣,我們就有了創建傳出和傳入消息氣泡所需的圖像視圖了!
先別太興奮了,我們還需要實現消息氣泡的委托方法。
設置氣泡圖像
為每個 message 設置 colored bubble imag ,我們需要重載被collectionView(_:messageBubbleImageDataForItemAt:)調用的 JSQMessagesCollectionViewDataSource 方法。
這要求數據源提供消息氣泡圖像數據,該數據對應于collectionView 中的 indexPath 中的 message 項。
在 ChatViewController 添加代碼:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAt indexPath: IndexPath!) -> JSQMessageBubbleImageDataSource! {
let message = messages[indexPath.item] // 1
if message.senderId == senderId { // 2
return outgoingBubbleImageView
} else { // 3
return incomingBubbleImageView
}
}
以上代碼注釋:
- 在這里檢索消息。
- 如果消息是由本地用戶發送的,則返回 outgoing image view。
- 相反,則返回 incoming image view.
移除頭像
JSQMessagesViewController 提供頭像,但是在匿名 RIC app 中我們不需要或者不想使用頭像。
在 ChatViewController 添加代碼:
override func collectionView(_ collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAt indexPath: IndexPath!) -> JSQMessageAvatarImageDataSource! {
return nil
}
為了移除 avatar image, 在每個 message’s avatar display 返回 nil 。
最后,在 viewDidLoad() 添加如下代碼:
// No avatars
collectionView!.collectionViewLayout.incomingAvatarViewSize = CGSize.zero
collectionView!.collectionViewLayout.outgoingAvatarViewSize = CGSize.zero
這將告訴布局,當沒有 avatars 時,avatar 大小為 CGSize.zero。
檢查 app 構建,我們可以導航到我們的一個頻道;
是時候開始對話并添加一些信息了!
創建消息
在 ChatViewController 中創建如下方法:
private func addMessage(withId id: String, name: String, text: String) {
if let message = JSQMessage(senderId: id, displayName: name, text: text) {
messages.append(message)
}
}
該方法創建了一個 JSQMessage,并添加到 messages 數據源中。
在 viewDidAppear(_:) 添加硬編碼消息:
// messages from someone else
addMessage(withId: "foo", name: "Mr.Bolt", text: "I am so fast!")
// messages sent from local sender
addMessage(withId: senderId, name: "Me", text: "I bet I can run faster than you!")
addMessage(withId: senderId, name: "Me", text: "I like to run!")
// animates the receiving of a new message on the view
finishReceivingMessage()
Build and run,我們將看到如下效果:
恩,文字讀起來有點不爽,它應該顯示黑色的。
消息氣泡文字
現在我們知道,如果想在 JSQMessagesViewController 做幾乎所有事情,我們只需要覆蓋一個方法。要設置文本顏色,請使用老式的collectionView(_:cellForItemAt:)。
在 ChatViewController 中添加如下方法:
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = super.collectionView(collectionView, cellForItemAt: indexPath) as! JSQMessagesCollectionViewCell
let message = messages[indexPath.item]
if message.senderId == senderId {
cell.textView?.textColor = UIColor.white
} else {
cell.textView?.textColor = UIColor.black
}
return cell
}
如果消息是由本地用戶發送的,設置文本顏色為白色。如果不是本地用戶發送的,設置文本顏色為黑色。
這是一個很不錯的聊天 app! 是時候讓它與 Firebase 一起工作了。
Sending Messages
在 ChatViewController.swift 中添加如下屬性:
private lazy var messageRef: FIRDatabaseReference = self.channelRef!.child("messages")
private var newMessageRefHandle: FIRDatabaseHandle?
這和我們在 ChannelListViewController 中添加的 channelRef、 channelRefHandle 屬性相似,我們應該很熟悉了。
接下來,刪除 ChatViewController 中的 viewDidAppear(_:) ,移除 stub test messages。
然后,重寫以下方法,使 "發送" 按鈕將消息保存到 Firebase 數據庫。
override func didPressSend(_ button: UIButton!, withMessageText text: String!, senderId: String!, senderDisplayName: String!, date: Date!) {
let itemRef = messageRef.childByAutoId() // 1
let messageItem = [ // 2
"senderId": senderId!,
"senderName": senderDisplayName!,
"text": text!,
]
itemRef.setValue(messageItem) // 3
JSQSystemSoundPlayer.jsq_playMessageSentSound() // 4
finishSendingMessage() // 5
}
注解:
- 使用 childByAutoId(),創建一個帶有惟一鍵的子引用。
- 然后創建一個字典來存儲消息。
- 接下來,保存新子位置上的值。
- 然后播放常規的 “消息發送” 聲音。
- 最后,完成 "發送" 操作并將輸入框重置為空。
Build and run; 打開 Firebase 應用程序指示板并單擊 Data 選項卡。在應用程序中發送一條消息,我們就可以看到實時顯示在儀表板上的消息了:
High five ! 我們已經可以像專業人員一樣將消息保存到 Firebase 數據庫了。現在消息還不會出現在屏幕上,接下來我們將處理它。
同步 Data Source
在 ChatViewController 中添加如下代碼:
private func observeMessages() {
messageRef = channelRef!.child("messages")
// 1.
let messageQuery = messageRef.queryLimited(toLast:25)
// 2. We can use the observe method to listen for new
// messages being written to the Firebase DB
newMessageRefHandle = messageQuery.observe(.childAdded, with: { (snapshot) -> Void in
// 3
let messageData = snapshot.value as! Dictionary<String, String>
if let id = messageData["senderId"] as String!, let name = messageData["senderName"] as String!, let text = messageData["text"] as String!, text.characters.count > 0 {
// 4
self.addMessage(withId: id, name: name, text: text)
// 5
self.finishReceivingMessage()
} else {
print("Error! Could not decode message data")
}
})
}
以下注解:
- 首先創建一個查詢,將同步限制到最后 25 條消息。
- 使用 .ChildAdded 觀察已經添加到和即將添加到 messages 位置每個子 item。
- 從 snapshot 中提取messageData。
- 使用 addMessage(withId:name:text) 方法添加新消息到數據源。
- 通知 JSQMessagesViewController,已經接收了消息。
接下來,在 viewDidLoad() 中調用方法: observeMessages()。
Build and run,我們將看到我們前面輸入和現在輸入的所有消息。
恭喜!我們已經有一個實時聊天應用了! 現在是做一些更高級的事情的時候了,比如在用戶輸入的時候檢測。
檢測用戶何時在輸入
這款應用程序最酷的功能之一就是看到 "用戶正在輸入" 的指示器。當小氣泡彈出時,你知道另一個用戶在鍵盤上打字。這個指標非常重要,因為它可以避免我們發送那些尷尬的 "你還在嗎?" 消息。
檢測打字有很多方法,但 textViewDidChange(_:) 是一個很好的檢查時機。將以下內容添加到ChatViewController的底部:
override func textViewDidChange(_ textView: UITextView) {
super.textViewDidChange(textView)
// If the text is not empty, the user is typing
print(textView.text != "")
}
要確定用戶是否在輸入,請檢查 textview . text 的值。如果這個值不是空字符串,那么您就知道用戶已經鍵入了一些東西。
通過 Firebase , 當用戶輸入時我們可以更新 Firebase 數據庫。然后,為了響應數據庫更新這個指示,我們可以顯示 “用戶正在輸入” 指示器。
為了實現目的,在 ChatViewController 中添加如下屬性:
private lazy var userIsTypingRef: FIRDatabaseReference =
self.channelRef!.child("typingIndicator").child(self.senderId) // 1
private var localTyping = false // 2
var isTyping: Bool {
get {
return localTyping
}
set {
// 3
localTyping = newValue
userIsTypingRef.setValue(newValue)
}
}
以下是我們需要了解的這些特性:
- 創建一個用于跟蹤本地用戶是否正在輸入的 Firebase 引用。
- 新增私有屬性,標記本地用戶是否在輸入。
- 每次更改時,使用計算屬性更新 localTyping 和 userIsTypingRef。
現在,添加如下方法:
private func observeTyping() {
let typingIndicatorRef = channelRef!.child("typingIndicator")
userIsTypingRef = typingIndicatorRef.child(senderId)
userIsTypingRef.onDisconnectRemoveValue()
}
這個方法創建一個名為 typingIndicator 的通道的子引用,它是我們更新用戶輸入狀態的地方。我們不希望這些數據在用戶注銷之后仍然逗留,因此我們可以在用戶使用后刪除它 onDisconnectRemoveValue()。
添加以下內容調用新方法:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
observeTyping()
}
替換 textViewDidChange(_:) 中的 print(textView.text != "") :
isTyping = textView.text != ""
這只是在用戶輸入時設置 isTyping。
最后,在 didPressSend(_:withMessageText:senderId:senderDisplayName:date:): 后面添加如下代碼:
isTyping = false
當按下 Send 按鈕時,這將重置輸入指示器。
Build and run,打開Firebase應用程序儀表板查看數據。當我們鍵入消息時,我們應該可以看到為用戶提供的類型指示器記錄更新:
我們現在已經知道什么時候用戶在輸入了,接下來是顯示指示器的時候了。
查詢正在輸入的用戶
"用戶正在輸入" 指示符應該在除本地用戶外任何用戶鍵入時顯示,因為本地用戶在鍵入時自己已經知道啦。
使用 Firebase query ,我們可以檢索當前正在鍵入的所有用戶。在 ChatViewController 中添加如下屬性:
private lazy var usersTypingQuery: FIRDatabaseQuery =
self.channelRef!.child("typingIndicator").queryOrderedByValue().queryEqual(toValue: true)
這個屬性保存了一個 FIRDatabaseQuery,它就像一個 Firebase 引用,但它是有序的。通過檢索所有正在輸入的用戶來初始化查詢。這基本上是說,“嘿,Firebase,查詢關鍵字 / typing 指示器,然后給我所有值為 true 的用戶。”
接下來,在 observeTyping() 添加如下代碼:
// 1
usersTypingQuery.observe(.value) { (data: FIRDataSnapshot) in
// 2 You're the only one typing, don't show the indicator
if data.childrenCount == 1 && self.isTyping {
return
}
// 3 Are there others typing?
self.showTypingIndicator = data.childrenCount > 0
self.scrollToBottom(animated: true)
}
注釋:
- 我們使用 .value 監聽狀態,當其值改變時,該 ompletion block 將被調用。
- 我們需要知道在查詢中有多少用戶,如果僅僅只有本地用戶,不顯示指示器。
- 如果有用戶,再設置指示器顯示。調用 scrolltobottom 動畫以確保顯示指示器。
在 build and run 之前,拿起一個物理 iOS 設備,測試這種情況需要兩個設備。一個用戶使用模擬器,另一個用戶使用真機。
現在,同時 build and run 模擬器和真機,當一個用戶輸入時,另外用戶可以看到指示器出現:
現在我們有了一個打字指示器,但我們還缺少一個現代通訊應用的一大特色功能——發送圖片!
發送圖片
要發送圖像,我們將遵循與發送文本相同的原則,其中有一個關鍵區別: 我們將使用 Firebase 存儲,而不是直接將圖像數據存儲在消息中,這更適合存儲音頻、視頻或圖像等大型文件。
在 ChatViewController.swift 中添加 Photos :
import Photos
接下來,添加如下屬性:
lazy var storageRef: FIRStorageReference = FIRStorage.storage().reference(forURL: "YOUR_URL_HERE")
這是一個 Firebase 存儲引用,概念上類似于我們已經看到的 Firebase 數據庫引用,但是對于存儲對象來說,用你的 Firebase 應用程序 URL 替換YOUR_URL_HERE,我們可以在你的應用程序控制臺中點擊存儲。
發送照片信息需要一點點的 smoke 和 mirrors ,而不是在這段時間阻塞用戶界面,這會讓你的應用感覺很慢。保存照片到Firebase 存儲返回一個URL,這可能需要幾秒鐘——如果網絡連接很差的話,可能需要更長的時間。我們會用一個假的URL發送照片信息,并在照片保存后更新消息。
添加如下屬性:
private let imageURLNotSetKey = "NOTSET"
并添加方法:
func sendPhotoMessage() -> String? {
let itemRef = messageRef.childByAutoId()
let messageItem = [
"photoURL": imageURLNotSetKey,
"senderId": senderId!,
]
itemRef.setValue(messageItem)
JSQSystemSoundPlayer.jsq_playMessageSentSound()
finishSendingMessage()
return itemRef.key
}
這很像我們之前實現的 didPressSend(_:withMessageText:senderId:senderDisplayName:date:) 方法。
現在,我們需要能夠在獲取映像的 Firebase 存儲 URL之后更新消息。添加以下:
func setImageURL(_ url: String, forPhotoMessageWithKey key: String) {
let itemRef = messageRef.child(key)
itemRef.updateChildValues(["photoURL": url])
}
接下來,我們需要允許用戶選擇要發送的圖像。幸運的是 JSQMessagesViewController 已經包含添加一個圖像到我們消息的 UI ,所以我們只需要實現對應的方法處理點擊就好了:
override func didPressAccessoryButton(_ sender: UIButton) {
let picker = UIImagePickerController()
picker.delegate = self
if (UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.camera)) {
picker.sourceType = UIImagePickerControllerSourceType.camera
} else {
picker.sourceType = UIImagePickerControllerSourceType.photoLibrary
}
present(picker, animated: true, completion:nil)
}
這里,如果設備支持拍照,將彈出攝像機,如果不支持,會彈出相冊。
接下來,當用戶選擇圖像,我們需要實現 UIImagePickerControllerDelegate方法來處理。將以下內容添加到文件的底部(在最后一個關閉括號之后):
// MARK: Image Picker Delegate
extension ChatViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [String : Any]) {
picker.dismiss(animated: true, completion:nil)
// 1
if let photoReferenceUrl = info[UIImagePickerControllerReferenceURL] as? URL {
// Handle picking a Photo from the Photo Library
// 2
let assets = PHAsset.fetchAssets(withALAssetURLs: [photoReferenceUrl], options: nil)
let asset = assets.firstObject
// 3
if let key = sendPhotoMessage() {
// 4
asset?.requestContentEditingInput(with: nil, completionHandler: { (contentEditingInput, info) in
let imageFileURL = contentEditingInput?.fullSizeImageURL
// 5
let path = "\(FIRAuth.auth()?.currentUser?.uid)/\(Int(Date.timeIntervalSinceReferenceDate * 1000))/\(photoReferenceUrl.lastPathComponent)"
// 6
self.storageRef.child(path).putFile(imageFileURL!, metadata: nil) { (metadata, error) in
if let error = error {
print("Error uploading photo: \(error.localizedDescription)")
return
}
// 7
self.setImageURL(self.storageRef.child((metadata?.path)!).description, forPhotoMessageWithKey: key)
}
})
}
} else {
// Handle picking a Photo from the Camera - TODO
}
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true, completion:nil)
}
}
注解:
- 首先,從 info dictionary 獲取圖像。
- 調用 sendPhotoMessage() 方法,保存圖像 URL 到 Firebase 數據庫。
- 接下來,我們將得到照片的 JPEG 表示,準備發送到 Firebase 存儲。
- 如前所述,根據用戶的惟一 id 和當前時間創建一個獨特的 URL。
- 創建一個 FIRStorageMetadata 對象并將元數據設置為 image / jpeg。
- 然后保存圖像到 Firebase 數據庫。
- 圖像被保存后,我們將再次調用 setImageURL() 方法。
幾近完美! 現在我們已經建立了可以將圖像數據保存到 Firebase 并將 URL 保存到消息數據存儲中的應用程序,但我們還沒有更新應用程序來顯示這些照片。接下來我們來解決這個問題。
展示圖像
首先,在 ChatViewController 中添加屬性:
private var photoMessageMap = [String: JSQPhotoMediaItem]()
它包含一個 jsqphotomediaitem 數組。
現在,我們需要為 addMessage (withId:name:text:) 創建一個兄弟方法。添加以下代碼:
private func addPhotoMessage(withId id: String, key: String, mediaItem: JSQPhotoMediaItem) {
if let message = JSQMessage(senderId: id, displayName: "", media: mediaItem) {
messages.append(message)
if (mediaItem.image == nil) {
photoMessageMap[key] = mediaItem
}
collectionView.reloadData()
}
}
在這里,如果圖像鍵尚未設置,則將 JSQPhotoMediaItem 存儲在新屬性中。這允許我們在稍后設置圖像時檢索并更新消息。
我們還需要能夠從 Firebase 數據庫獲取圖像數據,以便在UI中顯示它。添加以下方法:
private func fetchImageDataAtURL(_ photoURL: String, forMediaItem mediaItem: JSQPhotoMediaItem, clearsPhotoMessageMapOnSuccessForKey key: String?) {
// 1
let storageRef = FIRStorage.storage().reference(forURL: photoURL)
// 2
storageRef.data(withMaxSize: INT64_MAX){ (data, error) in
if let error = error {
print("Error downloading image data: \(error)")
return
}
// 3
storageRef.metadata(completion: { (metadata, metadataErr) in
if let error = metadataErr {
print("Error downloading metadata: \(error)")
return
}
// 4
if (metadata?.contentType == "image/gif") {
mediaItem.image = UIImage.gifWithData(data!)
} else {
mediaItem.image = UIImage.init(data: data!)
}
self.collectionView.reloadData()
// 5
guard key != nil else {
return
}
self.photoMessageMap.removeValue(forKey: key!)
})
}
}
注解:
- 獲取存儲映像的引用。
- 從存儲中獲取對象。
- 從存儲中獲取圖像元數據。
- 如果元數據顯示圖像是 GIF,我們需要使用 UIImage 類別,它通過 SwiftGifOrigin Cocapod 被拉進來。這是需要的,因為 UIImage 不處理 GIF 圖像。否則我們只需要用普通的 UIImage 就可以了。
- 最后,我們從 photoMessageMap 中刪除鍵,現在我們已經獲取了圖像數據。
最后,我們需要更新 observeMessages()。在 if 語句中,但在 else 條件之前,添加以下測試:
else if let id = messageData["senderId"] as String!,
let photoURL = messageData["photoURL"] as String! { // 1
// 2
if let mediaItem = JSQPhotoMediaItem(maskAsOutgoing: id == self.senderId) {
// 3
self.addPhotoMessage(withId: id, key: snapshot.key, mediaItem: mediaItem)
// 4
if photoURL.hasPrefix("gs://") {
self.fetchImageDataAtURL(photoURL, forMediaItem: mediaItem, clearsPhotoMessageMapOnSuccessForKey: nil)
}
}
}
讓我們逐行解釋:
- 首先,檢查你是否有一個photoURL集。
- 如果可以,創建一個新的 JSQPhotoMediaItem。這個對象封裝了消息中的富媒體——正是你所需要的!
- 調用 addPhotoMessage 方法。
- 最后,檢查一下,確保 photoURL 包含一個 Firebase 存儲對象的前綴。如果是,獲取圖像數據。
現在只剩下最后一件事了,你能猜到是什么么?
當你在解碼照片信息時,你只是在你第一次觀察圖像數據時才這樣做。但是,你還需要觀察稍后發生的消息的任何更新,比如在將圖像 URL 保存到存儲后更新它。
添加下面屬性:
private var updatedMessageRefHandle: FIRDatabaseHandle?
在 observeMessages() 底部添加如下代碼:
// We can also use the observer method to listen for
// changes to existing messages.
// We use this to be notified when a photo has been stored
// to the Firebase Storage, so we can update the message data
updatedMessageRefHandle = messageRef.observe(.childChanged, with: { (snapshot) in
let key = snapshot.key
let messageData = snapshot.value as! Dictionary<String, String> // 1
if let photoURL = messageData["photoURL"] as String! { // 2
// The photo has been updated.
if let mediaItem = self.photoMessageMap[key] { // 3
self.fetchImageDataAtURL(photoURL, forMediaItem: mediaItem, clearsPhotoMessageMapOnSuccessForKey: key) // 4
}
}
})
注解:
- 從 Firebase 快照中獲取消息數據字典。
- 檢查字典是否有一個 photoURL 鍵集。
- 如果是這樣,則從緩存中提取 JSQPhotoMediaItem。
- 最后,獲取圖像數據并使用圖像更新消息!
當 ChatViewController 消失時,我們需要做的最后一件事就是整理和清理。添加以下方法:
deinit {
if let refHandle = newMessageRefHandle {
messageRef.removeObserver(withHandle: refHandle)
}
if let refHandle = updatedMessageRefHandle {
messageRef.removeObserver(withHandle: refHandle)
}
}
Build and run 應用程序; 我們就應該能夠在聊天中點擊小的 paperclip 圖標發送照片或圖片信息了。注意這些消息何時顯示一個等待的小 spinner—— 當我們的應用程序保存照片數據到 Firebase 存儲的時候。
Kaboom! 我們剛剛做了一個說大也大說小也小、實時的、用戶可以輸入照片和 GIF 的聊天應用程序。
Where to Go From Here?
Demo 下載地址: completed project
我們現在知道 Firebase 和 JSQMessagesViewController 的基本知識,但還有很多你可以做,包括 one-to-one messaging、social authentication、頭像顯示等。
想更多了解,請查閱 Firebase iOS documentation.
-- 2017.10.24
上海 虹橋V1