LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

アプリ品質向上のため、iOSアプリにFeature Togglesを導入しました

これは Livesense Advent Calendar 2024 DAY 23 の記事です。

アルバイト事業部開発グループの三野田です。

現在、マッハバイトのアプリチームでは、普段の施策開発と並行して、アプリ品質の向上に取り組んでおり、UIテストの導入や設計の見直しなどを行なっています。

made.livesense.co.jp

今回は、アプリ品質向上の取り組みの一環としてFeature Togglesの導入を行なったため、紹介します。

今回扱わないこと

今回、アプリ上の値をリアルタイムで更新するために、Firebase Remote Configを利用していますが、Firebase Remote Configの導入手順や実装詳細には触れず、すでにアプリに導入されている前提で記載しています。

導入手順や実装詳細について詳しく知りたい方はFirebase Remote Configの公式ページをご参照いただけますと幸いです。

firebase.google.com

アプリチームが抱えている課題

アプリチームでは、クラッシュなどのユーザーにとって不利益になるような不具合に対して、より迅速に対応したいという声が上がっていました。

具体的には、現在のアプリのリリースにはAppleやGoogleの審査が必要なことに加え、審査に通ってリリースを行なってもユーザーがアプリをアップデートするまではリリース内容が反映されません。

そのため、修正が完了したとしても、その内容がユーザーに届くまでには少なくない時間を要してしまいます。

そこで、この審査の待ち時間とリリース内容反映までの時間を可能な限りゼロにしたいと考え、Feature Togglesを導入することにしました。

Feature Toggles

Feature Togglesとは、コードを変更することなくシステムの動作を変更できるようにする開発手法のことです。

martinfowler.com

例えば、ある機能の公開/非公開や動作内容を簡単に切り替えられるようにしておくことで、下記のようなメリットが得られます。

  1. 開発期間中にTogglesをオフにしておくことで、機能が未完成の状態でもメインのブランチにマージできるため、各ブランチの生存期間が短くなりコンフリクトの発生を抑えることができる
  2. (アプリ開発において)Togglesのオン/オフを動的に切り替えられるようにしておくことで、AppleやGoogleの審査完了を待つことなく、アプリの動作を変更することができる

Feature Togglesの種類

Feature Togglesは、その「生存期間」と「どの程度動的に変更されるか」によって、4つのカテゴリに分類できます。

https://martinfowler.com/articles/feature-toggles.html

Release Toggles

前述の素早くメインのブランチにマージを行う開発だけではなく、動作確認まで完了している機能を何らかの理由で公開したくない場合などで使用されます。

Togglesの生存期間が1〜2週間程度で、4つのカテゴリのうち唯一、変更が静的な(同一バージョンにおいてTogglesのオン/オフが切り替わらない)Togglesです。

Experiment Toggles

A/Bテストなどで使用され、ユーザーごとにTogglesのオン/オフが切り替わります。

A/Bテストで使用されるということもあって、十分なサンプル数を得るために、Togglesの生存期間が数時間なこともあれば数週間にもなる可能性があります。

Ops Toggles

パフォーマンスへの影響が不明な新機能などをリリースした際に、何か問題があっても新機能をすぐ非公開にできるようにしておきたい場合などに使用されます。

新機能をリリースして問題なく運用可能と判断できたらTogglesを破棄します。

Permission Toggles

プレミアムユーザーや社内ユーザーなど、特定のユーザーに限定してTogglesをオンに切り替えたい場合に使用します。

プレミアムユーザーに公開される機能を管理する場合などは、Togglesの生存期間が非常に長くなります。

今回使用するFeature Toggles

今回は、新機能に不具合が発生した場合には機能を非公開にできるようにしておき、不具合なく安定して稼働していると判断できた場合にはTogglesを削除したいため、Ops Togglesに該当しそうです。

しかし、単純にOps Togglesのみでオン/オフを管理してしまうと、正式リリースより以前のバージョンでも機能がオンになってしまいます。

そこで、開発中はRelease Togglesでオン/オフを管理し、正式な機能リリースのタイミングでOps Togglesへ切り替えるようにします。

実装

Togglesの定義

オン/オフを切り替えたい機能に合わせてTogglesを定義しておきます。

enum FeatureToggleKey: String {
    case featureToggleTest1 = "feature_toggle_test_1"
    case featureToggleTest2 = "feature_toggle_test_2"

    enum Category {
        case release
        case ops
    }

    var category: Category {
        switch self {
        case .featureToggleTest1:
            .release
        case .featureToggleTest2:
            .ops
        }
    }
}

また、それぞれのTogglesがどのカテゴリに属するのかもここで定義しています。前述した通り、開発中は.releaseにしておき、正式リリースのタイミングで.opsに切り替えます。

Togglesの判定処理

定義したTogglesが有効かどうかを問い合わせるための、isEnabled(feature:)という真偽値を返す関数を持った構造体FeatureToggleImplを用意します。

FeatureToggleImplではRelease TogglesとOps Togglesのどちらの方法で判定するかの分岐処理のみを行っています。

protocol FeatureToggle {
    func isEnabled(feature: FeatureToggleKey) -> Bool
}

struct FeatureToggleImpl<Release: ReleaseToggle, Ops: OpsToggle>: FeatureToggle {
    private let release: Release
    private let ops: Ops

    init(
        release: Release = ReleaseToggleImpl(),
        ops: Ops = OpsToggleImpl<FirebaseRemoteConfigImpl>()
    ) {
        self.release = release
        self.ops = ops
    }

    func isEnabled(feature: FeatureToggleKey) -> Bool {
        switch feature.category {
        case .release:
            release.isEnabled(feature: feature)
        case .ops:
            ops.isEnabled(feature: feature)
        }
    }
}

また、テスト時にDIを行えるように、Protocolを使ってインターフェースを定義しています。

各Togglesの実装詳細

ReleaseToggleImplの実装はシンプルで、開発中は常に機能を非公開にしておきたいため、常にfalseを返すようにします。

struct ReleaseToggleImpl: ReleaseToggle {
    func isEnabled(feature: FeatureToggleKey) -> Bool {
        false
    }
}

OpsToggleImplは値を動的に切り替えたいため、Firebase Remote Configから値を取得し、その値を返すようにします。

struct OpsToggleImpl<RemoteConfig: FirebaseRemoteConfig>: OpsToggle {
    func isEnabled(feature: FeatureToggleKey) -> Bool {
        RemoteConfig.getBoolValue(forKey: feature.rawValue)
    }
}

Togglesの呼び出し

ここまでの実装で、下記コードのようにしてFeature Togglesによる機能の公開制御を行うことができるようになりました。

if featureToggle.isEnabled(feature: .featureToggleTest1) {
    // 新機能
} else {
    // 既存機能
}

現状でも運用は可能ですが、このままでは当該機能の動作を確認したい場合に、各Togglesの返却値を変更して再ビルドを行ったり、Remote Configの値をFirebaseコンソール上で都度変更したりする必要があります。

そこで、デバッグ用にローカルでTogglesの値を保持し、デバッグ版アプリではローカルに保持している値を参照するように変更しておきます。

デバッグ用コードの追加

FeatureToggleImplに、set(_:for:)というFeature Toggelsの値を設定するための関数を追加します。

処理内容はisEnabled(feature:)と同じ要領で、ReleaseToggleImplOpsToggleImplへの分岐処理のみを行うようにします。

protocol FeatureToggle {
    ...
    func set(_ enabled: Bool, for feature: FeatureToggleKey)
}

struct FeatureToggleImpl<Release: ReleaseToggle, Ops: OpsToggle>: FeatureToggle {
    ...

    func set(_ enabled: Bool, for feature: FeatureToggleKey) {
        switch feature.category {
        case .release:
            release.set(enabled, for: feature)
        case .ops:
            ops.set(enabled, for: feature)
        }
    }
}

ReleaseToggleImplにもset(_:for:)を追加し、実際のローカルへの保存処理を実装します。今回はUserDefaultsを使用しました。

また、isEnabled(feature:)のコードを修正して、リリース版アプリの動作はそのままに、デバッグ版アプリでのみUserDefaultsに保存された値を返すように修正します。

struct ReleaseToggleImpl: ReleaseToggle {
    func isEnabled(feature: FeatureToggleKey) -> Bool {
        #if RELEASE
            false
        #else
            userDefaults.bool(forKey: prefix + feature.rawValue)
        #endif
    }
}

// MARK: - デバッグ版でのみ使用される処理
extension ReleaseToggleImpl {
    private var userDefaults: UserDefaults { UserDefaults.standard }
    private var prefix: String { "release_toggle_" }

    func set(_ enabled: Bool, for feature: FeatureToggleKey) {
        userDefaults.set(enabled, forKey: prefix + feature.rawValue)
    }
}

OpsToggleImplに関しては、Remote Configの値をアプリ上で書き換えられなかったため、Remote Configの値をデフォルト値としてUserDefaultsに設定しています。

デフォルト値として設定することで、Remote Configを参照しつつ、機能のオン/オフをアプリ上で切り替えられるようにしています。

struct OpsToggleImpl<RemoteConfig: FirebaseRemoteConfig>: OpsToggle {
    func isEnabled(feature: FeatureToggleKey) -> Bool {
        #if RELEASE
            RemoteConfig.getBoolValue(forKey: feature.rawValue)
        #else
            let enabled = RemoteConfig.getBoolValue(forKey: feature.rawValue)
            // Remote Configの値をデフォルト値として設定
            userDefaults.register(defaults: [prefix + feature.rawValue: enabled])
            return userDefaults.bool(forKey: prefix + feature.rawValue)
        #endif
    }
}

// MARK: - デバッグ版でのみ使用される処理
extension OpsToggleImpl {
    private var userDefaults: UserDefaults { UserDefaults.standard }
    private var prefix: String { "ops_toggle_" }

    func set(_ enabled: Bool, for feature: FeatureToggleKey) {
        userDefaults.set(enabled, forKey: prefix + feature.rawValue)
    }
}
デバッグ時の切り替え

これで、下記のようにしてTogglesの値をアプリ上で自由に変更できるようになりました。

featureToggle.set(true, for: .featureToggleTest2)

Togglesのオン/オフを切り替えるデバッグ用画面

運用

Feature Togglesを使った開発全体のフローは以下のようになる想定です。

graph TD;
    A[Release Togglesの追加] --> B[施策開発];
    B --> C[Ops Togglesに切り替え];
    C --> D[動作確認];
    C2[Remote Configパラメータ追加] --> D;
    D --> E[リリース];
    E --> F{機能に問題あり?};
    F -- 問題なし --> G[Ops Toggles削除] --> H[不要になったRemote Configパラメータ削除];
    F -- 問題あり --> I[Remote Configの値をfalseに変更] --> J[新規Remote Configパラメータ追加] --> K[Ops Togglesの参照先を新規パラメータに変更] --> L[不具合修正] --> D;

ただ、Feature Togglesの正式な運用は年明けからを予定しているため、実際に運用していく中でより適切な形に変更するかもしれません。もし変更があった場合には、後述の今後の改善事項と合わせて記事に書きたいと思っています。

今後

正式な運用に向けて、AndroidアプリにもFeature Togglesを導入しつつ、下記のような改善を行っていきたいと考えています。

実装面での改善

今回の実装では、Togglesの呼び出し箇所でif文を記述して処理を分岐させるという形にしました。この形はある程度自由な書き方ができる一方で、Togglesの分岐処理が頻出したり既存の分岐処理と組み合わせたりなど、容易に複雑度の高いコードを書けてしまうというデメリットもあります。

今回の導入で参考にした記事では、Strategyパターンを使って実装に制限をかけることでの保守性向上を行う手法が紹介されており、マッハバイトでも今後の運用でTogglesが増える可能性が高いため、積極的に取り入れていく予定です。

運用面での改善

リリース後のOps Toggles削除について、現状は全く自動化できておらず、開発者側でOps Toggles削除のIssueを作成し、適切なタイミングで着手するという流れになってしまっています。

しかし、現状のままでは、Issue作成の抜け漏れだったり、運用が明確ではないことによる齟齬だったりの問題が考えられます。

そのため、「Release TogglesからOps Togglesへの変更があった場合には、自動でOps Toggles削除のIssueが作られる」のようなワークフロー作成を最優先で行っていく予定です。

A/Bテストへの流用

Feature Togglesは、Experiment Togglesでの説明の通り、A/Bテストにもその仕組みを利用することができます。

現在のマッハバイトアプリでは社内製のツールを使ってA/Bテストを実施していますが、将来的にFirebase A/B Testingへの移行を考えています。

今回実装したFeature Togglesの仕組みを用いることで、Firebase A/B Testingの導入がかなり楽になるのではないかと考えています。

firebase.google.com

まとめ

現在、アプリチームではアプリ品質向上に積極的に取り組んでおり、その一環として「リリースにおける審査の待ち時間とリリース内容反映までの時間を短縮する」という課題を解決するためにFeature Togglesを導入しました。

Feature Togglesというものがオン/オフを切り替えるだけというシンプルな考え方に基づいた手法のため、実装は比較的簡単に進めることができました。

しかし、使い方や運用には注意が必要で、システムをより複雑にしてしまったり、不要なTogglesが増えすぎてしまったりするため、使い方や運用を考えることにより多くの時間を費やす必要があります。

また、今回はRelease Toggles + Ops Toggelsでの実装を行いましたが、Release Togglesのみでの実装であっても、素早いマージや(リリースは必要ですが)素早い切り戻しを実現することができます。

もしFeature Togglesを使った開発に興味がある場合は、Release Togglesのみで小さく始めてみるというのもおすすめです。その際に本記事が少しでも参考になれば幸いです。