dip Engineer Blog

Engineer Blog
ディップ株式会社のエンジニアによる技術ブログです。
弊社はバイトル・はたらこねっとなど様々なサービスを運営しています。

強制アップデートのベストプラクティスを探る

はじめに

こんにちは。iOSエンジニアの@satoshi-babaです。
バイトルアプリでは強制アップデートの機能を備えています。
強制アップデートを実装、また実装する上で発覚した課題を解決するまでの道のりを共有したいと思います。

アップデートしてくれない問題

モバイルアプリ開発者なら誰もが経験あると思いますが、なかなかアップデートしてくれないユーザさんいますよね。
アプリの自動更新機能もあることにはありますが、機能自体をオフにしている方も結構いらっしゃいます。

アップデートされなくても動くからいいじゃん!
そう思う方もいらっしゃるかもしれませんが、開発者としてもユーザとしても以下のようなデメリットが潜んでいます。

  • 素早くサービスとしての価値を提供できない。
  • データの破損などの潜在的なバグでユーザが不利益を被る可能性がある。
  • メンテナンスのコストが増える。(巡り巡って機能開発に手が回らない)

ユーザにもアップデートをしたくない理由はあると思いますが、
それとトレードオフしても強制アップデートはあった方がいいと思います。

求める機能

強制アップデートとして求める機能はこんな感じになりました。

MUST

  • アプリ起動時/復帰時に強制アップデートのチェックを実施する
  • アプリバージョンおよびOSバージョンで対象を絞る

WANT

  • できるだけ即時実行する

Firebase Remote Configで実装

強制アップデートの実装にあたって、Firebase Remote Configが真っ先に頭に浮かびました。
コンソールからパラメーターを簡単に変更でき、OSやアプリバージョンなどユーザセグメントを設定することもできます。

f:id:dip-dev:20210412133809p:plain

(引用元: https://firebase.google.com/docs/remote-config?hl=ja)

実装としてもコンソールでパラメーターを追加したら、アプリ側で対象のRemote Configに対してフェッチするだけです。

remoteConfig.fetch() { status, errror in
    if status == .success || (errror != nil && status == .throttled) {
        // RemoteConfigが取得成功した場合に、パラメーターが使用できるようにRemoteConfigを有効化する。
        self.remoteConfig.activateFetched()
        guard let version = self.remoteConfig["version"].stringValue else {
            return
        }
        self.forceUpdateIfNeeded(version)
    }
}

これで完璧かと思ったらRemote Configには落とし穴がありました。

Remote Configのフェッチ時間は12時間で、しかもフェッチの回数も1時間あたり5回までという制限があります。
フェッチの間隔を調整したりする必要があることが判明しました。

フェッチ時間に関する話は公式ドキュメントにも記載があります。
https://firebase.google.com/docs/remote-config/use-config-android?hl=ja#throttling

実装検討

MUST要件はクリアできたものの、WANT要件がクリアできません。
何かいいものがないか検討したものがこちらです。

1. ストア情報を基にアップデート判断する。

1つ目の方法はApp Store/Google Playなど、ストア情報をベースにアップデートの判断をします。
iOSであればApp Store Connect APIで取得することができます。
APIを使用できない状況下でも、対象のページをGET通信してDOM解析して引っこ抜くことができます。

手軽に実装ができますが、「DOMが変更された場合に対応ができない」「対象を絞れない」というデメリットがあり、
要件とマッチせずに早期に見送りとなりました。

2. Firebase Storageにjsonファイルを配置し参照する。

2つ目が対象を記載したJSONファイルをサーバー上に配置して参照します。
以下のようなjsonを配置しアプリ側で解析することで対象を絞ることもできます。

{
  "created_at": "2021/03/28 10:00:00",
  "details": [
    {
        "os_version": "13.*.*",
        "app_version": [
            "1.0.0",
            "1.1.0",
        ],
        "title": "強制アップデートのお願いのタイトルを書く",
        "message": "{強制アップデートのお願いの本文を書く}",
        "button_text": "OK",
        "redirect_url": "https://apps.apple.com/jp/app/xxx",
    },
    {
        "os_version": "14.*.*",
        "app_version": [
            "1.0.0",
            "1.1.0",
        ],
        "title": "強制アップデートのお願いのタイトルを書く",
        "message": "{強制アップデートのお願いの本文を書く}",
        "button_text": "OK",
        "redirect_url": "https://apps.apple.com/jp/app/xxx",
    }
  ]
}

Firebase Storegeに配置する場合はセキュリティルールを設定し読み取り権限のみを与えます。

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read;
      allow write: if request.auth != null;
    }
  }
}

この方法ではシンプルな実装にも関わらず、最低限の機能が備わっており即時実行もできていると思います。
その代わりにFirebase Remote Configと同等のセグメント設定をするためには、それなりの実装が必要になります。
またサーバー側でキャッシュコントロールを入れる面倒なことになるので注意が必要です。

3. Firebase Remote Configのフェッチ時間を短く設定してある程度は諦める。

次に考えられるのがFirebase Remote Configを使用し、適切ねフェッチ時間を設定しつつ、ある程度の即時実行は諦める選択肢です。

アプリ側でRemote Configの初期化時にsetMinimumFetchIntevalInSecondsを設定するか、

FirebaseRemoteConfigSettings configSettings = 
    FirebaseRemoteConfigSettings.Builder()
      .setMinimumFetchIntervalInSeconds(1) // プロジェクトによって適切な時間を設定する。
      .build(); 

フェッチのタイミングでキャッシュ時間を設定するのみです。

// フェッチのキャッシュなしで、RemoteConfigへのFetchを実施する。
remoteConfig.fetch(withExpirationDuration: TimeInterval(0)) { status, errror in
    // フェッチの上限到達の場合はstatusにthrottledが格納される。
    if remoteConfigFetchStatus == .success || (errror != nil && status == .throttled) {
        // RemoteConfigが取得成功した場合に、パラメーターが使用できるようにRemoteConfigを有効化する。
        self.remoteConfig.activateFetched()
        guard let version = self.remoteConfig["version"].stringValue else {
            return
        }
        self.forceUpdateIfNeeded(version)
    }
}

この方法だと先にも述べた通り、1時間あたりに5回しか実行できないため、WANT要件は少し漏れてしまいます。
12分おきにするのがベターで、よっぽどのことがなければ耐えられると思いますが、サービスによっては考慮の余地があります。

4. Cloud Functions For Firebaseで設定変更を通知する。

4つ目がCloud Functionsを使用して、設定変更を通知する方法になります。

f:id:dip-dev:20210412133839p:plain

(引用元: https://firebase.google.com/docs/remote-config/propagate-updates-realtime)

Firebase Remote Configの設定を変更した際に、Cloud Functionsで検知してアプリにサイレントPush通知を送信します。

// Remote Configの更新があった際に、 PUSH_REQUEST_CHANGEのトピックに対してPush通知を送る。
exports.pushConfig = functions.remoteConfig.onUpdate(versionMetadata => {
  const payload = {
    topic: "PUSH_REQUEST_CHANGE",
    data: {
      "CONFIG_STATE": "STALE"
    }
  };

  return admin.messaging().send(payload).then(resp => {
    console.log(resp);
    return null;
  });
});

アプリ側はCloud Functionsで更新通知用として設定したトピックに所属するように設定します。

/// Device Tokenの発行に成功した時に発火する。
internal func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String) {
    let dataDict: [String: String] = ["token": fcmToken]
    NotificationCenter.default.post(name: Notification.Name("FCMToken"), object: nil, userInfo: dataDict)

    // Remote Configの更新通知用のTopicを定めておく。
    messaging.subscribe(toTopic: "PUSH_REQUEST_CHANGE ") { _ in }
}

変更通知のPush通知を受け取ったら、Remote ConfigのフェッチフラグをUserDefaultなどに保存しておいて...

/// Notificationを受信した時に呼ばれる。
/// アプリ未起動/フォアグラウンド/バックグランドの全てで呼び出される。
internal func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {

    // Cloud FunctionでRemote Configをトリガーに送っているNotificationの名称と同じであれば...
    if userInfo.index(forKey: "CONFIG_STATE") != nil {
        // Remote Configの強制フェッチを有効にする。
        UserDefaults.standard.set(true, forKey: ”REMOTE_CONFIG_FORCE_FETCH")
    }
    completionHandler(UIBackgroundFetchResult.newData)
}

フェッチするタイミングで保存していたフラグを確認して、即時フェッチが必要がであればフェッチ時間を0として実行します。
これで最新のRemote Configが取得することができました。

// 基本的には1時間程度のキャッシュを設定しておき...
var expirationDuration = 3_600

// リモートコンフィグの更新通知を受領してたら即時取得を実施する。
if UserDefaults.standard.bool(forKey: " REMOTE_CONFIG_FORCE_FETCH ") {
    expirationDuration = 0
    UserDefaults.standard.set(false, forKey: " REMOTE_CONFIG_FORCE_FETCH ")
}

// RemoteConfigへのFetchを実施する。
remoteConfig.fetch(withExpirationDuration: TimeInterval(expirationDuration)) { status, errror in
    {省略}
}

任意のタイミングでフェッチを仕掛けられるため、強制アップデートの実装案としてはかなり良いと思います。
とはいえ1時間に5回の制限は変わらないので、無闇に更新をかけないように運用でカバーする必要があります。

結果

最終的に採用されたのは2になりました。

Firebase Remote Configのユーザセグメントの機能は強力ではありましたが、今回の要件に対してはオーバースペックに感じます。
ケースバイケースで2もしくは4を選択するのが良さそうです。

皆さんが強制アップデート機能を実装するときの参考にしてみてください!

著者

dippeople.dip-net.jp