koogawa log

iOS、Android、foursquareに関する話題

【iOS/Swift】Firebase Cloud Firestoreで簡単なGPSロガーを作ってみる

この記事は Firebase #2 Advent Calendar 2018 4日目の記事です。


Firebase を理解するには何か作ってみるのが一番!ってことで、今回は簡単なGPSロガーを作ってみました。

f:id:koogawa:20150802150835p:plain

次のような機能があります。

  • Startボタンを押すと位置情報を記録開始
  • アプリをバックグラウンドに落としても記録し続ける
  • 位置情報が取得されると地図にもピンが立つ
  • 1日経過したデータは起動時に自動削除
  • Stopボタンを押すと位置情報の取得終了

***

以下、実装メモです。すべての実装は説明できないので、完全なソースコードは最後の方に貼ってある GitHub リポジトリを参照してください。また、今回は学習を目的としたサンプルプログラムという位置付けなので、料金については詳しく触れません。Cloud Firestore はデータの読み取り、書き込み、削除の回数によっても課金されますので、下記のリンクもよくお読みください。

Cloud Firestore の料金  |  Firebase


動作環境

  • Xcode 10.1
  • Swift 4.2
  • FirebaseCore 5.1.8
  • Firebase/Firestore 5.13.0
  • CocoaPods 1.5.3

手順

プロジェクト作成

https://console.firebase.google.com/ から新規プロジェクトを作成します。

f:id:koogawa:20181203231855p:plain

チェック項目については各自おまかせします。

テータベースの作成

左のメニューから「Database」を選択し、「データベースの作成」ボタンをクリックします。

f:id:koogawa:20181203233644p:plain

Cloud Firestore データモデルについて

公式ドキュメントから引用します。

Cloud Firestore は NoSQL ドキュメント指向データベースです。SQL データベースとは違い、テーブルや行はありません。代わりに、データは「ドキュメント」に格納し、それが「コレクション」にまとめられます。

これは図に表すと理解しやすいと思います。

f:id:koogawa:20181203232958p:plain

また、各「ドキュメント」には、一連のキーと値のペア(フィールド)が含まれています。今回は locations コレクションにGPSログ(ドキュメント)を追加していく想定で進めていきます。

f:id:koogawa:20181203233919p:plain

iOSアプリに Firebase 追加

iOS」ボタンをクリックします。

f:id:koogawa:20181203233955p:plain

バンドル名は com.example.GPSLogger、アプリ名は GPSLogger としました。

f:id:koogawa:20181203234015p:plain

GoogleService-Info.plist をプロジェクトに追加

画面に従っていくと GoogleService-Info.plist をダウンロードするように促されるので、これをプロジェクトに追加します。

f:id:koogawa:20181203234303p:plain

このファイルには Firebase を使うためのIDなどがセットされています。このファイルを追加しないとアプリ起動時にクラッシュします。

Cloud Firestore インストール

次のような Podfile を用意して pod install します。

use_frameworks!

target 'GPSLogger' do
  pod 'Firebase/Core'
  pod 'Firebase/Firestore'
end

アプリで Firebase を初期化する

AppDelegate で FirebaseApp.configure() を実行し、アプリを初期化します。

import UIKit
import Firebase

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  var window: UIWindow?

  func application(_ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?)
    -> Bool {
    FirebaseApp.configure()
    return true
  }
}

モデル作成

位置情報を格納するためのモデルを作ります。緯度、経度、作成日時のみを保持するようにしました。

struct Location {
    let latitude: Double
    let longitude: Double
    let createdAt: Date

    init(document: [String: Any]) {
        latitude = document["latitude"] as? Double ?? 0
        longitude = document["longitude"] as? Double ?? 0
        createdAt = document["createdAt"] as? Date ?? Date()
    }
}

緯度経度を格納するための型(地理的座標)も用意されていますが、今回はわかりやすくするためにあえて Double 型を採用しています。

後述しますが、Firebase からはデータが Dictionary 型で返ってくるので、init(document: [String: Any]) { のようなメソッドを定義し、そこから Location インスタンスを生成できるようにしています。

Firebase では次の型が使用できます:配列、ブール型、バイト、日時、浮動小数点数、地理的座標、整数、マップ、Null、参照、テキスト文字列。

Startボタンを押したときの処理

位置情報の取得を開始します。*1

self.locationManager.startUpdatingLocation()

位置情報を追加

取得できた位置情報(CLLocation)を Firestore に追加します。

let db = Firestore.firestore()
var ref: DocumentReference? = nil
ref = db.collection(kLocationsCollectionName).addDocument(data: [
    "latitude": location.coordinate.latitude,
    "longitude": location.coordinate.longitude,
    "createdAt": FieldValue.serverTimestamp()
]) { err in
    if let err = err {
        print("Error adding document: \(err)")
    } else {
        print("Document added with ID: \(ref!.documentID)")
    }
}

追加日時は FieldValue.serverTimestamp() メソッドで時間軸を全てサーバーに預けてしまうことによってモバイルの個体差によるずれを解消しています。

リアルタイムアップデート

addSnapshotListener メソッドを使用すると、ドキュメントが更新されたときにイベントを受け取ることができます。

let db = Firestore.firestore()
self.listener = db.collection("locations")
    .addSnapshotListener(includeMetadataChanges: true) { [weak self] documentSnapshot, error in
        guard let document = documentSnapshot else {
            print("Error fetching document: \(error!)")
            return
        }
        print("Current data: \(document.description)")
        self?.loadStoredLocations()
}

ここではイベントを受け取るたび(つまり位置情報が追加されるたび)にデータをロードし、テーブルビューを更新しています。includeMetadataChangestrue にしているのは、データが削除された際にもイベントを受け取るためです。

Stopボタンを押したときの処理

位置情報の取得を停止します。

self.locationManager.stopUpdatingLocation()

また、リアルタイムアップデートも停止します。先ほど実行した addSnapshotListener メソッドの戻り値 ListenerRegistrationremove メソッドを呼ぶことで、イベントの受け取りを停止します。

self.listener.remove()

アプリを起動したときの処理

getDocuments メソッドを実行して Firestore に保存されている位置情報をロードします。

let db = Firestore.firestore()
db.collection(”locations”)
    .order(by: "createdAt", descending: false)
    .getDocuments { [weak self] snapshot, error in
        if let error = error {
            print("Error getting documents: \(error)")
        } else {
            self?.locations = snapshot?.documents.map { Location(document: $0.data()) } ?? []
        }
}

createdAt でソートをかけて全データを取得し、Location 型の配列としてローカルに持ちます。

古いデータの削除

古いデータがいつまでも残ってしまうのを防ぐため、1日経過した位置情報ログを削除します。

まずは whereField を利用して1日(86400秒)より古いデータを抽出します。

let db = Firestore.firestore()
db.collection("locations")
    .whereField("createdAt", isLessThanOrEqualTo: Date().addingTimeInterval(-86400))
    .getDocuments { snapshot, error in
        if let error = error {
            print("Error getting documents: \(error)")
            return
        }
        for document in snapshot?.documents ?? [] {
            print("Deleting document", document)
            self.delete(documentID: document.documentID)
        }
}

そして一件ずつ delete メソッドで削除していきます。

fileprivate func delete(documentID: String) {
    let db = Firestore.firestore()
    db.collection("locations")
        .document(documentID)
        .delete() { err in
            if let err = err {
                print("Error removing document: \(err)")
            } else {
                print("Document successfully removed!")
            }
    }
}

大量にデータを削除する場合はメモリ不足エラーを避けるため、小さなバッチに分けてドキュメントを削除することが推奨されています。

所感

割と簡単にGPSロガーが作れてしまいました。ドキュメントが充実しているのも安心できますね。

とくに便利なのがオフラインデータ機能です。この機能により、アプリが使用している Cloud Firestore データのコピーがキャッシュに保存されるため、端末がオフラインの場合でもアプリはデータにアクセスできます。端末がオンラインに戻ると、アプリがローカルで行った変更とリモートの Cloud Firestore に保存されたデータが同期されます。なんて素敵な機能なんでしょう!

ソースコード

こちらに全てアップしてあります。Firebase を使い慣れている方にとってはツッコミどころ満載だと思いますので、ご指摘など歓迎です:-)

github.com

リンク

*1:位置情報の利用をユーザーに許可して貰う必要があります。詳しい実装は全体のソースコードを参照してください