TestFlightでのアプリ配布において、「内部テスター向け」と「外部テスター向け」のビルドを正確に識別し、適切なアップデート通知を実装するのは意外と複雑です。この記事では、FastAPI + SwiftUIを組み合わせた実装例を通じて、TestFlight環境の技術的制御方法を詳しく解説します。
🎯 この記事で解決する課題
- TestFlightの内部・外部テスター環境の正確な判定
- 環境に応じた適切なアップデート通知
- App Store Connect APIを活用した自動化
- パフォーマンスを考慮したキャッシング戦略
📊 システム構成
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ iOS Client │◄──►│ FastAPI Server │◄──►│ App Store Connect │
│ (SwiftUI) │ │ (Python) │ │ API │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
│ │
▼ ▼
環境判定・UI制御 JWT認証・ビルド情報取得
🔧 バックエンド実装(FastAPI)
JWT認証システム
App Store Connect APIとの連携には、ES256アルゴリズムによる署名付きJWTが必要です。
import base64
import time
import jwt
from fastapi import FastAPI, HTTPException
from functools import lru_cache
app = FastAPI()
def _make_jwt() -> str:
"""ES256 署名済み JWT(有効期限20分)を生成"""
private_key = base64.b64decode(settings.asc_p8_content_base64).decode("utf-8")
now = int(time.time())
payload = {
"iss": settings.asc_issuer_id, # Issuer ID
"iat": now, # 発行時刻
"exp": now + 1200, # 有効期限(20分)
"aud": "appstoreconnect-v1", # オーディエンス
}
return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": settings.asc_key_id})
キャッシング戦略
API呼び出しを最小限に抑えるため、LRUキャッシュを実装します。
@lru_cache(maxsize=1)
def _cached_builds_data() -> dict:
"""App Store Connect APIからビルド情報を取得してキャッシュ"""
token = _make_jwt()
headers = {"Authorization": f"Bearer {token}"}
try:
with httpx.Client() as client:
response = client.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{settings.app_id}/builds",
headers=headers,
timeout=30.0,
params={
"filter[betaAppReviewSubmission.betaReviewState]": "APPROVED",
"sort": "-version,-buildAudience",
"include": "buildAudience",
"limit": 50,
}
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
# エラー時はキャッシュをクリア
_cached_builds_data.cache_clear()
raise HTTPException(
status_code=e.response.status_code,
detail=f"App Store Connect API error: {e.response.text}",
)
APIエンドポイント設計
環境ごとに最適化されたエンドポイントを提供します。
@app.get("/testflight/latest/internal")
async def get_latest_internal_build():
"""内部テスター向け最新ビルド取得"""
builds_data = _cached_builds_data()
for build in builds_data["data"]:
audience_type = _get_audience_type(build, builds_data["included"])
if audience_type == "INTERNAL_ONLY":
return _format_build_info(build)
raise HTTPException(status_code=404, detail="内部テスター向けビルドが見つかりません")
@app.get("/testflight/latest/external")
async def get_latest_external_build():
"""外部テスター向け最新ビルド取得"""
builds_data = _cached_builds_data()
for build in builds_data["data"]:
audience_type = _get_audience_type(build, builds_data["included"])
if audience_type == "APP_STORE_ELIGIBLE":
return _format_build_info(build)
raise HTTPException(status_code=404, detail="外部テスター向けビルドが見つかりません")
@app.get("/testflight/version/{version}/audience")
async def get_version_audience(version: str):
"""特定バージョンのオーディエンス情報取得"""
builds_data = _cached_builds_data()
for build in builds_data["data"]:
if build["attributes"]["version"] == version:
audience_type = _get_audience_type(build, builds_data["included"])
return {"audienceType": audience_type}
raise HTTPException(status_code=404, detail=f"バージョン {version} が見つかりません")
📱 クライアント実装(SwiftUI)
環境判定システム
まず、ビルド環境を定義します。
enum BuildEnvironment: CaseIterable {
case debug // デバッグビルド
case testFlightInternal // TestFlight内部テスター
case testFlightExternal // TestFlight外部テスター
case testFlightUnknown // TestFlight(判定不能)
case appStore // App Store配布
var displayName: String {
switch self {
case .debug: return "Debug Build"
case .testFlightInternal: return "TestFlight - 内部"
case .testFlightExternal: return "TestFlight - 外部"
case .testFlightUnknown: return "TestFlight"
case .appStore: return "App Store"
}
}
var badgeColor: Color {
switch self {
case .debug: return .orange
case .testFlightInternal: return .blue
case .testFlightExternal: return .green
case .testFlightUnknown: return .gray
case .appStore: return .clear
}
}
}
基本環境検出
extension BuildEnvironment {
static let current: Self = {
#if DEBUG || targetEnvironment(simulator)
return .debug
#else
// サンドボックスレシートの存在でTestFlight環境を判定
let isSandbox = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
return isSandbox ? .testFlightUnknown : .appStore
#endif
}()
}
詳細環境検出クラス
サーバーAPIと連携して詳細な環境情報を取得します。
class TestFlightEnvironmentDetector: ObservableObject {
static let shared = TestFlightEnvironmentDetector()
@Published var detailedEnvironment: BuildEnvironment = BuildEnvironment.current
private init() {}
func detectDetailedEnvironment() async -> BuildEnvironment {
// 既に詳細環境が判明している場合はスキップ
guard BuildEnvironment.current == .testFlightUnknown else {
return BuildEnvironment.current
}
do {
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let audienceInfo = try await APIClient.shared.getAudienceInfo(version: currentVersion)
let environment: BuildEnvironment = audienceInfo.audienceType == "INTERNAL_ONLY"
? .testFlightInternal
: .testFlightExternal
await MainActor.run {
self.detailedEnvironment = environment
}
return environment
} catch {
print("環境検出エラー: \(error.localizedDescription)")
return .testFlightUnknown
}
}
func printDetailedEnvironmentInfo() async {
let environment = await detectDetailedEnvironment()
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
let emoji = environment == .testFlightInternal ? "🔵" : "🟢"
print("\(emoji) \(environment.displayName) build \(version) (\(build))")
}
}
アップデート確認システム
struct TestFlightUpdateInfo {
let currentBuild: String
let latestBuild: String
let isUpdateAvailable: Bool
let updateURL: URL?
let releaseNotes: String?
}
class TestFlightUpdateChecker: ObservableObject {
static let shared = TestFlightUpdateChecker()
@Published var updateInfo: TestFlightUpdateInfo?
@Published var isChecking = false
private init() {}
func checkForUpdates() async -> TestFlightUpdateInfo? {
await MainActor.run { isChecking = true }
defer { Task { await MainActor.run { isChecking = false } } }
do {
let detailedEnvironment = await TestFlightEnvironmentDetector.shared.detectDetailedEnvironment()
// 環境に応じて適切なエンドポイントを選択
let latestBuildInfo = try await getLatestBuildInfo(for: detailedEnvironment)
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
let isUpdateAvailable = compareBuilds(
current: currentBuild,
latest: latestBuildInfo.buildNumber
)
let updateInfo = TestFlightUpdateInfo(
currentBuild: currentBuild,
latestBuild: latestBuildInfo.buildNumber,
isUpdateAvailable: isUpdateAvailable,
updateURL: URL(string: "itms-beta://"),
releaseNotes: latestBuildInfo.releaseNotes
)
await MainActor.run {
self.updateInfo = updateInfo
}
return updateInfo
} catch {
print("アップデート確認エラー: \(error.localizedDescription)")
return nil
}
}
private func getLatestBuildInfo(for environment: BuildEnvironment) async throws -> BuildInfo {
switch environment {
case .testFlightInternal:
return try await APIClient.shared.getLatestInternalBuild()
case .testFlightExternal:
return try await APIClient.shared.getLatestExternalBuild()
default:
throw TestFlightError.unsupportedEnvironment
}
}
private func compareBuilds(current: String, latest: String) -> Bool {
guard let currentInt = Int(current),
let latestInt = Int(latest) else {
return current != latest
}
return latestInt > currentInt
}
func openTestFlightApp() {
guard let url = URL(string: "itms-beta://") else { return }
UIApplication.shared.open(url)
}
}
🎨 UI実装
環境バッジコンポーネント
struct EnvironmentBadgeView: View {
@StateObject private var detector = TestFlightEnvironmentDetector.shared
var body: some View {
Group {
switch detector.detailedEnvironment {
case .debug:
badgeView(text: "Debug Build", color: .orange)
case .testFlightInternal:
badgeView(text: "TestFlight - 内部", color: .blue)
case .testFlightExternal:
badgeView(text: "TestFlight - 外部", color: .green)
case .testFlightUnknown:
badgeView(text: "TestFlight", color: .gray)
case .appStore:
EmptyView()
}
}
.task {
await detector.detectDetailedEnvironment()
}
}
private func badgeView(text: String, color: Color) -> some View {
Text(text)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.2))
.foregroundColor(color)
.clipShape(Capsule())
}
}
アップデート確認UI
struct UpdateCheckView: View {
@StateObject private var updateChecker = TestFlightUpdateChecker.shared
var body: some View {
VStack(spacing: 16) {
if updateChecker.isChecking {
ProgressView("アップデート確認中...")
} else if let updateInfo = updateChecker.updateInfo {
if updateInfo.isUpdateAvailable {
updateAvailableView(updateInfo)
} else {
Text("最新バージョンです")
.foregroundColor(.secondary)
}
}
Button("アップデート確認") {
Task {
await updateChecker.checkForUpdates()
}
}
.buttonStyle(.bordered)
.disabled(updateChecker.isChecking)
}
.task {
await updateChecker.checkForUpdates()
}
}
private func updateAvailableView(_ updateInfo: TestFlightUpdateInfo) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "arrow.up.circle.fill")
.foregroundColor(.blue)
Text("アップデートが利用可能です")
.font(.headline)
}
Text("現在: \(updateInfo.currentBuild) → 最新: \(updateInfo.latestBuild)")
.font(.caption)
.foregroundColor(.secondary)
if let releaseNotes = updateInfo.releaseNotes, !releaseNotes.isEmpty {
Text(releaseNotes)
.font(.caption)
.padding(.top, 4)
}
Button("TestFlightで更新") {
updateChecker.openTestFlightApp()
}
.buttonStyle(.borderedProminent)
}
.padding()
.background(Color.blue.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
🚀 実装例:アプリ起動時の処理
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
// アプリ起動時に環境情報を出力
await BuildEnvironment.printDetailedEnvironmentInfo()
// アップデート確認(バックグラウンドで実行)
await TestFlightUpdateChecker.shared.checkForUpdates()
}
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
VStack(spacing: 20) {
EnvironmentBadgeView()
UpdateCheckView()
// その他のコンテンツ
Spacer()
}
.padding()
.navigationTitle("My App")
}
}
}
📊 パフォーマンス最適化
API呼び出し頻度の制御
# キャッシュ制御用のタイムスタンプ管理
@lru_cache(maxsize=1)
def _get_cache_timestamp():
return time.time()
def _should_refresh_cache():
"""5分経過でキャッシュをリフレッシュ"""
return time.time() - _get_cache_timestamp() > 300
@app.middleware("http")
async def cache_control_middleware(request: Request, call_next):
if _should_refresh_cache():
_cached_builds_data.cache_clear()
_get_cache_timestamp.cache_clear()
response = await call_next(request)
return response
エラー時のフォールバック戦略
extension TestFlightEnvironmentDetector {
func detectWithFallback() async -> BuildEnvironment {
// まず基本判定を試行
let basicEnvironment = BuildEnvironment.current
guard basicEnvironment == .testFlightUnknown else {
return basicEnvironment
}
// 詳細判定を試行(タイムアウト付き)
do {
return try await withTimeout(5.0) {
await detectDetailedEnvironment()
}
} catch {
// タイムアウトまたはエラー時は基本判定を返す
print("詳細環境検出に失敗、基本判定を使用: \(error)")
return basicEnvironment
}
}
}
🎯 まとめ
この実装により、以下の課題を解決できます:
課題 | 解決方法 |
---|---|
内部・外部テスターの判別 | App Store Connect APIとの連携 |
パフォーマンスの最適化 | LRUキャッシュとタイムアウト制御 |
エラー処理 | 段階的フォールバック戦略 |
ユーザビリティ | 直感的な環境表示とアップデート通知 |
導入のメリット
- 自動化: 手動での環境管理が不要
- 正確性: App Store Connect APIベースの確実な判定
- 保守性: 構造化されたエラーハンドリング
- 拡張性: モジュール化された設計
TestFlightの複雑な配布環境も、適切な技術的アプローチにより効率的に制御できます。この実装を参考に、プロジェクトの要件に合わせてカスタマイズしてみてください。