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 文件,添加如下依赖代码:

1
2
3
4
pod 'Firebase/Storage'
pod 'Firebase/Auth'
pod 'Firebase/Database'
pod 'JSQMessagesViewController'

保存文件,命令行执行如下命令:

1
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:

1
import Firebase

要登录聊天,app 需要使用 Firebase 身份验证服务进行身份验证。将以下代码添加到loginDidTouch(_:):

1
2
3
4
5
6
7
8
9
10
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 的头部添加如下代码:

1
2
3
4
5
6
import Firebase

enum Section: Int {
case createNewChannelSection = 0
case currentChannelsSection
}

紧随导入语句之后的 enum 中包含两个表视图 section 。

接下来,在类内,添加如下代码:

1
2
3
4
// 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 中添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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 标签设置为通道名称。

为了确保这一切正常工作,请在属性下面添加以下代码:

1
2
3
4
5
6
7
8
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 对象的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"channels": {
"name": "Channel 1"
"messages": {
"1": {
"text": "Hey person!",
"senderName": "Alice"
"senderId": "foo"
},
"2": {
"text": "Yo!",
"senderName": "Bob"
"senderId": "bar"
}
}
}
}

Firebase 数据库支持非规范化的数据结构,因此可以为每个消息项包含 senderId。一个非规范化的数据结构意味着我们将复制大量的数据,但好处是可以更快的检索数据。

实时 Channel 同步

首先,删除上面添加的viewDidAppear(_:)代码,然后在其他以下属性中添加以下属性:

1
2
private lazy var channelRef: FIRDatabaseReference = FIRDatabase.database().reference().child("channels")
private var channelRefHandle: FIRDatabaseHandle?

channelRef 将用于存储对数据库中通道列表的引用;channelRefHandle 将为引用保存一个句柄,以便以后可以删除它。

接下来,我们需要查询Firebase数据库,并得到一个在我们的表视图中显示的通道列表。添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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. 我们将数据从快照中提取出来,如果成功,创建一个通道模型并将其添加到我们的通道数组中。
1
2
3
4
5
6
7
8
9
10
11
12
// 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,所以只需向我们的类添加以下代码就好了:

1
2
3
4
5
6
7
8
9
10
// 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

所有内容都应该按照预期运行,但我们还没有实现当用户点击时可以访问其中一个通道。让我们添加以下代码来解决这个问题:

1
2
3
4
5
6
7
// 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 ,添加如下引入:

1
2
import Firebase
import JSQMessagesViewController

将继承类 UIViewController 改为 JSQMessagesViewController:

1
final class ChatViewController: JSQMessagesViewController {

在 ChatViewController 头部,定义如下属性:

1
2
3
4
5
6
var channelRef: FIRDatabaseReference?
var channel: Channel? {
didSet {
title = channel?.name
}
}

既然 ChatViewController 继承自JSQMessagesViewController , 我们需要设置 senderId 和 senderDisplayName 的初始值,以使 app 可以唯一标识消息的发送者——即使它不知道那个人具体是谁。

这些需要在 view controller 首次实例化时设置。最好的设置时刻是当 segue 即将 prepare 时。回到ChannelListViewController, 添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 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,添加如下方法:

1
2
3
4
5
6
7
8
// 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() 方法最下方添加如下代码:

1
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 顶部,添加如下属性:

1
var messages = [JSQMessage]()

messages 是应用程序中存储 JSQMessage 各种实例的数组。

添加如下代码:

1
2
3
4
5
6
7
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 中添加如下代码:

1
2
3
4
5
6
7
8
9
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())
}

然后在头部添加如下属性:

1
2
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 添加代码:

1
2
3
4
5
6
7
8
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 添加代码:

1
2
3
override func collectionView(_ collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAt indexPath: IndexPath!) -> JSQMessageAvatarImageDataSource! {
return nil
}

为了移除 avatar image, 在每个 message’s avatar display 返回 nil 。

最后,在 viewDidLoad() 添加如下代码:

1
2
3
// No avatars
collectionView!.collectionViewLayout.incomingAvatarViewSize = CGSize.zero
collectionView!.collectionViewLayout.outgoingAvatarViewSize = CGSize.zero

这将告诉布局,当没有 avatars 时,avatar 大小为 CGSize.zero。

检查 app 构建,我们可以导航到我们的一个频道;

Empty channel

是时候开始对话并添加一些信息了!

创建消息

在 ChatViewController 中创建如下方法:

1
2
3
4
5
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(_:) 添加硬编码消息:

1
2
3
4
5
6
7
// 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 中添加如下方法:

1
2
3
4
5
6
7
8
9
10
11
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 中添加如下属性:

1
2
private lazy var messageRef: FIRDatabaseReference = self.channelRef!.child("messages")
private var newMessageRefHandle: FIRDatabaseHandle?

这和我们在 ChannelListViewController 中添加的 channelRef、 channelRefHandle 属性相似,我们应该很熟悉了。

接下来,删除 ChatViewController 中的 viewDidAppear(_:) ,移除 stub test messages。

然后,重写以下方法,使 “发送” 按钮将消息保存到 Firebase 数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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的底部:

1
2
3
4
5
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 中添加如下属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
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。

现在,添加如下方法:

1
2
3
4
5
private func observeTyping() {
let typingIndicatorRef = channelRef!.child("typingIndicator")
userIsTypingRef = typingIndicatorRef.child(senderId)
userIsTypingRef.onDisconnectRemoveValue()
}

这个方法创建一个名为 typingIndicator 的通道的子引用,它是我们更新用户输入状态的地方。我们不希望这些数据在用户注销之后仍然逗留,因此我们可以在用户使用后删除它 onDisconnectRemoveValue()。

添加以下内容调用新方法:

1
2
3
4
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
observeTyping()
}

替换 textViewDidChange(_:) 中的 print(textView.text != “”) :

1
isTyping = textView.text != ""

这只是在用户输入时设置 isTyping。

最后,在 didPressSend(_:withMessageText:senderId:senderDisplayName:date:): 后面添加如下代码:

1
isTyping = false

当按下 Send 按钮时,这将重置输入指示器。

Build and run,打开Firebase应用程序仪表板查看数据。当我们键入消息时,我们应该可以看到为用户提供的类型指示器记录更新:

Typing indicator

我们现在已经知道什么时候用户在输入了,接下来是显示指示器的时候了。

查询正在输入的用户

“用户正在输入” 指示符应该在除本地用户外任何用户键入时显示,因为本地用户在键入时自己已经知道啦。

使用 Firebase query ,我们可以检索当前正在键入的所有用户。在 ChatViewController 中添加如下属性:

1
2
private lazy var usersTypingQuery: FIRDatabaseQuery = 
self.channelRef!.child("typingIndicator").queryOrderedByValue().queryEqual(toValue: true)

这个属性保存了一个 FIRDatabaseQuery,它就像一个 Firebase 引用,但它是有序的。通过检索所有正在输入的用户来初始化查询。这基本上是说,“嘿,Firebase,查询关键字 / typing 指示器,然后给我所有值为 true 的用户。”

接下来,在 observeTyping() 添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
// 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 :

1
import Photos

接下来,添加如下属性:

1
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发送照片信息,并在照片保存后更新消息。

添加如下属性:

1
private let imageURLNotSetKey = "NOTSET"

并添加方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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之后更新消息。添加以下:

1
2
3
4
func setImageURL(_ url: String, forPhotoMessageWithKey key: String) {
let itemRef = messageRef.child(key)
itemRef.updateChildValues(["photoURL": url])
}

接下来,我们需要允许用户选择要发送的图像。幸运的是 JSQMessagesViewController 已经包含添加一个图像到我们消息的 UI ,所以我们只需要实现对应的方法处理点击就好了:

1
2
3
4
5
6
7
8
9
10
11
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方法来处理。将以下内容添加到文件的底部(在最后一个关闭括号之后):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 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 中添加属性:

1
private var photoMessageMap = [String: JSQPhotoMediaItem]()

它包含一个 jsqphotomediaitem 数组。

现在,我们需要为 addMessage (withId:name:text:) 创建一个兄弟方法。添加以下代码:

1
2
3
4
5
6
7
8
9
10
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中显示它。添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 条件之前,添加以下测试:

1
2
3
4
5
6
7
8
9
10
11
12
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 保存到存储后更新它。

添加下面属性:

1
private var updatedMessageRefHandle: FIRDatabaseHandle?

在 observeMessages() 底部添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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 消失时,我们需要做的最后一件事就是整理和清理。添加以下方法:

1
2
3
4
5
6
7
8
9
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

Joey Chang wechat
扫描二维码,关注我的公众号
坚持原创技术分享,您的支持将鼓励我继续创作!