Firebase 教程: iOS 實時聊天

原文: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 你應該很熟悉了。

Real time chat app

在此教程,您將學習到如下內容:

  1. 使用 CocoaPods 設置 Firebase SDK 和 JSQMessagesViewController。
  2. 使用 Firebase 數據庫實時同步數據。
  3. Firebase 匿名身份驗證。
  4. 使用 JSQMessagesViewController 做為完整的聊天界面。
  5. 指示用戶何時輸入。
  6. 使用 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
  })
}

注釋如下:

  1. 首先,確保 name field 非空。
    2.使用 Firebase Auth API 匿名登錄,該方法帶了一個方法塊兒,方法塊兒傳遞 user 和 error 信息。
  2. 在完成方法塊里,檢查是否有認證錯誤,如果有,終止運行。
  3. 最后,如果沒有錯誤異常,進入 ChannelListViewController 頁面。

Build and run,輸入你的名字,然后進入 app。

Empty channel list
創建 Channels 列表

一旦用戶登錄了, app 導航到 ChannelListViewController 頁面, 該頁面展示給用戶當前頻道列表, 給他們提供選擇創建新通道。該頁面使用兩個 section 的表視圖。第一個 section 提供了一個表單,用戶可以在其中創建一個新的通道,第二 section 列出所有已知通道。

Channel list view

本小節,我們將學到:

  1. 保存數據到 Firebase 數據庫
  2. 監聽保存到數據庫的新數據。

在 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    

注釋如下 :

  1. 添加一個存儲 sender name 的屬性。
  2. 添加一個 text field ,稍后我們會使用它添加新的 Channels。
  3. 添加一個空的 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 的人來說,這應該是非常熟悉的,但簡單地說幾點:

  1. 設置 Sections。請記住,第一部分將包含一個用于添加新通道的表單,第二部分將顯示一個通道列表。
  2. 為每個部分設置行數。第一部分設置為 1,第二部分設置個數為通道的個數。
  3. 定義每個單元格的內容。對于第一個部分,我們將 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 一起工作了。 :]

Dummy channels
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")
    }
  })
}

代碼解釋:

  1. 我們在通道引用上調用 observe:with: 方法,將句柄存儲到引用。每當在數據庫中添加新的通道時,就調用 completion block 。
  2. completion 后接收到一個 FIRDataSnapshot (存儲在快照中),其中包含數據和其它有用的方法。
  3. 我們將數據從快照中提取出來,如果成功,創建一個通道模型并將其添加到我們的通道數組中。
// 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
  }
}

下面是詳細解釋:

  1. 首先檢查 text field 是否擁有一個 channel name.
  2. 使用 childByAutoId() 唯一標志 key 創建一個通道引用。
  3. 創建一個字典,以此保存通道的數據。[String: AnyObject] 是類似 JSON 的對象。
  4. 最后,在這個新的通道上設置名稱,它將自動保存到Firebase !

Build and run 我們的 app ,創建一些 channels。

Create 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 定制聊天控制類,所以我們不需要再創建自己的了!

這部分教程,我們將關注四點:

  1. 創建消息數據。
  2. 創建消息泡沫。
  3. 刪除頭像支持。
  4. 改變 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
}

注釋:

  1. 從 segue 獲取目標視圖控制器并將其轉換為 UINavigationController。
  2. 強制轉換 UINavigationController 的第一個view controller 為 ChannelListViewController。
  3. 設置 ChannelListViewController 的senderDisplayName 為 nameField 中提供的用戶名。

返回 ChatViewController.swift,在 viewDidLoad() 方法最下方添加如下代碼:

self.senderId = FIRAuth.auth()?.currentUser?.uid

這將基于已登錄的 Firebase 用戶設置 senderId。

Build and run 我們的 app 并導航到一個 channel 頁面。

Empty Channel

通過簡單地繼承 JSQMessagesViewController,我們得到一個完整的聊天界面。:]

Fine chat app
設置 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
  }
}

以上代碼注釋:

  1. 在這里檢索消息。
  2. 如果消息是由本地用戶發送的,則返回 outgoing image view。
  3. 相反,則返回 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 構建,我們可以導航到我們的一個頻道;

Empty channel

是時候開始對話并添加一些信息了!

創建消息

在 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
}

注解:

  1. 使用 childByAutoId(),創建一個帶有惟一鍵的子引用。
  2. 然后創建一個字典來存儲消息。
  3. 接下來,保存新子位置上的值。
  4. 然后播放常規的 “消息發送” 聲音。
  5. 最后,完成 "發送" 操作并將輸入框重置為空。

Build and run; 打開 Firebase 應用程序指示板并單擊 Data 選項卡。在應用程序中發送一條消息,我們就可以看到實時顯示在儀表板上的消息了:

Sending a message

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")
   }
 })
}

以下注解:

  1. 首先創建一個查詢,將同步限制到最后 25 條消息。
  2. 使用 .ChildAdded 觀察已經添加到和即將添加到 messages 位置每個子 item。
  3. 從 snapshot 中提取messageData。
  4. 使用 addMessage(withId:name:text) 方法添加新消息到數據源。
  5. 通知 JSQMessagesViewController,已經接收了消息。

接下來,在 viewDidLoad() 中調用方法: observeMessages()。

Build and run,我們將看到我們前面輸入和現在輸入的所有消息。

Messages from firebase

恭喜!我們已經有一個實時聊天應用了! 現在是做一些更高級的事情的時候了,比如在用戶輸入的時候檢測。

檢測用戶何時在輸入

這款應用程序最酷的功能之一就是看到 "用戶正在輸入" 的指示器。當小氣泡彈出時,你知道另一個用戶在鍵盤上打字。這個指標非常重要,因為它可以避免我們發送那些尷尬的 "你還在嗎?" 消息。

檢測打字有很多方法,但 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)
 }
}

以下是我們需要了解的這些特性:

  1. 創建一個用于跟蹤本地用戶是否正在輸入的 Firebase 引用。
  2. 新增私有屬性,標記本地用戶是否在輸入。
  3. 每次更改時,使用計算屬性更新 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應用程序儀表板查看數據。當我們鍵入消息時,我們應該可以看到為用戶提供的類型指示器記錄更新:

Typing indicator

我們現在已經知道什么時候用戶在輸入了,接下來是顯示指示器的時候了。

查詢正在輸入的用戶

"用戶正在輸入" 指示符應該在除本地用戶外任何用戶鍵入時顯示,因為本地用戶在鍵入時自己已經知道啦。

使用 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)
}

注釋:

  1. 我們使用 .value 監聽狀態,當其值改變時,該 ompletion block 將被調用。
  2. 我們需要知道在查詢中有多少用戶,如果僅僅只有本地用戶,不顯示指示器。
  3. 如果有用戶,再設置指示器顯示。調用 scrolltobottom 動畫以確保顯示指示器。

在 build and run 之前,拿起一個物理 iOS 設備,測試這種情況需要兩個設備。一個用戶使用模擬器,另一個用戶使用真機。

現在,同時 build and run 模擬器和真機,當一個用戶輸入時,另外用戶可以看到指示器出現:

Multi-user typing indicator

現在我們有了一個打字指示器,但我們還缺少一個現代通訊應用的一大特色功能——發送圖片!

發送圖片

要發送圖像,我們將遵循與發送文本相同的原則,其中有一個關鍵區別: 我們將使用 Firebase 存儲,而不是直接將圖像數據存儲在消息中,這更適合存儲音頻、視頻或圖像等大型文件。

在 ChatViewController.swift 中添加 Photos :

import Photos

接下來,添加如下屬性:

lazy var storageRef: FIRStorageReference = FIRStorage.storage().reference(forURL: "YOUR_URL_HERE")

這是一個 Firebase 存儲引用,概念上類似于我們已經看到的 Firebase 數據庫引用,但是對于存儲對象來說,用你的 Firebase 應用程序 URL 替換YOUR_URL_HERE,我們可以在你的應用程序控制臺中點擊存儲。


Firebase console storage

發送照片信息需要一點點的 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)
  }
}

注解:

  1. 首先,從 info dictionary 獲取圖像。
  2. 調用 sendPhotoMessage() 方法,保存圖像 URL 到 Firebase 數據庫。
  3. 接下來,我們將得到照片的 JPEG 表示,準備發送到 Firebase 存儲。
  4. 如前所述,根據用戶的惟一 id 和當前時間創建一個獨特的 URL。
  5. 創建一個 FIRStorageMetadata 對象并將元數據設置為 image / jpeg。
  6. 然后保存圖像到 Firebase 數據庫。
  7. 圖像被保存后,我們將再次調用 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!)
    })
  }
}

注解:

  1. 獲取存儲映像的引用。
  2. 從存儲中獲取對象。
  3. 從存儲中獲取圖像元數據。
  4. 如果元數據顯示圖像是 GIF,我們需要使用 UIImage 類別,它通過 SwiftGifOrigin Cocapod 被拉進來。這是需要的,因為 UIImage 不處理 GIF 圖像。否則我們只需要用普通的 UIImage 就可以了。
  5. 最后,我們從 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)
    }
  }
}

讓我們逐行解釋:

  1. 首先,檢查你是否有一個photoURL集。
  2. 如果可以,創建一個新的 JSQPhotoMediaItem。這個對象封裝了消息中的富媒體——正是你所需要的!
  3. 調用 addPhotoMessage 方法。
  4. 最后,檢查一下,確保 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
    }
  }
})

注解:

  1. 從 Firebase 快照中獲取消息數據字典。
  2. 檢查字典是否有一個 photoURL 鍵集。
  3. 如果是這樣,則從緩存中提取 JSQPhotoMediaItem。
  4. 最后,獲取圖像數據并使用圖像更新消息!

當 ChatViewController 消失時,我們需要做的最后一件事就是整理和清理。添加以下方法:

deinit {
  if let refHandle = newMessageRefHandle {
    messageRef.removeObserver(withHandle: refHandle)
  }
  
  if let refHandle = updatedMessageRefHandle {
    messageRef.removeObserver(withHandle: refHandle)
  }
}

Build and run 應用程序; 我們就應該能夠在聊天中點擊小的 paperclip 圖標發送照片或圖片信息了。注意這些消息何時顯示一個等待的小 spinner—— 當我們的應用程序保存照片數據到 Firebase 存儲的時候。

Send photos

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容