iOS
Swift
Firebase
Firestore

Firebase Cloud Firestoreを使って、車の草耐久レースを少し改善した話。

普段の業務では、iOSやAlexaのスキル開発を中心にやっていますが、趣味では愛車でサーキットへ行ったり、ジムカーナを楽しんだりしています。(自称アウトドア)
その延長線上で、とあるアプリを作りかなり革新的なことを実現したので簡単にまとめます。
IMG_7206.JPG

そもそも草レースって?

一般的に車のレースっていうとまず先にF1であったりSUPER GT選手権やスーパー耐久などが思い浮かぶ方も多いかもしれません。これらに関しては、プロドライバーであったり自動車メーカー、ワークスなどがガチで争うマジモンのレースです。
ただ、そんなプロのレースの他にアマチュアのレースの世界があります。それが草レース。要は車趣味の愛好家がガチンコで楽しむ趣味のレースと言えばわかりやすいでしょうか。

今回参加した草レース

草レースの中でもいくつかのタイプはあります。1つの枠でLapTimeを極めるものや周回数を極めるものであったり。
そんなレースの中で今回ピットクルーとして参加したのはこちらです。
スクリーンショット 2018-07-30 21.10.41.png

今やドリフトの聖地となっている東北エビスサーキットの東コースを使った「エビス スーパー耐久シリーズ」です。中でも最も時間が長い、12時間の第3戦に参加してきました。

簡単にレースについて説明すると、「朝9時から夜の9時まで最も多く周回数を重ねたチームが勝ち」っていうレースです。

今回求められた要件について

  • ドライバーとピットとの距離があっても即時連絡可能なこと
  • 周回数とラップタイムをピット側で把握することができること

この2点が基本項目として求められていました。
エンジニアの皆様であれば、「簡単やん!?」と思うことがあるかもしれません。
ええ、簡単なんです。
(実際作るのは簡単ですが、耐久カーの横に乗ってデバッグするほうが大変でしたが)

なぜこんなことが求められるのか?

私も実際草レースのお手伝いをするまで知らなかったんですが、草レースの世界って趣味なこともありものすごくアナログなんです。

ピットとドライバーの連絡は、ブツブツ切れて聞こえづらい特定小電力のトランシーバーだったり、ピットボードが使われていたりします。
もちろんこれらは、プロのレースでも現役で使われていて、アナログだからこその確実性が担保されていると思いますので、一概に悪いとは言えません。
ですがせっかくなら新しい技術でも挑戦してみたいですよねということで、今回のアプリを立ち上げることになりました。

また特定小電力のトランシーバーでは、全周約2kmのコース上では途切れることも多く、ガソリンの状況であったり、車の状況がすぐにピットに伝えるために適しているとは言えないのです。
こういった小さいラグが積み重なり、周回遅れにつながったりします。
なのでこのような小さい無駄をなくして少しでも周回数を重ねたいというのが狙いでした。

うち、バックエンド出来ないんですけど

ITエンジニアはまぁ普通の人から見ると一括りなので、仕方ないです。
バックエンド出来ないわけではないですが、短期間でやりきれるかというと1人で開発するには時間が足りません。。。さぁどうしましょうか。。。

なんのことはない、Firebaseを使おう

今最も熱い(個人的) MBaaSです。Webアプリケーションやモバイルアプリケーションのバックエンドで行う機能を提供してくれるクラウドサービスがFirebaseです。
こやつを使えば、サクッと行けるんちゃいますか?と思い、使ってみました。

Firebaseを使ってみよう!

今回利用したのは、Firebase Cloud Firestoreです。このサービスは、アプリデータの保存と同期をリアルタイムに簡単に行ってくれる凄いやつ。

Firebase Cloud Firestore

主な機能としては下記の通りです。

  • 柔軟性
    • Cloud Firestore データモデルは、柔軟な階層型データ構造に対応しています。データはドキュメントに格納され、ドキュメントはコレクションにまとめられます。ドキュメントには、サブコレクションの他に複雑なネスト オブジェクトを含めることができます。
  • 高機能なクエリ処理
    • Cloud Firestore では、個別のドキュメントを取得する場合や、クエリ パラメータに一致するすべてのドキュメントをコレクションで取得する場合に、クエリを使用できます。クエリには複数の連鎖フィルタを使用でき、またフィルタ処理と並べ替え処理を組み合わせることができます。デフォルトではクエリにはインデックスが付いているので、クエリのパフォーマンスは、データセットではなく結果セットのサイズに比例します。
  • リアルタイム アップデート
    • Realtime Database と同様に、Cloud Firestore はデータ同期を使用して、すべての接続端末のデータを更新します。ただし、シンプルな 1 回限りの取得クエリを効率的に実行するようにも設計されています。
  • オフライン サポート
    • Cloud Firestore は、アプリでアクティブに使用されるデータをキャッシュします。これによりアプリは、端末がオフラインになっている場合でもデータの書き込み、読み取り、聞き取り、クエリを実行できます。端末がオンラインに戻ると、Cloud Firestore でローカルに行われた変更がすべて同期されます。
  • 拡張性のある設計
    • Cloud Firestore は、Google Cloud Platform の強力なインフラストラクチャの優れた機能(自動マルチリージョン データ複製、優れた整合性の確保、アトミックな一括オペレーション、リアル トランザクション サポート)を提供します。Cloud Firestore は、世界でも最大規模のアプリからの最も過酷なワークロードに対応できるように設計されています。

とのことです。
今回、レース用車両にクライアント端末を搭載することから通信が常に確立される保証がないことから、オフライン時でも逐一データが書き込めて、通信が再度確立された際にサーバーの状態と自動的に同期されるFirebase Cloud Firestoreは物凄く最適でした。
もちろん、今回の用途ではFirebase Realtime Databaseでも実現出来ると思いますが、あえて使ったことがなかったCloud Firestoreを利用してみました。

Firebase Cloud Firestoreのデータ構造

Firebase Cloud FirestoreはNoSQLドキュメント指向データベースです。
データは「ドキュメント」に格納され、それぞれのドキュメントが「コレクション」にまとめられます。

イメージ的には下記の感じです。

HogeCollection
┣HogeDocument
 ┠HogeKey
 ┃ ┗HogeValue
 ┗HogeHogeKey
  ┗HogeHogeValue

ドキュメントの中には、キーと値のペアが保持されています。
値には、文字列や数値などのほか、リストなどの複雑なオブジェクトを保持させることが出来ます。

触った感じ的にはMongoDBを扱うのに近い印象を持ちました。

すでに計測アプリ自体の開発は進んでおり、レース当日までの時間も少なかったため下記のようなデータ構造としています。

スクリーンショット 2018-08-07 12.56.35.png

Timeというコレクションに、乱数生成した名称のドキュメントをLap計測を開始したタイミングで生成し、生成したドキュメントの下に、Pit側で必要な情報類をひとまずペアで保持させるという形にしています。
もちろんデータ構造としてはどうなのよと思うところもあるかもしれませんが、綺麗さよりも稼働を優先したためこのようになっています。

Firebase Cloud FirestoreをiOSアプリから触る

下記のようなラッパークラスを作成して、必要なときにshareInstanceから呼び出して使用しています。

FirestoreUtility.swift
//
//  FirestoreUtility.swift
//
//  Created by MasamiYamate on 2018/06/07.
//  Copyright © 2018年 MasamiYamate. All rights reserved.
//

import UIKit
import Firebase
import FirebaseFirestore

class FirestoreUtility: NSObject {

    //FireStoreのKeyアップデート通知キー
    static let FIRESTORE_NOTIFICATION_DOCUMENT_UPDATE = Notification.Name(rawValue: "FIRESTORE_NOTIFICATION_DOCUMENT_UPDATE")

    //Singletonの取得元
    static var share:FirestoreUtility = FirestoreUtility()

    //FireStoreのインスタンス
    var defaultFirestore : Firestore?

    /// イニシャライズイベント
    override init() {
        super.init()
        defaultFirestore = Firestore.firestore()
    }

    /// Cloud FireStoreへのデータ書き込み
    ///
    /// - Parameters:
    ///   - dicData: 書き込みデータ
    ///   - collectionName: コレクション名
    ///   - documentData: ドキュメント名
    func setFireStoreData (dicData:[String:Any] , collectionName:String , documentName:String) {
        guard let db:Firestore = defaultFirestore else {
            return
        }
        db.collection(collectionName).document(documentName).setData(dicData) { err in
            if let err = err {
                print("Error writing document: \(err)")
            } else {
                print("Document successfully written!")
            }
        }
    }

    /// Cloud FireStoreからのデータ取得
    ///
    /// - Parameters:
    ///   - collectionName: コレクション名
    ///   - documentName: ドキュメント名
    func getFireStoreData (collectionName:String , documentName:String) {
        guard let db:Firestore = defaultFirestore else {
            return
        }

        let docRef = db.collection(collectionName).document(documentName)

        self.setUpFirestoreRealtimeListener(collectionName: collectionName, documentName: documentName)

        docRef.getDocument { (document, error) in
            if let document = document, document.exists {
                if let newSetDic:[String:Any] = document.data() {
                    self.setNewUpdateDic(data: newSetDic)
                } else {
                    print("Document does not exist")
                }
            } else {
                print("Document does not exist")
            }
        }
    }

    /// firestoreのRealtimeListenerを追加します
    ///
    /// - Parameters:
    ///   - collectionName: コレクションネーム
    ///   - documentName: ドキュメントネーム
    func setUpFirestoreRealtimeListener (collectionName:String , documentName:String) {
        guard let db:Firestore = defaultFirestore else {
            return
        }
        db.collection(collectionName).document(documentName).addSnapshotListener { documentSnapshot, error in
            guard let document = documentSnapshot else {
                print("Error fetching document: \(error!)")
                return
            }
            if let newSetDic:[String:Any] = document.data() {
                self.setNewUpdateDic(data: newSetDic)
            } else {
                print("Document does not exist")
            }
        }
    }

    /// Firestoreの最新データ受信時のイベント
    ///
    /// - Parameter data: 更新された結果を含むデータ
    private func setNewUpdateDic (data:[String:Any]) {
        //データがアップデートされると本メソッドが実行される
        //引数として[String:Any]のDictionaryが渡されるので
        //適宜処理を行う
        NotificationCenter.default.post(name: FirestoreUtility.FIRESTORE_NOTIFICATION_DOCUMENT_UPDATE, object: nil)
    }

}

計測開始の時には基本的に、「setUpFirestoreRealtimeListener 」をコールし、該当するコレクションに含まれるドキュメントが更新されたときに「setNewUpdateDic」が呼び出される流れです。
一方でデータ登録するときは、「setFireStoreData」にデータを渡すだけです。データ登録のタイミングは基本的にPit側からのMessage送付または、車側の端末でのLapタイム登録時になります。

12時間稼働させてみた

IMG_1279.jpg

車載側端末は、iPad mini3のCellularモデルを搭載しています。SIMはIIJの格安SIMで通信を確立しています。
操作しやすいようスピードメーターの上部にiPadを設置しています。もちろん、公道では無理ですがレースなので、この配置で問題ありません。

Pitからのメッセージが送付された際、画像のような形でピットインというメッセージが表示されます。
画像の場合は、「赤旗(走行中止)のためピットイン」という指示です。

IMG_0003.jpg

ピット側での運用はこのような形で行いました。ドライバーの状況把握のため、YoutubeLiveでの配信も合わせて行っています。
ピット側から確認できることは、ドライバーからの特定のメッセージ(PitIn希望など)と周回Lapが逐一送付されます。
一方でピットから送付できる内容は、ドライバーへのメッセージとスポットから出される旗の色です。

運用実績

今回2台の車を走行させたので、2ペア(Pit、車載)で運用しました。
結果として下記のような実績になりました。

  • Aカー
    • 周回数:524周
    • 走行時間:12時間1分14秒
  • Bカー
    • 周回数:103周
    • 走行時間2時間45分45秒

アプリの運用としては、冷房も遮熱板も無い、レースカーの中での運用であったため、iPadが熱暴走して落ちてしまう恐れもあったのですが、無事12時間の連続稼働に耐えてくれています。
Bカーは、車のトラブルで途中リタイアとなりましたことが残念でした。

システムを組んだ人間としては、問題なく目標時間稼働させ続けることが出来たので満足行く結果になったと思っています。

投入したことで得られた恩恵

どのタイミングでも視覚的に情報を伝達できる

従来、ホームストレートでピットボードをドライバーに見せることで、視覚的に情報を伝えていましたが、やはりスピードが出ていることで、見落としなどもあります。そういった中で、常にドライバーが確認できるという点は、ドライバー的に集中を切らすことなく運転できるということで良かったとのことでした。

浮上した問題点

Lap計測が非常に難しい

車内に設置していることもありGPSの精度は高くないと思いますが、加速、減速、高速度域でのカーブ進入など、通常の公道と違う環境であることもあり、CoreLocationによる現在位置更新のタイミングが非常にシビアです。
そのため、計測基準位置と現在位置の比較がうまく行かず、Lapタイムの計測漏れが頻発してしまいました。
これに関しては、計測ロジックの変更や加速度なども含めて考慮した計測を行わないと解決できなさそうです。

ドライバー操作が稀に難しい

ドライバーは、防燃性のドライバーグローブをしていますがそのままでは、iPadの操作が出来ません。そのため、指先にアルミテープを巻いてタップできるようにしていました。
しかし、劣悪な環境下であることで、アルミテープが一瞬でボロボロになっていまい(汗や指を動かすことで、ボロボロに)タップが効かなくなることがありました。

まとめ

Firebaseはいいぞ!

短期間の開発期間でしたが、バックエンドが苦手なiOSエンジニアでもサクッと組めちゃうこの恐ろしさ。
冗談抜きでスピード感持ってプロダクトをとりあえず作るとかには最適。もちろん向き不向きはあるだろうけど、Firebaseの知見があると役立ちそうなシーンは多い気がします。

案外ニッチなシーンでITを掛け合わすシーンは多そう

ビジネスになるかどうかはさておき、思ったよりもローテクで動いている場所もある気がします。
そんなとこが案外開拓する価値があるのかなとある種気づきになりました。

やっぱモノ作るって楽しいね

どんな形であれ、自分の作ったものがちゃんと目標を達成して動いて、貢献できるって楽しいですね。
今後もいろんなプロダクトをまた作りづつけたいと改めて思いました。

最後に

技術的な話は薄めになりましたが、あくまでもFirebaseを活用してみたよー的なお話でした。
まだまだ未熟な技術者ですが、今後も技術者として活動続けたいと思いました。
最後までお読みいただきありがとうございました。