Firebase 教程: iOS 實時聊天

躍然發表於2017-10-25

原文: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 ,你將看到如下介面:

Login Screen

允許匿名認證

Firebase允許使用者通過電子郵件或社交帳戶登入,但它也可以匿名地對使用者進行身份驗證,為使用者提供唯一的識別符號,而不需要了解他們任何資訊。

要設定匿名驗證,開啟 Firebase 應用程式的 Dashboard,選擇左側的 Auth 選項,單擊 “Sign-In” 方法,然後選擇“ Anonymous”,開啟 “ Enable” 按鈕,然後單擊 “Save”。

Enable anonymous auth

像這樣,我們啟用了超級祕密隱形模式 ! 好吧,雖然這只是匿名身份驗證,但它仍然很酷。

Super secret stealth mode achieved

登入

開啟 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 資訊。
3. 在完成方法塊裡,檢查是否有認證錯誤,如果有,終止執行。
4. 最後,如果沒有錯誤異常,進入 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
}

如果訊息是由本地使用者傳送的,設定文字顏色為白色。如果不是本地使用者傳送的,設定文字顏色為黑色。

Incoming messages

這是一個很不錯的聊天 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](http://upload-images.jianshu.io/upload_images/130752-6966090a573224fc.gif?imageMogr2/auto-orient/strip)

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

以下註解:

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

接下來,在 viewDidLoad() 中呼叫方法: observeMessages()。

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

![Messages from firebase](http://upload-images.jianshu.io/upload_images/130752-402d03270957ef56.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

##### 檢測使用者何時在輸入

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

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

相關文章