SwiftとFirebaseでアプリをリリースした話　〜Firebaseについて〜

先日、サーバーサイドは全てFirebaseで担っているアプリ「シェアフィ」をリリースしました。
（制作期間は約5ヶ月、個人開発機関としては最長！）

開発したアプリ「シェアフィ」の紹介をしつつ、Firebaseの説明をしていきます。
今後の開発の参考にしていただければ、幸いです。


- [アプリ「シェアフィ」について](#アプリシェアフィについて)
- [使用Firebase](#使用Firebase)
 - [FireStore](#FireStore)
 - [FirebaseHosting](#FirebaseHosting)
 - [FirebaseFunctions](#FirebaseFunctions)
 - [FirebaseCrashlytics](#FirebaseCrashlytics)
 - [AppDistribution](#AppDistribution)
 - [FirebaseAnalytics](#FirebaseAnalytics)
 - [CloudMessage](#CloudMessage)
 - [RemoteConfig](#RemoteConfig)
 - [DynamicLinks](#DynamicLinks)
- [最後に](#最後に)

##アプリ「シェアフィ」について
簡単にアプリの説明です。
「シェアフィ」は、買い物メモを作成して、共有できるアプリになります。
特徴としては、
　共有したら、相手にもリアルタイムでデータが反映される
　　→LINEとかTwitter見たいに勝手に更新される
　データを更新したら、プッシュで相手に知らせてくれる
　　→LINEとかと同じ

この特徴を実装するにあたり、Firebaseが頑張ってくれています。

##使用Firebase
 - FireStore
 - FirebaseHosting
 - FirebaseFunctions
 - FirebaseCrashlytics
 - AppDistribution
 - FirebaseAnalytics
 - CloudMessage
 - RemoteConfig
 - DynamicLinks

###用途
- FireStore
 - メモの保存
- FirebaseHosting
 - FAQなどのWEBページ（HTML＋CSS＋JS）
- FirebaseFunctions
 - 連携した相手のプッシュトークン取得とリモートプッシュ
- FirebaseCrashlytics
 - クラッシュ計測
- AppDistribution
 - 公開前のデバッグのための配信
- FirebaseAnalytics
 - アナリティクス
- CloudMessage
 - リモートプッシュ通知
- RemoteConfig
 - 強制アプデ用
- DynamicLinks
 - 連携用の招待コード発行


##FireStore
###特徴
- リアルタイムで更新してくれる
 - 「A」がデータを更新したら、「B」の表示されているデータも更新される
- 構造が[行,列]ではなくコレクションとドキュメント

DB構造が特殊のため、RDBに慣れている人は少々手こずるかもしれません。

###設計
シェアフィでは、
<img width="811" alt="スクリーンショット 2020-05-08 18.49.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246668/243fca2f-0af2-b747-7cc2-df942429dae5.png">

上記設計になります。
サブコレクションは、ドキュメントの中に入れたコレクションです。
データ部分には、データ構造（例 {uuid:"000",name:"userName"}）を入れています。
Listに関して、ドキュメントでは更新を検知できないため、コレクション→ドキュメント→サブコレクションという構造になっています。

###コード
```swift:FirestoreManager.swift
class FirestoreManager {
    
  let firestore = Firestore.firestore()

  //ドキュメント以下のデータが更新されたら、その都度取得
  func snapshotSelect() {
    firestore.collection("collectionName").document("documentName").addSnapshotListener { snapshot, error in
      guard let _snapshot = snapshot else {
        return
      }
      print("Response:\(_snapshot)")
    }
  }
    
  //1度だけの取得
  func singleSelect() {
    firestore.collection("collectionName").getDocuments() { queryDocuments, error in
        guard let _queryDocuments = queryDocuments else {
            return
        }
        print("Response:\(_queryDocuments.documents)")
    }
  }

  //絞り込み（WHERE）
  func whereSelect() {
        firestore.collection("collectionName")
            .whereField("keyName", isEqualTo: "value")
            .getDocuments() { queryDocuments, error in
                guard let _queryDocuments = queryDocuments else {
                    return
                }
                print("Response:\(_queryDocuments.documents)")
        }
    }
    
  //データの追加（自動でドキュメント名設定）
  func insertAutoDocumentName() {
      firestore.collection("collectionName")
          .addDocument(data: "setData") { error in
          if let _error = error {
              print("Error writing document: \(_error)")
          } 
          else {
              print("Document successfully written!")
          }
      }
  }
  //データの追加（主導でドキュメント名設定）
  func insertAtDocumentName() {
      firestore.collection("collectionName")
          .document("docmentName").setData("setData") { error in
          if let _error = error {
              print("Error writing document: \(_error)")
          } else {
              print("Document successfully written!")
          }
      }
  }

  //データの更新
  func update() {
      firestore.collection("collectionName").document("documentName")
          .setData("setData", merge: true) { error in
          if let _error = error {
              print("Error writing document: \(_error)")
          } else {
              print("Document successfully written!")
          }
      }
  }
  
  //データの削除
  func deleteList(){
      firestore.collection("collectionName").document("documentName").delete() { err in
          if let err = err {
              print("Error removing document: \(err)")

          } else {
              print("Document successfully removed!")
          }
      }
  }
}
```

こんな感じになっています。
注意点としては、
**リアルタイムでの更新をしたい場合は、「snapshot」でドキュメントを監視する。**
** データの更新の時は「merge: true」を付ける**
の2点です。

###参考
[Cloud Firestoreを実践投入するにあたって考えたこと](https://qiita.com/1amageek/items/d606dcee9fbcf21eeec6)
[Firestoreを試してみた](https://qiita.com/miyae/items/6988c1b61b76b4938ae6)
[iOSではじめてのFirestore [Swift]](https://qiita.com/takahiro_ishitsuka/items/c8ed9c0aa4d3257fc7ee)


##FirebaseHosting
###特徴
- サーバー用意せずともWEBページの公開ができる

###設計
シェアフィでは、HTMLとCSSとJSでFAQページを作成しました。
あとは、独自ドメインの適用してます。

###コマンド
```
#Firebaseへログイン
$ firebase login

#作業ディレクトリへ移動
$ cd dir

#初期化
$ firebase init
```
<img width="564" alt="スクリーンショット 2020-05-10 14.17.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246668/4b085996-7b69-ca02-3ebd-428cff47ef24.png">

```
# 下記を選択
Hosting: Configure and deploy Firebase Hosting sites
```

Firebaseに複数プロジェクトがある場合は、任意のプロジェクトを選択
その後、色々聞かれますが、全て **No** を選択

```
# デプロイ
$ firebase deploy
```

###参考
[【2018年版】利用前に知って欲しいFirebase Hostingにできること・できないこと](https://qiita.com/kgsi/items/66e1d41c4d6346733a59)
[Firebaseで動かすNode.jsアプリ入門🔥](https://qiita.com/seya/items/225f859d775b31047000)

##FirebaseFunctions
###特徴
- プログラムだけで実行できる
    - 環境構築の必要がない
- アプリから直接呼び出せる

###設計
リストを更新した際に共有相手にプッシュで知らせるために、使用しています。
コードは、Node.jsで記載

###コード
```Node.js:Push.js
const admin = require('firebase-admin');
const functions = require('firebase-functions');

admin.initializeApp(functions.config().firebase);

let db = admin.firestore();

exports.pushSubmit = functions.https.onCall((request, response) => {

//  パラメータの取得
  const userId = request.useID;
  const itemName = request.itemName;
//  プッシュの内容
  const payload = {
          notification: {
            title: "title",
            body: "messege",
            badge: "1",
            sound:"default"
          }
        };
// 　Firestoreに検索
  var pushRef = db.collection('collection').doc("documentName");
  pushRef.get()
  .then(doc => {
    var members = doc.data();
    for (var key in members) {
      if (key != userId){
        pushToDevice(members[key],payload);
      }
    }
  })
    .catch(err => {
      throw new functions.https.HttpsError('invalid-argument', 'data.name is undefined.', err)
    });
});

function pushToDevice(token, payload){
// priorityをhighにしとくと通知打つのが早くなる
  const options = {
    priority: "high",
  };

  admin.messaging().sendToDevice(token, payload, options)
  .then(pushResponse => {
    return { text: token };
  })
  .catch(error => {
    // response.send(error);
    throw new functions.https.HttpsError('unknown', error.message, error);
  });
}
```

上記がFirebase側のコードになります。
アプリから受け取ったIDを元にFirestoreへアクセスし、プッシュの送り先を取得してます。

```swift:CloudFunctionsManager.swift
import Firebase

class CloudFunctionsManager {
  let parameters  = [
      "useID": "useID",
      "itemName": "itemName"
  ]
  functions.httpsCallable("pushSubmit").call(parameters) { (res, error ) in
      if let error = error {
          print(error)
      }
  }
}
```

上記が、アプリからCloud Functionsのプログラムにアクセスするものになります。
APIやっているのにすごく短くて、簡潔です。

###参考
[Firebase で Cloud Functions を簡単にはじめよう](https://qiita.com/tdkn/items/2ed2b01f2656fc50da8c)
[Firebaseで動かすNode.jsアプリ入門🔥](https://qiita.com/seya/items/225f859d775b31047000)
##FirebaseCrashlytics
###特徴
- アプリ公開後のクラッシュ率を測れる

###設計
シェアフィでは公開後のバグ管理で使用しています。
無料で使用できるのが大きなメリット。

###注意点
最新のFirebaseCrashlyticsを使う場合では、ちょいちょいコードが変わっています。

```
import Crashlytics
　　↓
import FirebaseCrashlytics

Crashlytics.sharedInstance().crash()
   ↓
Crashlytics.crashlytics().fatalError()
```
その中でもハマったところをピックアップしました。
気をつけてください。

最新ではなく、「3.14.0」辺りの導入をお勧めします。

###参考
[Firebase Crashlyticsを使ってみた](https://qiita.com/giiiita/items/dd7b9c2a9dc002fe3cd7)
[[Firebase][iOS] Firebase Crashlytics を導入してみたら環境で分けるのにハマった話](https://dev.classmethod.jp/articles/firebase-crashlytics-ios-2/)
[Firebase Crashlytics を使ってみる](https://firebase.google.com/docs/crashlytics/get-started?platform=ios&authuser=1)
[Upgrade to the Firebase Crashlytics SDK
](https://firebase.google.com/docs/crashlytics/upgrade-sdk?hl=ja)

##AppDistribution
###特徴
- アプリ公開前に任意のユーザーに配布できる
    - 複数人でのデバッグ

###設計
Fastlaneを使用して、ファイルのアップロードをしています。

###Fastlane導入

```
#インストール
$ cd　Xcodeファイルのあるディレクトリへ（**.xcworkspace or **.xcodeprojがあるとこ）

$ gem install fastlane
#↑でダメだったら、
$ sudo gem install fastlane --verbose
#初期化
$ fastlane init
#fastlane のプラグインを追加しておく
$ fastlane add_plugin firebase_app_distribution
```
下記を参照に回答
[iOSアプリのリリースフロー自動化ツールfastlaneの導入](https://qiita.com/ToraDady/items/4c6ab9f6bd2fde83f82d#%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97)

作成されたディレクトリ「fastlane」→「Fastfile」

```Fastfile

default_platform(:ios)

platform :ios do
  
  desc "Description of what the lane does"
  lane :app_distribution do
    build_app(
      scheme: "scheme",
      export_options: {
        method: "ad-hoc"
      }
    )
    firebase_app_distribution(
      app: "Firebaseのプロジェクトから",
      groups: "作成したグループ名",
      release_notes: "Lots of amazing new features to test out!"
    )
  end
  after_all do |lane|
  # 実行が成功したら
  end
  error do |lane, exception|
  # 実行が失敗したら
  end
end
```

ざっくりですが、「Fastfile」の中身になります。

###参考
[Fastlane から Firebase App Distribution(Beta版) でテストアプリを配信する](https://qiita.com/kitaharamikiya/items/e8719e523cf5e106e2a1)
[[iOS] Firebase App Distributionを使用してiOSアプリを配布する](https://dev.classmethod.jp/articles/ios-firebase-distribution/)
[CircleCI + fastlane で Firebase App Distribution に ipa をアップロードする](https://qiita.com/koogawa/items/fb89c1ae8ff6913a184d)
[fastlane を使用して iOS アプリをテスターに配布する](https://firebase.google.com/docs/app-distribution/ios/distribute-fastlane?hl=ja)

##FirebaseAnalytics
###特徴
- アプリ公開後の分析
    - イベント数などに制限/クセがあるため、設計が重要

###設計
FirebaseAnalyticsは、だいぶ癖があります。
まず、収集できるものに対してかなり制限があります。
そして、管理画面から見れるデータにも制限があります。
詳しくはこちらから　
[収集と設定の上限](https://support.google.com/firebase/answer/9237506?hl=ja&ref_topic=6317484)

特に気をつけなければいけないのは、パラメータです。
収集の制限はありませんが、管理画面で閲覧できるのは、1プロジェクト50項目（テキスト10、数値40）だけです。
また登録できるイベントは500個までで、削除できません。

上記を踏まえた上でシェアフィでは、イベントではなく、パラメータで各種数値の計測を行うようにしています。

- 計測内容の一部
<img width="823" alt="スクリーンショット 2020-05-10 19.30.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246668/ff298daa-83e3-aaa8-f75a-a91deb0cc88e.png">
- 実際の管理画面

<img width="404" alt="スクリーンショット 2020-05-10 19.33.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246668/9c6c9324-db8b-02b7-a263-597a29cb31c7.png">

###コード
```swift:FirebaseAnalytics.swift
import Firebase

class FirebaseAnalytics {
//   イベント
  private func pushBaseAnalyticsEvent(eventName: String, param: [String : NSObject]){
      Analytics.logEvent(eventName, parameters: param)
  }
//   ユーザープロフィール
  private func pushBaseAnalyticsUserProfile(profilrName: String, value: String){
      Analytics.setUserProperty(value, forName: profilrName)
  }
}
```

計測を行うため実装は単純だと思います。

###参考
[iOS 用 Google アナリティクスを使ってみる](https://firebase.google.com/docs/analytics/ios/start?hl=ja)
[Firebase Analyticsのイベントとパラメータをどう設定するべきか？](https://qiita.com/KIRIN3qiita/items/7cafe260706c2c0a40ff)

##CloudMessage
###特徴
- プッシュ通知機能
    - 管理画面からと、プログラム経由がある
    
###設計
「CloudMessage」は、端末から取得できるFCMトークン（プッシュを送るために必要なトークン）を元にプッシュを送ります。
「CloudMessage」はグループ全体にプッシュを送ることや、AnalyticsとRemoteConfigと連携して特定のイベントを起こしたユーザーにだけプッシュを送るといったことができます。

シェアフィでは、プッシュ通知にて買い物を時間を伝えるローカルプッシュと買ったことを相手に知らせるリモートプッシュを使用しています。
「CloudMessage」はリモートプッシュで使用しています。
プッシュを送る相手がLINEのようにだいぶ特定された個人やグループになるため、「CloudMessage」のグループなどは使用できず、個別のFCMトークンを取得して、その人にだけプッシュを送るといったことを行なっています。

#####フロー
1. アプリから「FirebaseFunctions」へUserIDを送る
2. 「FirebaseFunctions」のUserIDから「FireStore」のPushコレクションを検索
3. 取得したFCMトークンを「CloudMessage」に送り、プッシュを送る

###コード
[FirebaseFunctions](#FirebaseFunctions)参照

###参考
(Firebase Cloud Messaging)[https://firebase.google.com/docs/cloud-messaging?hl=ja]
(LaravelでFirebase Cloud Messagingを使ってブラウザにプッシュ通知する)[https://qiita.com/kiyc/items/65ef447ca5f97bd3dad6]

##RemoteConfig
###特徴
- 管理画面でデータを入力し、アプリ側で取得する
    - ABテスト
    - アプリのバージョンを管理して強制アプデなどに使用

###設計
シェアフィでは、アプリの強制アップデートとして使用しています。
RemoteConfigにバージョンを登録して、インストールしているアプリのバージョンと比較し、RemoteConfigのバージョンの方が高かったら、アラートダイアログを出すようにしています。
ちなみにRemoteConfigでバージョン管理している理由は、アプリ公開後に任意タイミングでアップデートをさせたいからです。
###コード
```swift:RemoteConfigManager.swift

import FirebaseRemoteConfig

class RemoteConfigManager {
    static let shared = RemoteConfigManager()
    
    let key = ""
    let remoteConfig: RemoteConfig = RemoteConfig.remoteConfig()
    
    var isUpdate: Bool {
        guard let latestVersion = self.remoteConfig[key].stringValue else { return false }
        
        return atof(latestVersion) > atof(AppConfig.appVersion())
    }
    
    func fetchLatestVersion(complete: (() -> Void)? = nil) {
        self.remoteConfig.fetch(completionHandler: { [weak self] status, error in

            if status == .success {
                self?.remoteConfig.activate()
            }
            complete?()
        })
    }
}
```

アプリからRemoteConfigの値を取得するのが上記コードになります。
 **Key** にRemoteConfigの管理画面に設定したKey名を設定します。

```swift:Alert.swift
let alertController = UIAlertController(title: "アップデート", message: "ストアでアップデートしてください", preferredStyle: .alert)
let action = UIAlertAction(title: Localize.shared.toAppStore, style: .default, handler: { _ in
    guard let url = URL(string: "itms-apps://itunes.apple.com/app/idアプリID") else { return }

    UIApplication.shared.open(url)
})
alertController.addAction(action)
```

差分があった際は、上記のダイアログを表示しストアへ遷移させています。

###参考
[]()
[FirebaseのRemoteConfig使ってみた](https://qiita.com/YutaOkazai/items/ac03104136423dfa8d68)
[Firebase Remote ConfigのiOSへの導入](https://qiita.com/hsylife/items/dbf30e38045a4b3c5395)

##DynamicLinks
###特徴
- 管理画面とプログラムから作成できる
- AndroidとiOSで共有のURL
- イメージは、ディファードディープリンクとディープリンクとユニバーサルリンクを混ぜたもの

###設計
「DynamicLinks」はディープリンクを持ったURLを作成してくれます。

シェアフィでは、ユーザー同士の連携部分で使用しています。

アプリをインストールしていれば、
アプリ起動→連携

未インストールなら、
ストアへ遷移→アプリをインストール→起動→連携

上記のフローを行えるため、採用し使用しています。
また、iOSとAndroidを分ける必要もないことも魅力の1つです。

###コード
```swift:CreateDynamicLinks.swift
// ユニバーサルリンクを使用するために
let url_str: String = "ユニバーサルリンクの対応したURL"
guard let link = URL(string: url_str) else { return }
// Firebase管理画面にて作成したURLでも
let dynamicLinksDomainURIPrefix = "https://〇〇.page.link"
let linkBuilder = DynamicLinkComponents(link: link, domainURIPrefix: dynamicLinksDomainURIPrefix)
linkBuilder?.iOSParameters = DynamicLinkIOSParameters(bundleID: "BundleID")
// 未インストール時にストアへ遷移するためにAppStoreID
linkBuilder?.iOSParameters?.appStoreID = "AppStoreID"
// 対応させるアプリの最小バージョン
linkBuilder?.iOSParameters?.minimumAppVersion = "AppVersion"

linkBuilder?.navigationInfoParameters = DynamicLinkNavigationInfoParameters()
// リンクタップ後、直接ストアへとぶか
linkBuilder?.navigationInfoParameters?.isForcedRedirectEnabled = false

guard let longDynamicLink = linkBuilder?.url else { return }
// 作成したURLを短縮URLへ変更
DynamicLinkComponents.shortenURL(longDynamicLink, options: .none, completion: { url, warnings, error in
    guard let url = url else { return }
    print(url)
})
```

###参考
[[iOS] iOSアプリでFirebase Dynamic Linksを受け取る](https://dev.classmethod.jp/articles/firebase-dynamic-links/)
[FirebaseのDynamic Linksを使ってWebからアプリに誘導してみた](https://qiita.com/yukatou/items/29037eeb609008ab0de0)
[【iOS Firebase Dynamic Links】アプリインストールを経由するときに注意すること](https://qiita.com/AkkeyLab/items/717022d8b56dfac60482)

## 最後に
最後までお付き合いいただき、ありがとうございました。
誤字脱字があったり、何か違っているところがありましたら、コメントや[@gurensouen](https://twitter.com/gurensouen)に、ご連絡いただけますと幸いです。
あと、ここの部分をより詳しく知りたい等がありましたら、ご連絡ください。
可能な限り対応（加筆）します。

これからもアプリの改善をしつつ、経験したことをアウトプットしていきます。
よろしくお願いします。

よかったら、アプリをインストールして、この部分はこのFirebaseの機能を使ってるんだ！
と、発見しつつ使ってみてください。

改めまして、ありがとうございました。

[<img width="200" alt="APPICON" src="https://linkmaker.itunes.apple.com/ja-jp/badge-lrg.svg?releaseDate=2020-05-07&kind=iossoftware&bubble=ios_apps">]
(https://apps.apple.com/jp/app/%E3%82%B7%E3%82%A7%E3%82%A2%E3%83%95%E3%82%A3/id1506102636?mt=8)

![icon-40@3x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246668/d362e821-f205-6e41-f9df-183dcc248b93.png)