GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

Firebaseをプロダクトに採用して得た知見 #GameWith #TechWith

こんにちは、GameWith iOSエンジニアの@kyamです。

前回こちらのiOSアプリの開発記事を書いて以来の執筆となります。 tech.gamewith.co.jp

今回のブログ内容と関連するので是非一読していただけると今回の記事がスムーズに読めるかと思います。

なぜFirebaseの記事を書こうと思ったか

Firebaseに関する記事は巷にたくさんあると思いますが改めて記事を書こうと思った理由として、主に2つあります。

  • 記事を書く際に改めてブログで扱う技術について再調査する事が多く、知らなかったことを見つけ、実際に既存のプロダクトに活かすという事がよくある
  • 社内で新規プロダクトや既存プロダクトにFirebaseを採用する事例が増えているので、社内に向けても知見共有になれば良いなと思った

上記の理由からブログを書くことにしたのですが、今回の構成としてはiOSとWeb(管理画面)の開発でそれぞれ取り入れたFirebaseのサービスを順に簡単に紹介しつつ、採用して良かった点や、苦労した点、今後より最適化していきたい点などをまとめていきたいと思います。

前回のブログを書いてから今日までプロダクト改善や新規施作のためにいくつかのFirebaseが提供している機能を活用しました。 それぞれ順に説明しつつ、簡単に実装例などを紹介します。

RemoteConfig

Firebase Remote Config

一つ目の紹介です。

Firebaseの管理画面でサービス側パラメータ値を変更することで、アプリのデフォルトの動作や外観を変更することができます。内容にもよりますが基本的にアプリ起動時にパラメータ値のfetchを行います。

キャッシュの期間なども設定できますが、開発中はすぐさま管理画面上でのパラメーター値の変更をアプリ側で検知したいため isDeveloperModeEnabled を有効にするのがオススメです。

        config = RemoteConfig.remoteConfig()
        config.configSettings = RemoteConfigSettings(developerModeEnabled: false)
        let expirationDuration = config.configSettings.isDeveloperModeEnabled ? 0 : 3600
        config.fetch(withExpirationDuration: TimeInterval(expirationDuration)) { (status, error) in
            if status == .success {
                self.config.activateFetched()
            } else {
                if let e = error {
                    SBLog.error(e)
                }
            }
        }

A/B Testing

Firebase A/B テスト

A/B Testを作成する事ができる機能です。
上記で紹介したRemoteConfigを用いて、どちらのパターンがサービスにとって最適かを継続率や独自に設定したイベントを元に計測する事ができます。

Mippleでは動画の再生に到達するまでに二つのUIフローを用意し、どちらがより再生されるか、継続率の数値が良いかなどを検証することにしました。

    func checkMenuUIStatus() -> Bool {
        let showNewMenu = config[showNewMenuUIKey].boolValue
        if showNewMenu {
            Analytics.setUserProperty("新UI", forName: UserProperty.MENU_UI.rawValue)
        } else {
            Analytics.setUserProperty("旧UI", forName: UserProperty.MENU_UI.rawValue)
        }
        return showNewMenu
    }

ユーザープロパティをセットしてあげると予めセットしてあるイベントに対しても比較することができるので極力セットしたほうが良いです。 Firebase側は下記の添付画像のように自動でテスト結果を表示してくれます。(※数字は伏せています)

いつでも比率を増加させる事ができるので、AとBのテストの途中で明らかにBの方が良い数値であれば、アプリのアップデートを待たずしてBを100%のユーザーに適用する事なども管理画面上から実行できます。 RemoteConfigと組み合わせると本当にあらゆるテストが簡単にできるので気軽にエンジニアにこれテストできない?と聞いてみましょう。

簡単にディープリンクを作成する事ができるサービスです。
ダイナミックリンクを開くと、ネイティブアプリのリンク先のコンテンツに直接移動し、アプリがインストールされていない場合にはストアページに飛ぶといったものです。 管理画面上でリンクを発行することもできるのですが、Mippleではアプリ内で動的にリンクを作成しています。

Mippleで想定したユーザーの使い方は、

  • ユーザーが動画をシェアしようとする(その際に動的にアプリ内でダイナミックリンクを作成)
  • 実際にシェアされたリンクを別のユーザーがクリック、既にアプリをインストール済みのユーザーは再生画面が開き、そうでないユーザーはストアページに飛ぶ

といったものです。

extension DynamicLinker {
    func createDynamicLink(video: Video, completion: ((_ withUrl: URL?) -> Void)?) {
        guard let link = URL(string: "https://mipple.page.link/\(video.videoId)") else { return }

        let dynamicLinksDomain = "mipple.page.link"
        let linkBuilder = DynamicLinkComponents(link: link, domain: dynamicLinksDomain)

        let iOSParameters = DynamicLinkIOSParameters(bundleID: bundleID)
        iOSParameters.appStoreID = appStoreID
        linkBuilder.iOSParameters = iOSParameters
        
        let otherPlatformParameters = DynamicLinkOtherPlatformParameters()
        otherPlatformParameters.fallbackUrl = URL(string: "https://itunes.apple.com/jp/app/id1406664366")
        linkBuilder.otherPlatformParameters = otherPlatformParameters
        
        let socialMetaTagParameters = DynamicLinkSocialMetaTagParameters()
        socialMetaTagParameters.title = video.videoTitle
        socialMetaTagParameters.descriptionText = video.videoDescription
        if let thumbnailUrl = URL(string: video.videoThumbnailUrl) {
            socialMetaTagParameters.imageURL = thumbnailUrl
        }
        linkBuilder.socialMetaTagParameters = socialMetaTagParameters
        
        let navigationInfoParameters = DynamicLinkNavigationInfoParameters()
        navigationInfoParameters.isForcedRedirectEnabled = true
        linkBuilder.navigationInfoParameters = navigationInfoParameters
        
        guard let longDynamicLink = linkBuilder.url else { return }
        
        let options = DynamicLinkComponentsOptions()
        options.pathLength = .short
        linkBuilder.options = options
        
        linkBuilder.shorten { (url, warnings, error) in
            if let shortUrl = url {
                completion?(shortUrl)
            } else {
                completion?(nil)
            }
        }
    }

開発途中のコードになりますが、流れはコードの通りです。
渡された Videoオブジェクトからシェアの際に必要な情報(リンクを作成するのに必要なVideoIDやシェアされた時に表示されるサムネイル情報など)を抜き取り最後に短縮URLにするといった内容です。

ただ、実際には該当するWebページが必要で(Mippleの例でいうとWebの再生画面)、その画面でアプリで開くボタンなどを押した時にアプリ内の該当画面に飛ばすというのが望ましい挙動ですので、一旦Webを用意するまで上記の機能は封印しています。

Cloud Messaging

簡単にPush機能を入れる事ができるサービスです。
配信比率や配信先のグループ(RemoteConfigの対象値で振り分けなど)を指定できるので効果的に運用できれば良さそうです。 Pushサービスは他にもたくさんあるのですが、FirebaseSDKをサービスに導入しているのであれば使って見ましょう。

カスタムデータも送信できるので、アプリ側で値を見てアプリ内の該当ページにディープリンク的に飛ばすなども無料でできます。

Authentication

Firebase Authentication

管理画面のログイン管理に用いています。 管理画面では任意のメールアドレスとパスワードでのアクセスにしていますが、SNSログインなどにもFirebase Authentication自体は対応しています。

firebase.auth().signInWithEmailAndPassword(email, password).catch(function(error) {
  // Handle Errors here.
  var errorCode = error.code;
  var errorMessage = error.message;
  // ...
});

Database

動画データの管理に Cloud Firestore を採用しています。

今まではアプリ側でYoutubeAPIを叩きMippleアカウントから動画を引っ張ってきていたのですが、今後機能追加などを検討していく中でYouTubeAPIだけでは必要な動画情報が足りないということになりました。 そのため独自にDBを持ち、様々動画データを管理する必要性が出てきました。

AWSのRDSを使って・・API作って・・など色々検討したのですがインフラの構築、管理、API設計などスケールを気にしながら諸々を全部作るのは今のサービスのフェーズ的には時間的にもコスト的にも現実的ではないなと感じました。

そこで以前から興味のあったFirestoreを採用することにしました。 このためFirestoreのDBに動画データを登録し、登録されたデータを確認するための管理画面が必要になったため作成したという流れです。

管理画面に関しては Vue.js + Firebase の構成で作ったのですが、なぜ Vue.jsとFirebaseを採用したのかに関してはこちらのFirebase Advent Calendar*1で紹介する予定です!

話は戻り、Firestoreでは単純なクエリ・ソートは可能なものの、複雑なものはできないというのをよく見かけたため、まずデータ構造の持ち方を決め、実際の運用の際にどのようなクエリやソートを行うかを検討し実際にテストしました。

ところがtimeStampでデータをソートしようとしたところデータが取得できないという問題に遭遇しました。

(一瞬絶望する様子)

2つ以上の別々のフィールドを組み合わせてソートする場合は予めインデックスを作成しておかないと機能しません。 結果的に問題なくプロダクトに使う事ができたのですが、自分たちのサービスでしたい要件をFirestoreが満たすことができるかは投入前に確認するべきだなと感じました。

実装例

動画を日付順に並び替えて10件ずつ取得するケースだと以下のようになります。

        let db = Firestore.firestore()
        var query: Query!
        if dataSource.isEmpty {
            query = db.collection("videos")
                .order(by: "postedDate", descending: true)
                .limit(to: 10)
        } else {
            query = db.collection("videos")
                .order(by: "postedDate", descending: true)
                .start(afterDocument: lastDocumentSnapshot)
                .limit(to: 10)
        }
        isLoading = true
        query.getDocuments { (snapshot, error) in
            if let err = error {
                self.isLoading = false
                completion(err)
            } else {
                guard let snapshot = snapshot else { return }
                snapshot.documents.forEach { doc in
                    let data = doc.data()
                    let jsonData = JSON(data)
                    let video = Video(dataObject: jsonData)
                    self.dataSource.append(video)
                }
                if let lastDocument = snapshot.documents.last {
                    self.lastDocumentSnapshot = lastDocument
                    self.hasMore = true
                } else {
                    self.hasMore = false
                }
                self.isLoading = false
                completion(nil)
            }
        }

limitafterDocument を合わせて使うことで一般的なAPIクライアントで必須のページングの機能を導入することができます。 これによって読み込みの数を制限することで、ロードを早くし、余計なアクセスを減らすことができます。

Storage

動画サムネイルの管理に Cloud Storage を採用しています。 Cloud Storage 用の Firebase SDK があるので、簡単に扱うことができます。

最初動画の登録のためAPI経由でアップロードしていたのですが250KB以上の画像がAPI経由でアップロードに失敗すると言う問題に遭遇しました。 現状サムネサイズはそれを越えるものがないのですが、ここに関しては調査しようと思います。

また実装して気付いたのですが、Cloud Storageからダウンロードリンクを経由してサムネイルをアプリ内で用いると、アプリ内キャッシュがない場合の初期表示が露骨に遅いです。

改善策

初めは新しく用意したサムネイルのサイズが原因かなと思いサイズの削減なども行ったのですが、明らかにそこまでのサイズではないため勿論結果は変わりませんでした。 そこで画像読み込み部分を改善しようと思い、今までは KingFisher をアプリ内で使っていたのですが、これを機に少しでも速度を早めようと表示速度が速いと評判の Nuke を導入してみました。

ですが、体感ではあまり分からないレベルです。

そもそもの問題はGoogle Cloud Storage側にあるのではないかと思いコンソールで設定など色々調べていたところ、バケットのロケーションがUSになっていました。

どうやらデフォルトでFirebase側のプロジェクトの設定と紐づくようで、GCP側でバケットを手動で作らない限り自動的にUSになります。 試験的にアジアのバケットに画像データを用意したところスピードが劇的に向上しました。 途中でバケットのロケーションを変更することはできないので、既に本番稼働中のプロジェクトに関しては新しくバケットを作り既存のバケットからデータを転送する作業*2が必要となりますので注意してください。

Cookpadさんのエンジニアブログで先日公開された記事*3でも、Firebase Cloud Storageからの画像取得を改善した例が紹介されていました。 データの持ち方なども工夫されていましたが根本の原因はやはり「US」だったようです。

多くの方がFirebaseとCloud Storageを採用した時に一瞬詰まるポイントかもしれません。

Hosting

管理画面の公開にFirebase Hostingを利用しました。

独自のドメインを設定することもでき、当該ドメインの SSL 証明書が自動的にプロビジョニングされ安全にコンテンツを配信する事ができます。

今回の場合Vueを使っているので、npm run build でdistフォルダに公開用のビルドファイルが生成されます。 後は firebase deploy などといったコマンド一つで簡単にデプロイすることができます。

Functions

Cloud Functions for Firebase

Cloud Storageにファイルをアップロードした時やFirestoreにデータが登録された時など、任意のタイミングでJSの関数を実行する事ができます。 例としては下記の用途が公式ページで紹介されています。

  • ユーザーにフォローされた際に(Firestoreにフォロワー情報が保存される)、Cloud Messagingでユーザーに通知を送る関数を実行する
  • コメントで不適切なコメントがあった際に、ワードをチェックする関数を実行し、コメントを削除するなど

今はFirebaseのSDKが進化しており単体でそれぞれの機能が使いやすいのでFunctionsを使わずともサービス運営も可能だとは思いますが、 Functionsを部分的に採用する事でより効率的なサービス運営が可能になると思います。

Cloud Storage を Cloud Functions で拡張する  |  Cloud Storage for Firebase

終わりに

Firestore以外は過去にも利用した事が何度かあったのと、プロダクトの構造に大きく影響を与えるものはないため懸念はなかったのですが、Firestoreに関しては正直不安でした。 既存サービスをFirestoreを使ったものにリプレイスするというのは特別な理由がない限り必要ないと思いますが、新規サービスなどバックエンドにあまり工数を割かずスピード重視で開発したいという場合には十分に期待に応えてくれるものだと思いました。

Firebaseのサービスは今回紹介した以外にもいくつかあります。 いずれも導入が容易で使いやすさが特徴なので是非一度試してみてください。

今後

Firestoreを採用したことでアプリで扱うデータを自由に持つことができたので、現在は動画配信部分のインフラを整えている最中です。 これでアプリ・管理画面・動画配信基盤といったベースが完成するので、後はAndroid版の開発をしたり、アプリの質を上げたり、色々チャレンジ出来る事が増えそうです。

会社のStyleとして 新たな挑戦を恐れてはならない というものがありエンジニアの技術的なチャレンジを会社として全面的にサポートしてくれます。
少しでも話を聞いて見たいと思った方は是非Wantedlyで会いに来てください!

www.wantedly.com

今回の記事が少しでも多くの方の参考になれば幸いです。
ここまで読んで頂いてありがとうございました。

参考

以下の記事はプロダクトにFirebaseを導入するにあたって非常に参考になりました。

Firebaseを活用したiOSアプリ開発事例 - クックパッド開発者ブログ
Firebaseでバックエンドエンジニア不在のアプリ開発 クックパッドが体感した、メリットと課題 - エンジニアHub|Webエンジニアのキャリアを考える!
Cloud Firestoreの勘所 パート1 — 概要. Cloud Firestoreの概要 | by mono  | google-cloud-jp | Medium