自分を攻略していく記録

自分がやりたいことを達成するには何をすればいいのか、その攻略していく過程をつらつらと

Firebaseでサーバレスアプリをサクッと作ってみる② ~Firestore実装とプッシュ通知連携~

以下の記事でFirebaseのセットアップからプッシュ通知を受け取るところまで実装したので、次はFirestoreにデータを書き込み、それをトリガーにしたCloud Functions経由でプッシュ通知を送信するように実装していく。

diary.shuichi.tech

Firestoreをセットアップする

Podfilepod 'Firebase/Firestore' / pod 'Pring' / pod 'Firebase/Auth' と追加して pod install を実行する。Xcodeのバージョンが9.2以下だとうまく動かなかったので、もしXcodeのバージョンが低い場合はアップグレードする必要がある。Pringを利用すると直感的にモデルを扱えるので今回はこれを利用する。Firestoreにデータを書き込むためには、認証が必要なので Firebase/Auth も利用する(Firestoreのセキュリティルールをゆるくすれば必要はないがせっかくなので認証も実装する)。

Pringのリンクはこちら。わかりやすいので個人的には気に入っている。

github.com

ただ、使い方が若干わかりにくいので、この記事を参考にしていただければ嬉しい。

diary.shuichi.tech

続いてFirebaseのコンソールに行って、Databaseの初期化をする。

f:id:ngo275:20180710004034p:plain:w500

Databaseのルールを開き、下の画像のように編集する。このセキュリティルールが甘いせいでユーザの情報が流出する事件が先日起こったのでよく注意しよう。

f:id:ngo275:20180710010733p:plain:w500

Firebase Authenticationも利用するので、初期設定をしていく。

f:id:ngo275:20180710011035p:plain:w500

匿名の欄を開いて有効化する。ここの設定を忘れるとうまくいかないのだが、忘れがちなので気を付けるべきポイント。

f:id:ngo275:20180710011149p:plain:w500

モデルを実装していく

新しく Model.swift を作成して以下のようにObjectを継承したUserとBookを宣言する。本来ならそれぞれ User.swiftBook.swift のように分割する方が綺麗かもしれないがここではまとめて書いておく。 User については、匿名ログイン時に発行されるIDを使ってデータを生成し、すでに User が生成済みであれば何もしないようにしている。 UserBook の関係は、 User has many Books で、 NestedCollection を利用するとデータを取得する時に後々効いてくる。

// Model.swift

import Foundation
import Pring

@objcMembers
class User: Object {
    dynamic var name: String?
    dynamic var fcmToken: String?
    dynamic var books: NestedCollection<Book> = []

    static func anonymousLogin(_ completionHandler: @escaping ((User?) -> Void)) {
        Auth.auth().signInAnonymously { (auth, error) in
            if let error = error {
                print(error.localizedDescription)
                completionHandler(nil)
                return
            }
            
            guard let currentUser = Auth.auth().currentUser else {
                completionHandler(nil)
                return
            }

            User.get(currentUser.uid) { (user, _) in
                if let user = user {
                    print("Success login of an existing user")
                    completionHandler(user)
                } else {
                    let u = User(id: auth!.user.uid)
                    u.name = "Alice"
                    u.save() { ref, error in
                        print("Success login of a new user")
                        completionHandler(u)
                    }
                }
            }
        }
    }
}

@objcMembers
class Book: Object {
    dynamic var name: String?
}

ViewController.swift にログインの実装を加える。

// ViewController.swift

import UIKit
import UserNotifications

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
        UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { _, _ in
            print("push permission finished")
        }
      
        // ここだけ追加  
        User.anonymousLogin() { user in
            print(user)
        }
    }
}

この時点でXcodeで実行するとFirebase Authにてログイン済みのUserが追加され、Firestoreにも同じIDを持ったUserのデータが生成されている。認証周りとデータの書き込みがうまく行っていることが確認できる。

f:id:ngo275:20180710091956p:plain:w600

f:id:ngo275:20180710092031p:plain:w600

データの更新をトリガーにプッシュ通知を送信する

FCMトークンの扱い

まず AppDelegate.swift で受け取れるFCMトークンをUserオブジェクトと紐づける。ログイン処理が完了した後にFCMトークンをFirestoreの該当するUserに記録したいので UserDefaults を利用する。 AppDelegate.swift 内の、FCMトークンを受け取った場所で以下のように実装する。

// AppDelegate.swift

func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String) {
    // FCMトークンを記録しておく
    UserDefaults.standard.set(fcmToken, forKey: "FCM_TOKEN")
    UserDefaults.standard.synchronize()
    print("Firebase registration token: \(fcmToken)")
}

次に ViewController.swift で匿名ログインをしているが、ログイン完了時にUserオブジェクトの fcmTokenUserDefaults に記録しておいた値をセットして update をする。

// ViewController.swift

import UIKit
import UserNotifications

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
        UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { _, _ in
            print("push permission finished")
        }
        
        User.anonymousLogin() { user in
            // 匿名ログイン完了時にfcmTokenをセットしてupdateする
            if let user = user {
                user.fcmToken = UserDefaults.standard.string(forKey: "FCM_TOKEN")
                user.update()
            }
        }
    }
}

この状態で実行すると以下の画像のようにFCMトークンがセットされているはず。

f:id:ngo275:20180710092216p:plain:w600

Firestoreに新しいデータを書き込む

まず、Firestoreにデータを書き込む。 ViewController.swift を少し変更する。

// ViewController.swift

import UIKit
import UserNotifications

class ViewController: UIViewController {

    private var user: User?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
        UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { _, _ in
            print("push permission finished")
        }
        
        User.anonymousLogin() { user in
            self.user = user
            if let user = user {
                user.fcmToken = UserDefaults.standard.string(forKey: "FCM_TOKEN")
                user.update()
            }
        }
    
        // ここを追加する
        setButton()
    }
    
    private func setButton() {
        let button = UIButton(frame: CGRect(x: 0, y: 50, width: view.frame.width, height: view.frame.height / 6))
        button.backgroundColor = .black
        button.setTitle("Add Book", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.addTarget(self, action: #selector(addBook), for: .touchUpInside)
        view.addSubview(button)
    }
    
    @objc private func addBook(_ sender: UIButton){
        let book = Book()
        book.name = "Alice in Wonderland"
        user?.books.insert(book)
        user?.update()
    }
}

Xcodeで起動すると以下のようにボタンがセットされている。これをタップすると、FirestoreのUserの中にBookが記録される。

f:id:ngo275:20180710212857j:plain:w300

Firebaseのコンソールで確認すると以下の画像の通り。

f:id:ngo275:20180710213038p:plain:w600

Cloud Functionsを使ってFirestoreの更新を監視する

サーバレスで実行されるCloud Functionsを実装していく。これはAWSのLambdaに相当する。GCPのコンソール側から作成することもできるが、Firebaseのコンソールから進めていくほうがずっと楽である。

f:id:ngo275:20180710213330p:plain:w500

以下のコマンドでFirebaseのCLIをインストールする。

$ npm install -g firebase-tools

続いて、Firestoreの更新があった時に、それをトリガーに実行されるコードを書いていく。Xcodeのプロジェクトとは異なるディレクトリに移動して、 firebase init を実行する。 Functions をスペースで選択し、次へ進む。

f:id:ngo275:20180710213759p:plain:w500

いくつか質問が聞かれるがここでは以下のように進めた。

=== Project Setup

? Select a default Firebase project for this directory: FirebaseSample (fir-sample-94ad3)

=== Functions Setup

? What language would you like to use to write Cloud Functions? JavaScript
? Do you want to use ESLint to catch probable bugs and enforce style? No
✔  Wrote functions/package.json
✔  Wrote functions/index.js
? Do you want to install dependencies with npm now? Yes 

✔  Firebase initialization complete!

functions ディレクトリの中にある index.js を編集していく。

// functions/index.js

const functions = require('firebase-functions')
const admin = require('firebase-admin')
admin.initializeApp()
const firestore = admin.firestore()

const pushMessage = (fcmToken, bookName) => ({
  notification: {
    title: '保存が完了しました',
    body: `「${bookName}」の保存が完了しました🙌`,
  },
  data: {
    hoge: 'fuga', // 任意のデータを送れる
  },
  token: fcmToken,
})

exports.saveBook = functions.firestore
  .document('version/1/user/{userID}/books/{bookID}')
  .onCreate((snapshot, context) => {
    // 以下のようにすればbookの中身が取れる.
    const book = snapshot.data()
    // 以下のようにすればuserIDやbookIDが取れる.
    const userID = context.params.userID
    const userRef = firestore.doc(`version/1/user/${userID}`)
    userRef.get().then((user) => {
      const userData = user.data()
      admin.messaging().send(pushMessage(userData.fcmToken, book.name))
        .then((response) => {
          console.log('Successfully sent message:', response)
        })
        .catch((e) => {
          console.log('Error sending message:', e)
        })
    }).catch((e) => console.log(e))
  })

実装したら、 firebase deploy をターミナル側で実行してデプロイする。

以下のようにデプロイされているのが確認できればOK。

f:id:ngo275:20180710215521p:plain:w600

最後にiPhone実機でAdd Bookボタンをタップすると、数秒後にプッシュ通知が届く!!

f:id:ngo275:20180710225658j:plain:w300

うまくプッシュ通知が届かない時は、FirebaseのコンソールのFunctionsのタブでログを確認しよう。

f:id:ngo275:20180710230753p:plain:w600

ついでに保存されているBookの一覧をTableViewで表示してみる。

せっかくなので、Firestoreに保存されているBookの一覧を表示してみる。 ViewController.swiftUITableView を追加して、FirestoreのUser/Booksの監視を行うように実装する。

// ViewController.swift

import UIKit
import UserNotifications
import Pring

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    private var user: User?
    private var dataSource: DataSource<Book>? // TableViewに渡すDataSource
    private var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
        UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { _, _ in
            print("push permission finished")
        }
        
        setButton()
        setTableView()
        
        User.anonymousLogin() { [weak self] user in
            self?.user = user
            if let user = user {
                user.fcmToken = UserDefaults.standard.string(forKey: "FCM_TOKEN")
                user.update()
                
                let options = Options()
                let sortDescriptor = NSSortDescriptor(key: "createdAt", ascending: false)
                options.sortDescirptors = [sortDescriptor]

                // 以下のように書くと、Firestoreの更新があるとTableViewの更新もかけるようになる.
                self?.dataSource = user.books.order(by: \Book.createdAt).dataSource(options: options)
                    .on() { (snapshot, changes) in
                        guard let tableView = self?.tableView else { return }
                        switch changes {
                        case .initial:
                            tableView.reloadData()
                        case .update(let deletions, let insertions, let modifications):
                            tableView.beginUpdates()
                            tableView.insertRows(at: insertions.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                            tableView.deleteRows(at: deletions.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                            tableView.reloadRows(at: modifications.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                            tableView.endUpdates()
                        case .error(let error):
                            print(error)
                        }
                    }.listen()
            }
        }
    }
    
    private func setButton() {
        let button = UIButton(frame: CGRect(x: 0, y: 50, width: view.frame.width, height: view.frame.height / 6))
        button.backgroundColor = .black
        button.setTitle("Add Book", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.addTarget(self, action: #selector(addBook), for: .touchUpInside)
        view.addSubview(button)
    }
    
    @objc private func addBook(_ sender: UIButton){
        let book = Book()
        book.name = "Alice in Wonderland"
        user?.books.insert(book)
        user?.update()
    }
    
    private func setTableView() {
        let f = view.frame
        // Add Bookボタンの下に配置する
        tableView = UITableView(frame: CGRect(x: 0, y: 50 + f.height / 6, width: f.width, height: f.height * 5 / 6 - 50), style: .plain)
        tableView.delegate = self
        tableView.dataSource = self
        tableView.tableFooterView = UIView()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "BookCell")
        view.addSubview(tableView)
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return dataSource?.count ?? 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "BookCell", for: indexPath)
        cell.textLabel?.text = dataSource?[indexPath.item].name
        return cell
    }
}

完成したアプリがこちら

f:id:ngo275:20180711000218g:plain:w300

FirebaseSampleのコードはこちら

github.com

FirebaseSampleFunctionsのコードはこちら

github.com