axiom-app-composition について
SwiftUI 開発者向けに、アプリ全体の状態、ルートビュー、ライフサイクル、状態復元、機能モジュールの構成を扱うスキルです。明示的なステートマシンと薄い @main エントリーポイントを重視します。
主な機能
- 01enum によるアプリ状態のモデル化
- 02状態遷移の一元管理と検証
- 03アプリ状態に応じたルートビュー構成
- 04シーンのライフサイクルイベント処理
- 05軽量なシーン・ナビゲーション状態の復元
- 06機能モジュール分割の判断
活用シーン
- 01ログイン・オンボーディング・メイン画面の構成
- 02アプリ状態切り替え時のちらつき防止
- 03シーンがアクティブになった際のセッション再検証
- 04アプリ終了後のナビゲーション復元
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-app-compositionインストール後、Claude Code で/axiom-app-compositionを入力して読み込まれたか確認します。
詳細
アプリの構成
このスキルを使う場面
次の場合にこのスキルを使います。
- @main エントリーポイントとルートビューを構成するとき
- 認証状態(ログイン → オンボーディング → メイン)を管理するとき
- アプリレベルの状態をちらつきなしで切り替えるとき
- シーンのライフサイクルイベント(scenePhase)を処理するとき
- 終了後にアプリの状態を復元するとき
- 機能をモジュールに分割するタイミングを判断するとき
- 複数のウィンドウ間(iPad、axiom-visionOS)を調整するとき
プロンプトの例
| 質問の例 | このスキルが役立つ理由 |
|---|---|
| 「ログイン画面とメイン画面を切り替えるにはどうすればよいですか?」 | 遷移を検証する AppStateController パターン |
| 「スプラッシュ画面からメイン画面に切り替えるとき、アプリがちらつきます」 | アニメーション調整によるちらつきの防止 |
| 「認証状態はどこに置くべきですか?」 | あちこちに分散したブール値ではなく、アプリレベルのステートマシン |
| 「アプリがバックグラウンドに移行したとき、どう処理すればよいですか?」 | scenePhase のライフサイクルパターン |
| 「アプリをモジュールに分割するのはいつがよいですか?」 | コードベースの規模とチームに基づく判断ツリー |
| 「アプリが終了した後に状態を復元するにはどうすればよいですか?」 | SceneStorage と状態検証のパターン |
簡単な判断ツリー
What app-level architecture question are you solving?
│
├─ How do I manage app states (loading, auth, main)?
│ └─ Part 1: App-Level State Machines
│ - Enum-based state with validated transitions
│ - AppStateController pattern
│ - Prevents "boolean soup" anti-pattern
│
├─ How do I structure @main and root view switching?
│ └─ Part 2: Root View Switching Patterns
│ - Delegate to AppStateController (no logic in @main)
│ - Flicker prevention with animation
│ - Coordinator integration
│
├─ How do I handle scene lifecycle?
│ └─ Part 3: Scene Lifecycle Integration
│ - scenePhase for session validation
│ - SceneStorage for restoration
│ - Multi-window coordination
│
├─ When should I modularize?
│ └─ Part 4: Feature Module Basics
│ - Decision tree by size/team
│ - Module boundaries and DI
│ - Navigation coordination
│
└─ What mistakes should I avoid?
└─ Part 5: Anti-Patterns + Part 6: Pressure Scenarios
- Boolean-based state
- Logic in @main
- Missing restoration validation
パート 1:アプリレベルのステートマシン
基本原則
「アプリには明確に区別された状態があります。散在するブール値ではなく、enum で明示的にモデル化してください。」
複雑なアプリにはすべて、読み込み中、未認証、オンボーディング中、認証済み、エラーからの復旧など、明確に区別された状態があります。これらの状態は次のようにする必要があります。
- 明示的 — 複数のブール値ではなく enum にします
- 検証可能 — 遷移をチェックして記録します
- 一元化 — 信頼できる唯一の情報源にします
- 監視可能 — ビューが状態の変化に反応します
ブール値の寄せ集めの問題
// ❌ Boolean soup — impossible to validate, prone to invalid states
class AppState {
var isLoading = true
var isLoggedIn = false
var hasCompletedOnboarding = false
var hasError = false
var user: User?
// What if isLoading && isLoggedIn && hasError are all true?
// Invalid state, but nothing prevents it
}
問題点
- 有効な状態をコンパイル時に保証できません
- いずれかのブール値の更新を忘れやすくなります
- テストではすべての組み合わせを確認する必要があります
- 競合状態によって、あり得ない状態が生じます
AppStateController パターン
ステップ 1:状態を明示的に定義する
enum AppState: Equatable {
case loading
case unauthenticated
case onboarding(OnboardingStep)
case authenticated(User)
case error(AppError)
}
enum OnboardingStep: Equatable {
case welcome
case permissions
case profileSetup
case complete
}
enum AppError: Equatable {
case networkUnavailable
case sessionExpired
case maintenanceMode
}
ステップ 2:コントローラーを作成する
@Observable
@MainActor
class AppStateController {
private(set) var state: AppState = .loading
// MARK: - State Transitions
func transition(to newState: AppState) {
guard isValidTransition(from: state, to: newState) else {
assertionFailure("Invalid transition: \(state) → \(newState)")
logInvalidTransition(from: state, to: newState)
return
}
let oldState = state
state = newState
logTransition(from: oldState, to: newState)
}
// MARK: - Validation
private func isValidTransition(from: AppState, to: AppState) -> Bool {
switch (from, to) {
// From loading
case (.loading, .unauthenticated): return true
case (.loading, .authenticated): return true
case (.loading, .error): return true
// From unauthenticated
case (.unauthenticated, .onboarding): return true
case (.unauthenticated, .authenticated): return true
case (.unauthenticated, .error): return true
// From onboarding
case (.onboarding, .onboarding): return true // Step changes
case (.onboarding, .authenticated): return true
case (.onboarding, .unauthenticated): return true // Cancelled
// From authenticated
case (.authenticated, .unauthenticated): return true // Logout
case (.authenticated, .error): return true
// From error
case (.error, .loading): return true // Retry
case (.error, .unauthenticated): return true
default: return false
}
}
// MARK: - Logging
private func logTransition(from: AppState, to: AppState) {
#if DEBUG
print("AppState: \(from) → \(to)")
#endif
}
private func logInvalidTransition(from: AppState, to: AppState) {
// Log to analytics for debugging
Analytics.log("InvalidStateTransition", properties: [
"from": String(describing: from),
"to": String(describing: to)
])
}
}
ステップ 3:ストレージから初期化する
extension AppStateController {
func initialize() async {
// Check for stored session
if let session = await SessionStorage.loadSession() {
// Validate session is still valid
do {
let user = try await AuthService.validateSession(session)
transition(to: .authenticated(user))
} catch {
// Session expired or invalid
await SessionStorage.clearSession()
transition(to: .unauthenticated)
}
} else {
transition(to: .unauthenticated)
}
}
}
ステートマシンの図
┌─────────────────────────────────────────────────────────────┐
│ .loading │
└────────────┬───────────────┬────────────────┬───────────────┘
│ │ │
▼ ▼ ▼
.unauthenticated .authenticated .error
│ │ │
▼ │ │
.onboarding ─────────►│◄───────────────┘
│ │
└───────────────┘
ステートマシンのテスト
@Test func testValidTransitions() async {
let controller = AppStateController()
// Loading → Unauthenticated (valid)
controller.transition(to: .unauthenticated)
#expect(controller.state == .unauthenticated)
// Unauthenticated → Authenticated (valid)
let user = User(id: "1", name: "Test")
controller.transition(to: .authenticated(user))
#expect(controller.state == .authenticated(user))
}
@Test func testInvalidTransitionRejected() async {
let controller = AppStateController()
// Loading → Onboarding (invalid — must go through unauthenticated)
controller.transition(to: .onboarding(.welcome))
#expect(controller.state == .loading) // Unchanged
}
@Test func testSessionExpiredTransition() async {
let controller = AppStateController()
let user = User(id: "1", name: "Test")
controller.transition(to: .authenticated(user))
// Authenticated → Error (session expired)
controller.transition(to: .error(.sessionExpired))
#expect(controller.state == .error(.sessionExpired))
// Error → Unauthenticated (force re-login)
controller.transition(to: .unauthenticated)
#expect(controller.state == .unauthenticated)
}
State-as-Bridge パターン(WWDC 2025/266)
WWDC 2025 の「Explore concurrency in SwiftUI」より:
「時間に敏感な変更を必要とする UI コードと、長時間実行される非同期ロジックとの境界を見つけてください。」
重要な考え方は、同期的な状態変更が UI を動かし(アニメーション用)、非同期コードはモデル内に置かれ(SwiftUI なしでテスト可能)、状態がその 2 つを橋渡しするということです。
// ✅ State-as-Bridge: UI triggers state, model does async work
struct ColorExtractorView: View {
@State private var model = ColorExtractor()
var body: some View {
Button("Extract Colors") {
// ✅ Synchronous state change triggers animation
withAnimation { model.isExtracting = true }
// Async work happens in Task
Task {
await model.extractColors()
// ✅ Synchronous state change ends animation
withAnimation { model.isExtracting = false }
}
}
.scaleEffect(model.isExtracting ? 1.5 : 1.0)
}
}
@Observable
class ColorExtractor {
var isExtracting = false
var colors: [Color] = []
func extractColors() async {
// Heavy computation happens here, testable without SwiftUI
let extracted = await heavyComputation()
colors = extracted
}
}
アプリの構成でこれが重要な理由
- アプリレベルの状態変更(読み込み中 → 認証済み)は同期的に行うべきです
- 負荷の高い処理(セッションの検証、データの読み込み)は、モデル内で非同期に行うべきです
- この分離により、SwiftUI をインポートせずにステートマシンをテストできます
パート 2:ルートビュー切り替えのパターン
基本原則
「@main エントリーポイントは薄いシェルにしてください。すべてのロジックは AppStateController に置きます。」
シンプルな @main パターン
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task {
await appState.initialize()
}
}
}
}
@main が行うこと
- AppStateController を作成します
- environment を介して注入します
- 初期化を開始します
@main が行わないこと
- ビジネスロジック
- 認証チェック
- 条件付きレンダリング
- ナビゲーションの判断
RootView:状態の切り替え
struct RootView: View {
@Environment(AppStateController.self) private var appState
var body: some View {
Group {
switch appState.state {
case .loading:
LaunchView()
case .unauthenticated:
AuthenticationFlow()
case .onboarding(let step):
OnboardingFlow(step: step)
case .authenticated(let user):
MainTabView(user: user)
case .error(let error):
ErrorRecoveryView(error: error)
}
}
}
}
テスト容易性のメリット
薄いシェルのパターンにより、テストを最大 60 倍高速化できます。アプリのロジックをアプリのターゲットではなく Swift Package に置くと、テストは xcodebuild test(約 25 秒)ではなく swift test(約 0.4 秒)で実行されます。シミュレーターもアプリの起動も不要です。
| コンポーネント | 配置場所 | テスト方法 |
|---|---|---|
| ビジネスロジック、モデル、サービス | Swift Package(MyAppCore) | swift test(0.4 秒) |
| ルートビューの構成 | アプリのターゲット(薄いシェル) | xcodebuild test(25 秒) |
パッケージへの切り出し手順の全体については、axiom-swift-testing の Strategy 1 を参照してください。
遷移中のちらつきを防ぐ
問題:誤ったコンテンツが一瞬表示される
アプリの状態が変化すると、新しい画面が表示される前に古い画面が一瞬表示されることがあります。これは次のような場合に発生します。
- ビューの準備ができる前に状態が変化する
- トランジションアニメーションがない
- ローディング状態が短すぎて認識できない
解決策:アニメーション付きのトランジション
struct RootView: View {
@Environment(AppStateController.self) private var appState
var body: some View {
ZStack {
switch appState.state {
case .loading:
LaunchView()
.transition(.opacity)
case .unauthenticated:
AuthenticationFlow()
.transition(.opacity)
case .onboarding(let step):
OnboardingFlow(step: step)
.transition(.opacity)
case .authenticated(let user):
MainTabView(user: user)
.transition(.opacity)
case .error(let error):
ErrorRecoveryView(error: error)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.3), value: appState.state)
}
}
ローディング時間の最小値
洗練されたユーザー体験を実現するには、ローディング画面が十分な時間表示されるようにします。
extension AppStateController {
func initialize() async {
let startTime = Date()
// Do actual initialization
await performInitialization()
// Ensure minimum display time for loading screen
let elapsed = Date().timeIntervalSince(startTime)
let minimumDuration: TimeInterval = 0.5
if elapsed < minimumDuration {
try? await Task.sleep(for: .seconds(minimumDuration - elapsed))
}
}
}
コーディネーターとの統合
コーディネーターを使用する場合は、ルートレベルで統合します。
struct RootView: View {
@Environment(AppStateController.self) private var appState
@State private var authCoordinator = AuthCoordinator()
@State private var mainCoordinator = MainCoordinator()
var body: some View {
Group {
switch appState.state {
case .loading:
LaunchView()
case .unauthenticated, .onboarding:
AuthenticationFlow()
.environment(authCoordinator)
case .authenticated(let user):
MainTabView(user: user)
.environment(mainCoordinator)
case .error(let error):
ErrorRecoveryView(error: error)
}
}
.animation(.easeInOut(duration: 0.3), value: appState.state)
}
}
パート 3:シーンのライフサイクルとの統合
基本原則
「シーンのライフサイクルイベントはアプリ全体に関わるため、一元的に処理し、機能ごとに分散させないようにします。」
ScenePhase を理解する(Apple のドキュメント)
ScenePhase は、シーンの動作状態を示します。その値の解釈は、どこで読み取るかによって異なります。
View から読み取る → それを囲むシーンのフェーズを返します
App から読み取る → すべてのシーンを反映する集約値を返します
| フェーズ | 説明 |
|---|---|
.active | シーンがフォアグラウンドにあり、操作可能です |
.inactive | シーンはフォアグラウンドにありますが、処理を一時停止する必要があります |
.background | シーンは表示されておらず、アプリがまもなく終了する可能性があります |
Apple のドキュメントからの重要なポイント App レベルで読み取る場合、.active はいずれかのシーンがアクティブであることを意味し、.background はすべてのシーンがバックグラウンドにあることを意味します。
scenePhase の処理
@main
struct MyApp: App {
@State private var appState = AppStateController()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task {
await appState.initialize()
}
}
.onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase)
}
}
private func handleScenePhaseChange(from: ScenePhase, to: ScenePhase) {
switch to {
case .active:
// App became active — validate session, refresh data
Task {
await appState.validateSession()
await appState.refreshIfNeeded()
}
case .inactive:
// App about to go inactive — save state
appState.prepareForBackground()
case .background:
// App in background — release resources
appState.releaseResources()
@unknown default:
break
}
}
}
アクティブ時のセッション検証
extension AppStateController {
func validateSession() async {
guard case .authenticated(let user) = state else { return }
do {
// Check if token is still valid
let isValid = try await AuthService.validateToken(user.token)
if !isValid {
transition(to: .error(.sessionExpired))
}
} catch {
// Network error — keep authenticated but show warning
// Don't immediately log out on transient network issues
}
}
func prepareForBackground() {
// Save any pending data
// Cancel non-essential network requests
// Prepare for potential termination
}
func releaseResources() {
// Release cached images
// Stop location updates if not essential
// Reduce memory footprint
}
}
状態復元のための SceneStorage
Apple のドキュメントによると、SceneStorage は状態を自動的に復元します。保存と復元はシステムが管理します。
主な制約
- データは軽量に保ちます(完全なモデルは保存しません)
- 各 Scene は独自のストレージを持ちます(共有されません)
- シーンが明示的に破棄されると、データも破棄されます
struct MainTabView: View {
@SceneStorage("selectedTab") private var selectedTab = 0
@SceneStorage("lastViewedItemID") private var lastViewedItemID: String?
var body: some View {
TabView(selection: $selectedTab) {
HomeTab()
.tag(0)
SearchTab()
.tag(1)
ProfileTab()
.tag(2)
}
.onAppear {
if let itemID = lastViewedItemID {
// Restore to last viewed item
navigateToItem(itemID)
}
}
}
}
ナビゲーション状態の復元(WWDC 2022/10054)
複雑なナビゲーションには、Codable に準拠した NavigationModel を使用します。
// Encapsulate navigation state with Codable conformance
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe] = []
enum CodingKeys: String, CodingKey {
case selectedCategory
case recipePathIds
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
// Store only IDs, not full models
try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.selectedCategory = try container.decodeIfPresent(
Category.self, forKey: .selectedCategory)
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
// compactMap discards deleted items gracefully
self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
}
var jsonData: Data? {
get { try? JSONEncoder().encode(self) }
set {
guard let data = newValue,
let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
else { return }
self.selectedCategory = model.selectedCategory
self.recipePath = model.recipePath
}
}
}
// Use with SceneStorage
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationSplitView { /* ... */ }
.task {
if let data = data {
navModel.jsonData = data
}
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
}
}
WWDC の主なパターン
- 完全なモデルオブジェクトではなく、ID のみを保存します
- 削除された項目を適切に処理するには
compactMapを使用します - リアルタイムで永続化するため、すべての
objectWillChangeで保存します
復元した状態の検証
復元した状態を決して無条件に信用しないでください。
struct DetailView: View {
@SceneStorage("detailItemID") private var restoredItemID: String?
@State private var item: Item?
var body: some View {
Group {
if let item {
ItemContent(item: item)
} else {
ProgressView()
}
}
.task {
if let itemID = restoredItemID {
// Validate item still exists
item = await ItemService.fetch(itemID)
if item == nil {
// Item was deleted — clear restoration
restoredItemID = nil
}
}
}
}
}
マルチウィンドウの調整(iPad、axiom-visionOS)
Apple のドキュメントによると、WindowGroup 内の各ウィンドウは独立した状態を保持します。システムは各ウィンドウに対して @State と @StateObject の新しいストレージを割り当てます。
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
// Primary window
WindowGroup {
MainView()
.environment(appState)
}
// Data-presenting window (iPad)
// Prefer lightweight data (IDs, not full models)
WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in
if let itemID {
DetailView(itemID: itemID)
.environment(appState)
}
}
#if os(visionOS)
// Immersive space
ImmersiveSpace(id: "immersive") {
ImmersiveView()
.environment(appState)
}
#endif
}
}
Apple のドキュメントに記載された主な動作
- 同じ値のウィンドウがすでに存在する場合、新しいウィンドウを開く代わりに、システムはそのウィンドウを前面に表示します
- SwiftUI は状態復元のためにバインディング値を永続化します
- 各ウィンドウグループには一意の識別子文字列を使用します
追加のウィンドウを開く
struct ItemRow: View {
let item: Item
@Environment(\.openWindow) private var openWindow
var body: some View {
Button(item.title) {
// Open in new window on iPad
// Use ID to match window group, value to pass data
openWindow(id: "detail", value: item.id)
}
}
}
プログラムでウィンドウを閉じる
struct DetailView: View {
var itemID: Item.ID?
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
// ...
Button("Done") {
dismiss() // Closes this window
}
}
}
}
パート 4:機能モジュールの基本
基本原則
「機能に明確な境界がある場合にモジュールへ分割します。早まって分割しないでください。」
早すぎるモジュール化はオーバーヘッドを生み、遅すぎるモジュール化は問題を招きます。この判断フローを使ってください。
モジュール化するタイミングの判断フロー
Should I extract this feature into a module?
│
├─ Is the codebase under 5,000 lines with 1-2 developers?
│ └─ NO modularization needed yet
│ Single target is fine, revisit at 10,000 lines
│
├─ Is the codebase 5,000-20,000 lines with 3+ developers?
│ └─ CONSIDER modularization
│ Look for natural boundaries
│
├─ Is the codebase over 20,000 lines?
│ └─ MODULARIZE for build times
│ Parallel compilation essential
│
├─ Could this feature be used in multiple apps?
│ └─ EXTRACT to reusable module
│ Shared authentication, analytics, axiom-networking
│
├─ Do multiple developers work on this feature daily?
│ └─ EXTRACT for merge conflict reduction
│ Isolated codebases = parallel work
│
└─ Does the feature have clear input/output boundaries?
├─ YES → Good candidate for module
└─ NO → Refactor boundaries first, then extract
モジュール境界のパターン
公開 API を定義する
// FeatureModule/Sources/FeatureModule/FeatureAPI.swift
/// Public interface for the feature module
public protocol FeatureAPI {
/// Show the feature's main view
@MainActor
func makeMainView() -> AnyView
/// Handle deep link into feature
@MainActor
func handleDeepLink(_ url: URL) -> Bool
}
/// Factory to create feature with dependencies
public struct FeatureFactory {
public static func create(
analytics: AnalyticsProtocol,
networking: NetworkingProtocol
) -> FeatureAPI {
FeatureImplementation(
analytics: analytics,
networking: axiom-networking
)
}
}
内部実装
// FeatureModule/Sources/FeatureModule/Internal/FeatureImplementation.swift
internal class FeatureImplementation: FeatureAPI {
private let analytics: AnalyticsProtocol
private let networking: NetworkingProtocol
internal init(
analytics: AnalyticsProtocol,
networking: NetworkingProtocol
) {
self.analytics = analytics
self.networking = networking
}
@MainActor
public func makeMainView() -> AnyView {
AnyView(FeatureMainView(viewModel: makeViewModel()))
}
public func handleDeepLink(_ url: URL) -> Bool {
// Handle feature-specific deep links
return false
}
private func makeViewModel() -> FeatureViewModel {
FeatureViewModel(analytics: analytics, axiom-networking: axiom-networking)
}
}
メインアプリで使用する
// MainApp/Sources/App/AppDependencies.swift
@Observable
class AppDependencies {
let analytics: AnalyticsProtocol
let networking: NetworkingProtocol
// Lazy-created feature modules
lazy var profileFeature: FeatureAPI = {
ProfileFeatureFactory.create(
analytics: analytics,
networking: axiom-networking
)
}()
lazy var settingsFeature: FeatureAPI = {
SettingsFeatureFactory.create(
analytics: analytics,
networking: axiom-networking
)
}()
}
// MainApp/Sources/App/MainTabView.swift
struct MainTabView: View {
@Environment(AppDependencies.self) private var dependencies
var body: some View {
TabView {
dependencies.profileFeature.makeMainView()
.tabItem { Label("Profile", systemImage: "person") }
dependencies.settingsFeature.makeMainView()
.tabItem { Label("Settings", systemImage: "gear") }
}
}
}
モジュール間のナビゲーション調整
機能同士が直接依存しないようにします。
// ❌ Feature knows about other features
struct ProfileView: View {
func showSettings() {
// ProfileView imports SettingsFeature — circular dependency risk
NavigationLink(value: SettingsDestination())
}
}
// ✅ Feature delegates navigation to coordinator
struct ProfileView: View {
let onShowSettings: () -> Void
func showSettings() {
onShowSettings() // ProfileView doesn't know what happens
}
}
// Coordinator wires features together
class MainCoordinator {
func showSettings(from profile: ProfileFeatureAPI) {
// Coordinator knows about both features
navigationPath.append(SettingsRoute())
}
}
モジュールのフォルダー構成
MyApp/
├── App/ # Main app target
│ ├── MyApp.swift # @main entry point
│ ├── AppDependencies.swift # Dependency container
│ ├── AppStateController.swift # App state machine
│ └── Coordinators/ # Navigation coordinators
│
├── Packages/
│ ├── Core/ # Shared utilities
│ │ ├── Networking/
│ │ ├── Analytics/
│ │ └── Design/ # Design system
│ │
│ ├── Features/ # Feature modules
│ │ ├── Profile/
│ │ ├── Settings/
│ │ └── Onboarding/
│ │
│ └── Domain/ # Business logic
│ ├── Models/
│ └── Services/
パート 5:アンチパターン
アンチパターン 1:ブール値に基づく状態
// ❌ Boolean soup — impossible to validate
class AppState {
var isLoading = true
var isLoggedIn = false
var hasCompletedOnboarding = false
var hasError = false
}
// What if isLoading && isLoggedIn && hasError are all true?
修正方法 enum に基づく状態を使用します(パート 1)。
// ✅ Explicit states — compiler prevents invalid combinations
enum AppState {
case loading
case unauthenticated
case onboarding(OnboardingStep)
case authenticated(User)
case error(AppError)
}
アンチパターン 2:@main にロジックを記述する
// ❌ Business logic in App entry point
@main
struct MyApp: App {
@State private var user: User?
@State private var isLoading = true
var body: some Scene {
WindowGroup {
if isLoading {
LoadingView()
} else if let user {
MainView(user: user)
} else {
LoginView(onLogin: { self.user = $0 })
}
}
.task {
user = await AuthService.getCurrentUser()
isLoading = false
}
}
}
問題点
- @main にロジックが詰め込まれて肥大化します
- アプリを起動せずにテストするのが困難です
- 状態が複数の @State に分散します
修正方法 AppStateController に委譲します(パート 2)。
// ✅ @main is a thin shell
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task { await appState.initialize() }
}
}
}
アンチパターン 3:復元時の状態検証がない
// ❌ Trusts restored state blindly
.onAppear {
if let savedState = SceneStorage.appState {
appState.state = savedState // Token might be expired!
}
}
問題点
- セッションの有効期限が切れている可能性があります
- 別のデバイスでユーザーがログアウトしている可能性があります
- データが削除されている可能性があります
修正方法 適用する前に検証します(パート 3)。
// ✅ Validates restored state
.task {
if let savedSession = await SessionStorage.loadSession() {
do {
let user = try await AuthService.validateSession(savedSession)
appState.transition(to: .authenticated(user))
} catch {
// Session invalid — force re-login
await SessionStorage.clearSession()
appState.transition(to: .unauthenticated)
}
}
}
アンチパターン 4:機能全体に分散したナビゲーションロジック
// ❌ Every feature knows about every other feature
struct ProfileView: View {
@Environment(\.navigationPath) private var path
func showSettings() {
path.append(SettingsDestination()) // ProfileView imports Settings
}
func showOrderHistory() {
path.append(OrderHistoryDestination()) // ProfileView imports Orders
}
}
問題
- 循環依存
- ナビゲーションのテストが困難
- 変更の影響がモジュール全体に波及する
解決策 コーディネーターに委任します(パート 4)
// ✅ Feature delegates navigation decisions
struct ProfileView: View {
let onShowSettings: () -> Void
let onShowOrderHistory: () -> Void
// ProfileView doesn't know what these do
}
アンチパターン 5:巨大なコーディネーター
// ❌ Single coordinator knows all features
class AppCoordinator {
func showProfile() { }
func showSettings() { }
func showOnboarding() { }
func showPayment() { }
func showChat() { }
func showOrderHistory() { }
func showNotifications() { }
// ... 50 more methods
}
問題
- 誰もが変更を加える巨大なファイル
- マージコンフリクト
- 単一障害点
解決策 スコープを限定したコーディネーター
// ✅ Scoped coordinators for each domain
class AuthCoordinator { } // Login, signup, forgot password
class MainCoordinator { } // Tab navigation, main flows
class SettingsCoordinator { } // Settings navigation tree
class OrderCoordinator { } // Order flow, history, details
パート 5b:UIKit との統合(段階的な導入)
包括的なブリッジパターン(UIViewRepresentable、UIViewControllerRepresentable、UIHostingConfiguration、コーディネーター、ライフサイクル、注意点)については、
/skill axiom-uikit-bridgingを参照してください。このセクションでは、アプリレベルの統合戦略のみを扱います。
該当するケース
本番環境の iOS アプリの多くには、既存の UIKit コードがあります。すべてを SwiftUI で書き直すのは、現実的でない場合がほとんどです。段階的に導入するには、次のパターンを使用してください。
UIHostingController — UIKit 内の SwiftUI
既存の UIKit ナビゲーション階層に SwiftUI ビューを埋め込みます。
// Present a SwiftUI view from a UIKit view controller
let settingsView = SettingsView(store: store)
let hostingController = UIHostingController(rootView: settingsView)
navigationController?.pushViewController(hostingController, animated: true)
主なルール:
UIHostingControllerが SwiftUI ビューのライフサイクルを管理します。ルートビューを別途保持しないでください- 子として埋め込む際は、正しい Auto Layout サイズを設定するために
sizingOptions: .intrinsicContentSizeを使用します - シートの場合、
hostingController.modalPresentationStyle = .pageSheetをそのまま使用できます - SwiftUI の環境は自動的にはブリッジされません。ルートビューのイニシャライザーを通じて依存関係を注入します
UIViewControllerRepresentable — SwiftUI 内の UIKit
既存の UIKit ビューコントローラーをラップして、SwiftUI で使用します。
struct DocumentPickerView: UIViewControllerRepresentable {
@Binding var selectedURL: URL?
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: DocumentPickerView
init(_ parent: DocumentPickerView) { self.parent = parent }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
parent.selectedURL = urls.first
}
}
}
使用するケース:カメラ UI、書類ピッカー、メール作成画面、SwiftUI 版がない UIKit コントローラー。
AppDelegate + SwiftUI @main
UIApplicationDelegate のコールバックを SwiftUI アプリにブリッジします。
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
RootView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Push notification registration, third-party SDK init, etc.
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Forward to push notification service
}
}
使用するケース:プッシュ通知、AppDelegate を必要とするサードパーティ SDK、バックグラウンド URL セッション、Handoff。
移行の優先順位
UIKit アプリに SwiftUI を段階的に導入する場合:
- まず末端の画面から — 設定、アプリ情報、詳細ビュー(ナビゲーションが複雑でないもの)
- 新機能は SwiftUI で — 書き直すのではなく、新しい画面を SwiftUI で構築します
- 共有コンポーネント — 再利用可能な SwiftUI コンポーネントを構築し、
UIHostingControllerでラップします - ナビゲーションは最後に — 同じフロー内で
UINavigationControllerとNavigationStackを混在させないでください。ナビゲーションのサブツリー全体を移行します
避けること:アプリの半分だけで UINavigationController を NavigationStack に置き換えること。フロー全体を SwiftUI ナビゲーションにするか、UIKit ナビゲーションにするかのどちらかにしてください。
パート 6:プレッシャーがかかる状況
シナリオ 1:「今はルートをハードコードするだけでいい」
プレッシャー
「今のところフローは 1 つだけです。MainView を直接表示して、認証は後で追加しましょう。」
危険信号
- 「X は後で追加する」→ 積み重なっていく技術的負債
- 「フローは 1 つだけ」→ フローは増えていく
- 「シンプルにする」→ 今はシンプルでも、後で複雑になる
作業時間の比較
| 選択肢 | 初期作業 | 認証追加時 | 合計 |
|---|---|---|---|
| MainView をハードコード | 0 分 | 2~4 時間のリファクタリング | 2~4 時間 |
| AppStateController | 30 分 | 状態の追加に 30 分 | 1 時間 |
反論の例
「AppStateController パターンなら、今は 30 分で済みます。後で認証を追加するときには、状態の追加にもう 30 分かかります。今ハードコードしても時間はまったく節約できません。認証が必要になったとき、リファクタリングに 2~4 時間かかるからです。今、30 分かけましょう。」
対応方法
-
次の 2 つの状態を持つ最小限の AppStateController を作成します。
enum AppState { case loading case ready } -
認証が必要になったら、状態を追加します。
enum AppState { case loading case unauthenticated // Added case authenticated(User) // Added } -
合計作業時間は 4 時間ではなく 1 時間です
シナリオ 2:「まだモジュールは必要ない」
プレッシャー
「すべてを 1 つのターゲットにまとめましょう。モジュール化は過剰な設計です。」
判断基準
| コードベース | チーム | 推奨事項 |
|---|---|---|
| 5,000 行未満 | 開発者 1~2 人 | 単一ターゲットで問題ありません |
| 5,000~20,000 行 | 開発者 3 人以上 | モジュール化を検討します |
| 20,000 行超 | 問わない | モジュール化は不可欠です |
押し返すためのスクリプト
「モジュールによってオーバーヘッドが増えることには同意します。次の判断基準で進めましょう。コードは [X] 行で、開発者は [Y] 人です。その規模に基づいて、今すぐモジュール化すべきかどうかを判断します。[threshold] に達したら、改めて検討しましょう。いかがですか?」
対応方法
- コードベースの規模を確認します:
find . -name "*.swift" | xargs wc -l - しきい値未満の場合は、判断内容と再検討するしきい値を文書化します
- しきい値を超えている場合は、まず自然な境界を特定します
シナリオ 3:「ナビゲーションが複雑すぎてテストできない」
その主張
「ナビゲーションの状態をテストするのは難しすぎます。手動の QA だけにしましょう。」
これがうまくいかない理由
- ナビゲーションのバグは「自分のマシンでは動く」問題の最大の原因です
- ディープリンクには自動検証が必要です
- 状態復元には回帰テストが必要です
- 手動の QA ではエッジケースを見落とします
解決策:ステートマシンをテストする
// ✅ Test navigation state without UI
@Test func testLoginCompletesOnboarding() async {
let controller = AppStateController()
controller.transition(to: .unauthenticated)
// Simulate login
await controller.handleLogin(user: mockUser)
// First-time user goes to onboarding
#expect(controller.state == .onboarding(.welcome))
}
@Test func testDeepLinkWhileUnauthenticated() async {
let controller = AppStateController()
controller.transition(to: .unauthenticated)
// Deep link to order
let handled = controller.handleDeepLink(URL(string: "app://order/123")!)
// Should not navigate — requires auth
#expect(handled == false)
#expect(controller.state == .unauthenticated)
}
押し返すためのスクリプト
「ナビゲーションが複雑だからこそ、自動テストが必要です。AppStateController パターンを使えば、UI を起動せずに状態遷移をテストできます。ディープリンク、認証フロー、状態復元を数秒で検証できます。手動の QA では、すべての組み合わせを確認できません。」
パート 7:コードレビューのチェックリスト
アプリの状態
- アプリの状態にはブール値ではなく enum を使用している
- 有効な状態をすべて明示的に定義している
- 状態遷移を
isValidTransitionで検証している - 無効な遷移を検出している(デバッグ時はアサーション、本番環境ではログに記録)
- デバッグ用に状態変更をログに記録している
ルートビュー
- @main が AppStateController に処理を委譲している
- @main にビジネスロジックがない
- RootView が単一の信頼できる情報源に基づいて切り替わる
- 遷移にアニメーションがある(ちらつきがない)
- ローディング状態の表示時間が最小限保証されている
シーンのライフサイクル
- scenePhase の変更を一箇所で処理している
- .active でセッションを検証している(盲目的に信頼していない)
- .background でリソースを解放している
- タブ選択/ナビゲーション状態に SceneStorage を使用している
- 復元した状態を適用前に検証している
モジュールの境界
- フィーチャーにパブリック API プロトコルがある
- モジュール間に循環依存がない
- ナビゲーションをコーディネーターに委譲している
- 依存関係をシングルトンではなく、注入している
- モジュール化の判断理由を文書化している(分割する/しない理由)
テスト
- UI を使わずに状態遷移をテストしている
- 無効な遷移をテストしている
- ディープリンクの処理をテストしている
- 状態復元の検証をテストしている
リソース
WWDC:2025-266, 2024-10150, 2023-10149, 2025-256, 2022-10054
ドキュメント:/swiftui/scenephase, /swiftui/scene, /swiftui/scenestorage, /swiftui/windowgroup, /observation/observable()
スキル:axiom-swiftui-architecture, axiom-swiftui-nav, axiom-swift-concurrency