美文网首页
Firebase 教程: iOS 实时聊天

Firebase 教程: iOS 实时聊天

作者: _浅墨_ | 来源:发表于2017-10-24 20:37 被阅读1254次

    原文: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 信息。
    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
    }
    

    如果消息是由本地用户发送的,设置文本颜色为白色。如果不是本地用户发送的,设置文本颜色为黑色。

    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

    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

    相关文章

      网友评论

          本文标题:Firebase 教程: iOS 实时聊天

          本文链接:https://www.haomeiwen.com/subject/vofhsxtx.html