Help us understand the problem. What is going on with this article?

PringとMuniを使ってリアルタイムチャットを爆速で実装する(中級者向け)

More than 1 year has passed since last update.

こんにちは!
Diverse Advent Calendar 2018の23日目を担当するiOSエンジニアの中川です。
普段は株式会社DiverseYYCの開発を担当しながら個人でもiOSアプリ開発の仕事をしています。

さて、今回は「PringとMuniを使ってリアルタイムチャットを爆速で実装する」というテーマで記事を書かせて頂きます。
チャットの実装ってかなり工数重そうですよね、、、。
Firebaseを使えばサーバーの開発がいらないので工数は抑えられると思いますが、データの持たせ方を考えたり、チャット画面のUIを作ったりとやることはそれなりに多い気がしてます。

この記事を元に実装すれば工数のかかるリアルタイムチャットを簡単かつ爆速で実装することができます。
チャットの実装に困っている方、これから実装しようとしている方の手助けになれれば幸いです。

注意

この記事ではFirebaseの導入とユーザー認証が終わっている前提で説明させて頂きます。

Firebaseの導入についてはこちら

ユーザー認証についてはこちら

を参考にしてください。

またDBはCloudFireStoreを使用します。

準備するもの

今回チャットを爆速で実装するにあたって肝になるのが下記2つのライブラリです。

PringはFirebaseをかなり扱いやすくしてくれる神ライブラリです。
Pringについてはこちらをご覧ください。

Muniについてはこの記事で解説していきます。

開発環境

  • macOS Mojave
  • Xcode 10.1
  • Swift4

実装しよう

まずCocoapodsで必要なライブラリをインストール

pod 'Pring'
pod 'Muni'

必要なモデルを実装

今回の主な登場人物はこちらです。

  • User (ユーザー情報)
  • Room (自分と相手がいるRoomの情報)
  • Transcript (メッセージの情報)

User

UserにはUserProtocolを準拠させます。

import Pring
import Muni

extension Firebase {

    @objcMembers
    class User: Object, UserProtocol {

        dynamic var name: String?
        dynamic var thumbnailImage: File?
    }
}

Room

RoomにはRoomProtocolを準拠させます。

import Pring
import Muni
import Firebase

extension Firebase {

    @objcMembers
    class Room: Object, RoomProtocol {

        typealias TranscriptType = Firebase.Transcript

        dynamic var name: String?

        dynamic var thumbnailImage: File?

        dynamic var members: [String] = []

        dynamic var viewers: [String] = []

        dynamic var lastViewedTimestamps: [String : Timestamp] = [:]

        dynamic var recentTranscript: [String : Any] = [:]

        dynamic var transcripts: NestedCollection<Firebase.Transcript> = []

        dynamic var isMessagingEnabled: Bool = false

        dynamic var isHidden: Bool = false

        dynamic var config: [String : Any] = [:]
    }
}

Transcript

TranscriptにはTranscriptProtocolを準拠させましょう。

import Pring
import Muni
import FirebaseFirestore

extension Firebase {

    @objcMembers
    class Transcript: Object, TranscriptProtocol {

        dynamic var to: Relation<Firebase.Room> = .init()

        dynamic var from: Relation<Firebase.User> = .init()

        dynamic var text: String?

        dynamic var image: File?

        dynamic var video: File?

        dynamic var audio: File?

        dynamic var location: GeoPoint?

        dynamic var sticker: String?

        dynamic var imageMap: [File] = []
    }
}

いずれもMuniのProtocolを準拠させると補完で必要なプロパティが出てくるので、出てきたプロパティにdynamicをつけて初期値を設定してください。(※dynamicをつけないとFirebaseに保存されません。)
TimestampとGeoPointという型がFirebaseとFirebaseFirestoreが持っている型なので、それぞれimportが必要です。

以上で必要なモデルの実装は終わりです!

メッセージ一覧画面を作る

次はメッセージの一覧を表示するViewControllerを作ります。
こちらもMuniが必要なものを提供しているのでそれを使って実装します。

Muni<Firebase.User, Firebase.Room, Firebase.Transcript>.InboxViewControllerを継承したサブクラスを作ります。

import Muni
import Pring

class BoxViewController: Muni<Firebase.User, Firebase.Room, Firebase.Transcript>.InboxViewController {

    override func loadView() {
        super.loadView()

        self.title = "メッセージ"
        self.tableView.tableFooterView = UIView()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        self.dataSource.onCompleted { [weak self] (_, room) in
            self?.view.setNeedsDisplay()
            self?.tableView.reloadData()
        }

        // ※親クラスのlisten()を呼び出さないとデータが更新されないので注意
        self.listen()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.tableView.reloadData()
    }

    // MuniのMessagesViewControllerを継承したMessageViewControllerを返す
    override func messageViewController(with room: Firebase.Room) -> Muni<Firebase.User, Firebase.Room, Firebase.Transcript>.MessagesViewController {
        let messageViewController = MessageViewController(room: room)
        messageViewController.hidesBottomBarWhenPushed = true
        return messageViewController
    }

注意点はコメントで記載しているのでご確認ください!
Muniが提供しているデフォルトのCellを使う場合の実装はこれだけです。
ただこの場合だと表示されるCellに相手のユーザー名とサムネイルが入らないので下記のように

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

をoverrideして下記のように自分でユーザ名とサムネイルを入れて上げる必要があります。

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        // DataSourceからRoomを取得
        let room: Firebase.Room = self.dataSource[indexPath.item]
        // 特にカスタムせずデフォルトのCellを使う場合はIdentifierを"InboxViewCell"を指定
        let cell: InboxViewCell = tableView.dequeueReusableCell(withIdentifier: "InboxViewCell", for: indexPath) as! InboxViewCell

        if let meID: String = UserManager.default.me?.id, let to: [String : Any] = room.config[meID] as? [String : Any] {
            Firebase.User.get(to["id"] as! String) { (user, error) in
                if let error = error {
                    print(error)
                    return
                }
                if let user = user {
                    cell.nameLabel.text = user.name
                    if let ref = user.thumbnailImage?.ref {
                        // ここで使っているsd_setImageはFirebaseが提供しているpod 'FirebaseUI/Storage'のものです
                        // SDWebImageのものではないのでご注意ください
                        cell.thumbnailImageView.sd_setImage(with: ref)
                    }
                }
            }
        }
        cell.dateLabel.text = self.dateFormatter.string(from: room.updatedAt.dateValue())

        if let name: String = room.name {
            cell.nameLabel.text = name
        } else if let config: [String: Any] = room.config[self.userID] as? [String: Any] {
            if let nameKey: String = Firebase.Room.configNameKey {
                cell.nameLabel.text = config[nameKey] as? String
            }
        }

        if let text: String = room.recentTranscript["text"] as? String {
            cell.messageLabel?.text = text
        }

        if room.viewers.contains(self.userID) {
            cell.format = .normal
        } else {
            cell.format = .bold
        }
        return cell
    }

今回はべた書きしてますが、サブクラスにしたい場合はInboxViewCellを継承すればOKです。

メッセージ画面を作る

続いてメッセージ画面を実装します。
こちらに関してもMuniのクラスを継承することで簡単に実装できます。

Muni<Firebase.User, Firebase.Room, Firebase.Transcript>.MessagesViewControllerを継承したサブクラスを作ります。

import Muni
import Toolbar
import FirebaseAuth
import Firebase

class MessageViewController: Muni<Firebase.User, Firebase.Room, Firebase.Transcript>.MessagesViewController {

    // メッセージを送信するボタン
    var sendBarItem: ToolbarItem!

    // senderIDをoverrideして下記のように自分のuidを返すようにします
    // これを実装しないとエラーになるので注意
    override var senderID: String? {
        return Auth.auth().currentUser!.uid
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // titleに入れる相手のユーザー名とサムネイルを取得してViewに反映します
        // 相手のユーザーはroom.memberから自分のIDじゃないものを取って相手のIDとしています
        guard let user = UserManager.default.me else { return }
        let partnerID: String = room.members.filter { $0 != user.id }.first!
        Firebase.User.get(partnerID) { [weak self] (user, error) in
            guard let user = user else { return }
            self?.titleView?.nameLabel.text = user.name
            guard let thumbnailImageView = self?.titleView?.thumbnailImageView else { return }
            thumbnailImageView.clipsToBounds = true
            thumbnailImageView.layer.cornerRadius = thumbnailImageView.frame.width/2
            if let ref = user.thumbnailImage?.ref {
                thumbnailImageView.sd_setImage(with: ref)
            }
        }

        // 送信ボタンのtitleとボタンを押したときのアクションを設定
        // アクションは親クラスのsendを呼び出しましょう
        self.sendBarItem = ToolbarItem(title: "送信", target: self, action: #selector(send))

        // Toolbarの設定
        // 下記のようにsetItemsにメッセージを入力するtextViewと送信するボタンを設定します。
        self.toolBar.setItems([ToolbarItem(customView: self.textView), self.sendBarItem], animated: false)

        // こちらもlistenしないと値が更新されないので注意
        self.listen()
    }

    // textViewのテキストがnil、空じゃないときに送信できるようにします。
    override func transcript(_ transcript: Firebase.Transcript, shouldSendTo room: Firebase.Room) -> Bool {
        if let text: String = textView.text, text.isEmpty {
            return false
        } else {
            return true
        }
    }

    // メッセージが送信されるときにtranscript.textに入力されたテキストをいれてtextView.textをnilにします。
    override func transcript(_ transcript: Firebase.Transcript, willSendTo room: Firebase.Room, with batch: WriteBatch) {
        if self.textView.text.isEmpty { return }
        transcript.text = self.textView.text
        self.textView.text = nil
    }
}

複雑そうなメッセージ画面もたったこれだけで実装完了です。

メッセージする相手とのRoomのデータを保存する

今回は下記のようにUser(メッセージする相手)を渡してRoomを作成するようにしました。

private func roomCreate(to: Firebase.User) {
        let room: Firebase.Room = Firebase.Room()
        let from: Firebase.User = UserManager.default.me!
        room.members = [from.id, to.id]
        room.config[to.id] = ["id": from.id]
        room.config[from.id] = ["id": to.id]
        room.save()
    }

保存されたデータを見てみよう

FireStoreに保存されたデータはこのようになっています。

Room

スクリーンショット 2018-12-21 21.17.18.png

Transcript

Transcriptは新規メッセージが保存されるとTranscripsに追加されていきます。
スクリーンショット 2018-12-21 21.06.13.png

カスタマイズについて

テキスト以外の送信

MuniのTranscriptProtocolにはimage, video, Audioなどといったテキスト以外を保存するプロパティがありますので画像や動画の送信も可能です。
その場合は
相手が送信したメッセージ: MessageViewLeftCell
自分が送信したメッセージ: MessageViewRightCell

を継承したサブクラスを作って画像や動画を表示するUIを実装してください。

Toolbar

Toolbarのカスタムについては下記のUIViewを渡すイニシャライザが用意されているのでこちらを使いましょう。

    public convenience init(customView: UIView) {
        self.init(frame: .zero)
        self.addSubview(customView)
        customView.translatesAutoresizingMaskIntoConstraints = false
        self.customView = customView
    }

終わりに

いかがだったでしょうか?
本来ならかなり工数のかかるチャットですが、FirebaseとPring, Muniを使うことでたったこれだけの実装でリアルタイムチャットを作ることができます!
ユーザー登録までできている状態であれば爆速で実装できるかと思います!!
今回作ったサンプルはこちら

さて、明日24日は@seripaiさんの「ツール尽くして彼氏を作る!」です!
とても気になるタイトルですね!笑
ぜひ楽しみに!!

Masataka-n
本職でマッチングアプリの開発を 個人ではFirebaseを使ったアプリの開発をしています。 Swift, Firebase, TypeScript
diverse
結婚支援事業を中心に、友達・恋人探しのマッチング事業を展開。深刻化する恋愛離れ、未婚率の上昇を解決すべくWEB・アプリサービスを展開。すべての人へ出会いのプラットフォームを提供しています。
http://diverse-inc.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away