Lento con forza

大学生気分のIT系エンジニアが色々書いてく何か。ブログ名決めました。

iOSアプリ開発で実装中の機能をTestFlightだけで表示して本番では隠したい

アプリ開発をしていると、実装完了までに時間がかかる大きな機能を作ることがあります。そういう時にはいくつかの戦略がありますが、僕はフィーチャーフラグでユーザーには見せない状態で少しずつ実装して、mainブランチにマージしていく方法が好みです。

OpenFeatureを入れたりしてもいいですが、もっと気軽に入れたいと思っていて色々考えていました。僕のユースケースでは、TestFlightとローカルビルドかどうかを判断できると良さそうです。TestFlightの時だけtrueになるフラグを準備できれば、まだ実装中の機能の動作確認が行えます。

調べてみると、appStoreReceiptURLを使う方法が出てきます。これを参考に実装しても良いのですが、iOS 18でDeprecatedになってしまいました。

stackoverflow.com

代わりに、AppTransactionが使えるようです。AppTransaction.sharedで、アプリ購入に関する情報を取得できます。例えば、originalAppVersionにはユーザーが初めてアプリを購入したバージョンが取得できます。この辺りはWWDC 22のWhat's new with in-app purchaseに詳しいです。

developer.apple.com

そして、ユーザーがApp Storeからインストールしているか、TestFlightからインストールしているかを判定するためにはenvironmentが使えます。このフィールドは、App Storeの場合は productionが、TestFlightからインストールした場合はsandboxが格納されています。このフィールドを使うと、アプリがTestFlightからインストールされているかどうかを判定できます。

この値を利用して、TestFlightの時だけフラグがtrueになるようにしておけば、開発中の機能をユーザーに見せずにどんどんmainブランチにマージしていけますし、リリースも行えます。僕はこれを利用するために、DistributionChannelを作って暮らしています。

import Foundation
import StoreKit

@MainActor
enum DistributionChannel: String {
    case appStore
    case sandbox
    case debug
    case unknown

    static var isDeveloper: Bool {
        switch currentChannel {
        case .sandbox, .debug:
            true
        case .appStore, .unknown:
            false
        }
    }

    private static var cachedCurrentChannel: DistributionChannel?
    static var currentChannel: DistributionChannel {
        #if DEBUG
        return .debug
        #else
        if let cachedCurrentChannel {
            return cachedCurrentChannel
        } else {
            load()
            return .unknown
        }
        #endif
    }

    static func load() {
        #if DEBUG
        return
        #else
        Task {
            do {
                let result = try await AppTransaction.shared
                switch result {
                case .unverified(_, _):
                    cachedCurrentChannel = .unknown
                case .verified(let transaction):
                    if transaction.environment == .production {
                        cachedCurrentChannel = .appStore
                    } else if transaction.environment == .sandbox {
                        cachedCurrentChannel = .sandbox
                    } else {
                        cachedCurrentChannel = .unknown
                    }
                }
            } catch {
                cachedCurrentChannel = .unknown
            }
        }
        #endif
    }
}

DEBUGの時はローカルでビルドしているはずなので、AppTransactionを使うまでもなくフラグは有効にして良いはず。そうじゃない時は、AppTransactionの値を元に値を保存しておきます。DistributionChannel.isDeveloperがデバッグ起動とTestFlightの時はtrueになるようにしているので、UIレイヤーでこのフラグを見て実装中の機能を出し分ければ目的達成です。

AppTransaction.sharedはasyncのプロパティですが、そのままだと扱いにくいので、同期的にキャッシュを返すようにしています。僕の使い方だとMainActorにするので困っていないのですが、MainActorだと困りそうならなんとかしても良いのかも。

最初の読み込み時や、unverifiedの時は.unknownにしていて、この場合はisDeveloperはfalseになります。今回の用途だとDeveloeprだと確定した時に追加で開発中のUIを適用したいのでそのようになっています。