🦋

LUCA: SwiftUI に適したモダンアーキテクチャ

に公開
4

前提

新しいアーキテクチャが必要な背景

SwiftUI で新規にアプリ開発をするとなればまず第一に名が挙げられるであろうアーキテクチャといえば TCA (The Composable Architecture)ですよね。実際に業務では TCA を1年以上使ってきており、一貫した実装とテスト容易性に確かなメリットを感じています。しかし、アプリ開発のフェーズによっては TCA が足枷になることもあります。新規アプリの立ち上げ時、まだコンセプトも固まっていない頃は創造と破壊のイテレーションが高速に回るため、充実したテストのある堅牢な実装よりも、速度と柔軟性の方が必要とされます。TCA は便利な一方で書き方の制限が強く、ピュアな Swift なら数分で実装できるようなことでも、数時間悩んでしまうことがありました。(Point-Free に問い合わせたこともあります。)ある程度軌道に乗っており、要件が根本を覆さないようなフェーズであれば、TCA は非常に良いアーキテクチャですが、今の我々には適していないと判断しました。

また、これは TCA が直接的に悪いわけではありませんが、現状の Xcode のスペックでは手に負えないライブラリである側面があり、コンパイラーが正しくエラーを表示できなかったり、テストコードが存在しない警告を大量に抱えたりして、開発体験が残念ながら非常に悪いです。しかしながら、TCA の書き心地とテスト容易性は非常に魅力的なため手放したくありませんでした。

そこで、脱 TCA により柔軟で高速な開発体験を取り戻しつつも、TCA の魅力を残したピュア Swift/SwiftUI なアーキテクチャが必要となったのです。

考案時のコンセプト

特性 要求
Maintainability 明確な責務分離と一貫したパターンにより保守性を向上
Testability 依存性の注入がしやすく、Unit Test によりコンポーネントごとのテストが可能
Scalability 階層的な状態管理で拡張性を向上し、新機能の追加が容易
Type Safety 型安全な Action と Navigation
SwiftUI Integration SwiftUI との親和性の確保

要件

  • 個人開発やアプリ立ち上げ期向けのため、必要以上に複雑でないこと
  • Apple 純正の Framework、swiftlang/apple OSS ライブラリのみで実装できること
  • Xcode 26.0+、Swift 6.2+、iOS 18+、macOS 15+

LUCA

LUCA(ルカ)は SwiftUI × Observation 時代に最適化された実践的なアーキテクチャです。

L Layerd 3 層の明確な責務分離
U Unidirectional Data Flow 単一方向データフロー
C Composable TCA 由来の構成可能性とテスト容易性
A Architecture アーキテクチャ

つまり、データの流れが単一方向になるように心がけ、階層的な状態管理を行う、拡張しやすくテストもしやすい設計ってことです。TCA から強く影響を受けており、ピュアな Swift の言語機能を用いて軽量に実装・コンパイルできる事、SwiftUI の API に最大限寄り添うことを念頭に実装に落とし込みました。

AI 曰く

"Clean layers, Clear flow, Composable design"
「層は明確、フローは単純、設計は柔軟」

だそうです。

ビルドツール

LUCA ベースの Xcode Project を簡単に作ることができるビルドツールを公開しました。
XcodeGen に依存しているので、そちらも合わせてインストールが必要です。

https://github.com/kyome22/LUCA

アーキテクチャの全体像

┌───────────────────┐
│   UserInterface   │  ← UIの提供とイベントハンドリング
├───────────────────┤
│       Model       │  ← ビジネスロジックと状態管理
├───────────────────┤
│    DataSource     │  ← データアクセスとインフラ抽象化
└───────────────────┘

DataSourceModelUserInterfaceの3つのレイヤーで構成され、各レイヤーはローカルパッケージのモジュールとして提供します(Swift Package を用いたマルチモジュール構成)。

また、実装容易性および保守容易性のため、アーキテクチャの実現のために原則特別な Framework を必要としません(つまり Swift の言語機能だけで基本的には実装可能。例外として、状態購読に用いる AsyncStreamBundle のみ apple/swift-async-algorithms に依存します)。ただし、テスト容易性のため、SwiftUI の EnvironmentValues を使って依存性の注入を行えるようにします。

ファイル構成
.
├── LocalPackage
│   ├── Package.swift
│   ├── Sources
│   │   ├── DataSource
│   │   │   ├── Dependencies
│   │   │   │   └── AppStateClient.swift
│   │   │   ├── Entities
│   │   │   │   ├── AppState.swift
│   │   │   │   └── AsyncStreamBundle.swift
│   │   │   ├── Extensions
│   │   │   ├── Repositories
│   │   │   └── DependencyClient.swift
│   │   ├── Model
│   │   │   ├── Extensions
│   │   │   ├── Services
│   │   │   ├── Stores
│   │   │   ├── AppDelegate.swift (任意)
│   │   │   ├── AppDependencies.swift
│   │   │   └── Composable.swift
│   │   └── UserInterface
│   │       ├── Extensions
│   │       ├── Resources
│   │       ├── Scenes
│   │       └── Views
│   └── Tests
│       └── ModelTests
│           ├── ServiceTests
│           ├── StoreTests
│           └── WaitUntil.swift
├── ProjectName
│   └── ProjectNameApp.swift
└── ProjectName.xcodeproj

各レイヤーの役割

UserInterface(旧 Presentation)

UI の提供とイベントハンドリングを担当します。

ディレクトリ 役割
Extensions ・拡張の実装
Resources ・String Catalog や Asset Catalog などリソースの提供
Scenes ・App で用いる Scene の提供
Views ・Scene で用いる View の提供

画像や文言のリソースはここにしか置きません。そのため、Model レイヤーでリソースが欲しくなった時は直接使用しないテクニックが必要です。例えば、Model レイヤーではString.LocalizationValueだけを扱い、UserInterface レイヤーでリソースの実体を扱うなどです。また、DataSource 層で定義したenumstructから画像や文言を扱う場合は Extensions に拡張を生やしてリソースにアクセスします。

Model(旧 Domain)

ビジネスロジックと状態管理を担当します。

ディレクトリ/特殊ファイル 役割
Extensions ・拡張の実装
Services ・Dependency や Repository を使用してデータの加工・処理を行う
Stores ・ビジネスロジックの実装
・View で表示するデータの手配
・イベントやユーザーのアクションをハンドリング
AppDelegate.swift (任意) ・アプリのライフサイクルのイベントトリガー
AppDependencies.swift ・Dependencies のシングルトン保持と View へのアクセス手段の提供
Composable.swift ・Store 用のプロトコル

テストはServicesStoresの Unit Test を書くことになります。うまく LUCA で実装できていれば、Unit Test だけでもアプリ機能をかなり担保できるテストが書けます。

DataSource(旧 DataLayer)

データへのアクセスとインフラ抽象化を担当します。

ディレクトリ/特殊ファイル 役割
Dependencies ・管理下にない副作用を含む API を間接的に提供
・Service や Store での依存注入時に差し替え可能にする
Entities ・取り扱うデータ型の定義
Extensions ・拡張の実装
Repositories ・データの読み書きを司る
AppStateClient.swift ・AppState のための特別な DependencyClient
・Stream への atomic な送出(send)の提供
AppState.swift ・アプリのライフサイクル全体で必要な状態の管理
・状態更新を伝達する Stream(AsyncStreamBundle)の保持
AsyncStreamBundle.swift ・状態更新を伝達する Stream のラッパー(AsyncStream + share、latestValue 付き)
DependencyClient.swift ・Dependency 用のプロトコル

Repositories では固定のキーをよく扱うことになるため、Extensions で String を拡張してキーを Type Safe にすると良いです。

旧アーキテクチャからの変更

1. Action 中心の統一的なイベント処理

Before(メソッドを直接個別に呼び出す):

@MainActor @Observable public final class HogeViewModel {
    public func postLog(screenName: String) {
        // ログを送る処理
    }

    public func save() {
        // データを保存する処理
    }
}

struct HogeView: View {
    @State var viewModel: HogeViewModel

    var body: some View {
        Button("Save") {
            viewModel.save()
        }
        .onAppear {
            viewModel.postLog(screenName: "HogeView")
        }
    }
}

After(統一して send で Action を送る):

@MainActor @Observable public final class Hoge: Composable {
    public func reduce(_ action: Action) async {
        switch action {
        case let .task(screenName):
            // ログを送る処理

        case .saveButtonTapped:
            // データを保存する処理
        }
    }

    enum Action {
        case task(String)
        case saveButtonTapped
    }
}

struct HogeView: View {
    @State var store: Hoge

    var body: some View {
        Button("Save") {
            Task {
                await store.send(.saveButtonTapped)
            }
        }
        .task {
            await store.send(.task("HogeView"))
        }
    }
}
2. AppServices の廃止と Service の状態レス化

Before(シングルトンな AppServices + 状態を持つ Service):

// Serviceが状態を持っていた
public actor FooService {
    private var someValue: String
}

public actor BarService {
    private var someValue: Double
}

public final class AppServices: Sendable {
    public let fooService: FooService
    public let barService: BarService

    public nonisolated init(appDependencies: AppDependencies) {
        fooService = .init(appDependencies.aaaClient)
        barService = .init(appDependencies.bbbClient, appDependencies.cccClient)
    }

    static let shared = AppServices(appDependencies: .shared)
}

// AppServicesがシングルトンのため、Serviceもアプリのライフサイクルの中でシングルトン
extension EnvironmentValues {
    @Entry public var appServices = AppServices.shared
}

After(非シングルトンで状態レスな Service):

// AppServicesは削除

// Serviceは都度インスタンス化、状態を持たない
// actorからstructに変更
public struct LogService {
    private let appStateClient: AppStateClient
    private let loggingSystemClient: LoggingSystemClient

    public init(_ appDependencies: AppDependencies) {
        self.appStateClient = appDependencies.appStateClient
        self.loggingSystemClient = appDependencies.loggingSystemClient
    }

    public func bootstrap() {
        // 状態はAppStateClientで管理
        guard !appStateClient.withLock(\.hasAlreadyBootstrap) else { return }
        #if DEBUG
        loggingSystemClient.bootstrap { label in
            StreamLogHandler.standardOutput(label: label)
        }
        #endif
        appStateClient.withLock { $0.hasAlreadyBootstrap = true }
    }
}
3. AppStateClient による一元的状態管理と Stream 集約

Before(Service 毎に散らばった状態と Stream):

// Serviceが状態を持ち、その更新を外部で検知したい場合はStreamを公開しておき購読させる
public actor FooService {
    private var someValue: String

    private let someValueSubject = CurrentValueSubject<Int, Never>(0)

    func someValueStream() -> AsyncStream<Int> {
        AsyncStream { continuation in
            let cancellable = someValueSubject.sink { value in
                continuation.yield(value)
            }
            continuation.onTermination = { _ in
                cancellable.cancel()
            }
        }
    }
}

After(AppState に集約された Stream):

// アプリのライフサイクル全体で保持すべき状態とStreamをAppStateに集約
// Streamは AsyncStreamBundle(AsyncStream + swift-async-algorithms の share)で表現する
public struct AppState: Sendable {
    public var someValue: String = "Hello World"
    public var someStream = AsyncStreamBundle<Int>()
}

// AppStateへの安全なアクセスとStreamへのatomicな送出を提供
// どんなAppStateでも決まった書き方(スニペットにするといい)
public struct AppStateClient: DependencyClient {
    private let lock: OSAllocatedUnfairLock<AppState>

    private init(lock: OSAllocatedUnfairLock<AppState>) {
        self.lock = lock
    }

    // body全体をロック内で実行するのでread-modify-writeがatomic
    public func withLock<R: Sendable>(_ body: @Sendable (inout AppState) throws -> R) rethrows -> R {
        try lock.withLock(body)
    }

    // Streamへの送出は必ずこのsend経由にする(atomic性の担保)
    public func send<T: Sendable>(
        _ keyPath: any WritableKeyPath<AppState, AsyncStreamBundle<T>> & Sendable,
        _ value: T
    ) {
        lock.withLock { $0[keyPath: keyPath].send(value) }
    }

    public static let liveValue = Self(lock: .init(initialState: .init()))
    public static let testValue = Self(lock: .init(initialState: .init()))

    public static func testDependency(_ appState: OSAllocatedUnfairLock<AppState>) -> Self {
        Self(lock: appState)
    }
}

// 状態を取得するとき
let someValue = appStateClient.withLock(\.someValue)

// Streamに値を流すとき(producer)
appStateClient.send(\.someStream, 42)

// Streamを購読するとき(consumer)
Task {
    let stream = appStateClient.withLock(\.someStream.stream)
    for await value in stream {
        // valueを受け取った処理
    }
}

これらの変更により

  • トレーサビリティの向上: すべてのイベントが Action として記録・追跡可能
  • テスタビリティの向上: Service の状態レス化により、テストの独立性が確保
  • データフローの明確化: AppStateClient を経由した一元的な状態・Stream 管理

という改善がなされています。

実装例

https://github.com/Kyome22/Telescopure

https://github.com/Kyome22/ShiftWindow

具体的な実装方法

シンプルな BMI 計算アプリを例に LUCA の実装方法を紹介します。

Swift Package Manager でローカルパッケージを構成

ひな形をスニペットにしておくと便利です。

Package.swift
// swift-tools-version: 6.2

import PackageDescription

let swiftSettings: [SwiftSetting] = [
    .enableUpcomingFeature("ExistentialAny"),
]

let package = Package(
    name: "LocalPackage",
    defaultLocalization: "en",
    platforms: [
        .iOS(.v18),
    ],
    products: [
        .library(
            name: "DataSource",
            targets: ["DataSource"]
        ),
        .library(
            name: "Model",
            targets: ["Model"]
        ),
        .library(
            name: "UserInterface",
            targets: ["UserInterface"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-async-algorithms.git", exact: "1.1.4"),
    ],
    targets: [
        .target(
            name: "DataSource",
            dependencies: [
                .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
            ],
            swiftSettings: swiftSettings
        ),
        .target(
            name: "Model",
            dependencies: [
                "DataSource",
            ],
            swiftSettings: swiftSettings
        ),
        .target(
            name: "UserInterface",
            dependencies: [
                "Model",
            ],
            resources: [.process("Resources")],
            swiftSettings: swiftSettings
        ),
        .testTarget(
            name: "ModelTests",
            dependencies: [
                "Model",
            ],
            swiftSettings: swiftSettings
        ),
    ]
)

DataSource レイヤーの実装

1. 一般的な Entity の実装

structenumなどでデータ構造を定義します。必要に応じて Identifiable や Hashable、Codable などに準拠させます。

LocalPackage/Sources/DataSource/Entities/Person.swift
import Foundation

public struct Person: Codable, Sendable, Equatable {
    public var name: String
    public var weight: Double // kg
    public var height: Double // cm

    public init(name: String, weight: Double, height: Double) {
        self.name = name
        self.weight = weight
        self.height = height
    }

    public static let empty = Person(name: "", weight: .zero, height: .zero)
}

コーディングルール

  • Entity にはビジネスロジックを書かない

2. DependencyClient.swift の実装

全ての Dependency が準拠すべきプロトコルを定義します。テストを簡単に実装するための便利関数も定義しておきます。

LocalPackage/Sources/DataSource/DependencyClient.swift
public protocol DependencyClient: Sendable {
    static var liveValue: Self { get }
    static var testValue: Self { get }
}

public func testDependency<D: DependencyClient>(of type: D.Type, injection: (inout D) -> Void) -> D {
    var dependencyClient = type.testValue
    injection(&dependencyClient)
    return dependencyClient
}

3. 一般的な Dependency の実装

管理下にない副作用を含む API へのアクセスを抽象化し、テスト時にモック化可能にします。

LocalPackage/Sources/DataSource/Dependencies/UserDefaultsClient.swift
import Foundation

public struct UserDefaultsClient: DependencyClient {
    var data: @Sendable (String) -> Data?
    var setData: @Sendable (Data?, String) -> Void

    public static let liveValue = Self(
        data: { UserDefaults.standard.data(forKey: $0) },
        setData: { UserDefaults.standard.set($0, forKey: $1) }
    )

    public static let testValue = Self(
        data: { _ in nil },
        setData: { _, _ in }
    )
}

コーディングルール

  • 原則元の API のインターフェースをそのまま提供できるようにする

    • プロパティ名や関数名はなるべく変更しない
    • 不要だからといってデフォルト値を使って引数を減らすこともしない
  • 元がインスタンスに生えているプロパティや関数の場合は、第一引数にインスタンスをもらう

    public struct DataClient: DependencyClient {
        public var write: @Sendable (Data, URL) throws -> Void
    
        public static let liveValue = Self(
            write: { try $0.write(to: $1) }
        )
    
        public static let testValue = Self(
            write: { _, _ in }
        )
    }
    

4. 一般的な Repository の実装

直接的なデータの読み書きを Repository に閉じ込めます。Repository を通してのみデータの読み書きを行なってください。それこそがテスタビリティに貢献します。

LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift
import Foundation

public struct UserDefaultsRepository: Sendable {
    private var userDefaultsClient: UserDefaultsClient

    public var person: Person {
        get {
            guard let data = userDefaultsClient.data(.person) else { return Person.empty }
            return (try? JSONDecoder().decode(Person.self, from: data)) ?? Person.empty
        }
        nonmutating set {
            let data = try? JSONEncoder().encode(newValue)
            userDefaultsClient.setData(data, .person)
        }
    }

    public init(_ userDefaultsClient: UserDefaultsClient) {
        self.userDefaultsClient = userDefaultsClient
    }
}

文字列のキーを扱うなら Type Safe にします。(もちろん String の拡張ではない方法でも良いです。)

LocalPackage/Sources/DataSource/Extensions/String+Extension.swift
extension String {
    static let person = "person"
}

コーディングルール

  • initの引数には必要な Dependency を渡す

5. AppState.swift の実装

アプリ全体で共有する状態を管理する特殊な Entity である AppState を実装します。

LocalPackage/Sources/DataSource/Entities/AppState.swift
public struct AppState: Sendable {
    public var hasAlreadyTutorial: Bool = false
    // 状態購読が必要な場合は AsyncStreamBundle<T> のフィールドをここに足す
}

6. AppStateClient.swift の実装

AppState への安全なアクセス手段を提供する特殊な Dependency である AppStateClient を実装します。

OSAllocatedUnfairLock(Apple 純正・追加依存なし)を直接保持し、withLock をそれに委譲します。body 全体をロック内で実行するので read-modify-write が atomic になります(getAppState コピー→bodysetAppState 書き戻しのような実装にすると、並行書き込みで更新が失われるので避けます)。ロックは差し替え可能で、withLock(_:) 相当を持つ型なら何でも使えます。

LocalPackage/Sources/DataSource/Dependencies/AppStateClient.swift
import os

public struct AppStateClient: DependencyClient {
    private let lock: OSAllocatedUnfairLock<AppState>

    private init(lock: OSAllocatedUnfairLock<AppState>) {
        self.lock = lock
    }

    public func withLock<R: Sendable>(_ body: @Sendable (inout AppState) throws -> R) rethrows -> R {
        try lock.withLock(body)
    }

    // Streamへの送出は必ずこのsend経由にする(atomic性の担保)
    public func send<T: Sendable>(
        _ keyPath: any WritableKeyPath<AppState, AsyncStreamBundle<T>> & Sendable,
        _ value: T
    ) {
        lock.withLock { $0[keyPath: keyPath].send(value) }
    }

    // latestValue起点のread-modify-writeをロック内で完結させたい場合
    public func send<T: Sendable>(
        _ keyPath: any WritableKeyPath<AppState, AsyncStreamBundle<T>> & Sendable,
        default defaultValue: @autoclosure @Sendable () -> T,
        _ transform: @Sendable (inout T) -> Void
    ) {
        lock.withLock { appState in
            var value = appState[keyPath: keyPath].latestValue ?? defaultValue()
            transform(&value)
            appState[keyPath: keyPath].send(value)
        }
    }

    public static let liveValue = Self(lock: .init(initialState: .init()))
    public static let testValue = Self(lock: .init(initialState: .init()))

    public static func testDependency(_ appState: OSAllocatedUnfairLock<AppState>) -> Self {
        Self(lock: appState)
    }
}

7. AsyncStreamBundle.swift の実装(状態購読が必要な場合)

状態の変化を複数の Store からリアクティブに購読したい場合は、AsyncStreamBundleAppState のフィールドとして持たせます。AsyncStream を apple/swift-async-algorithms の share() で多重購読可能にし、最新値を latestValue として同期的に読み戻せるようにしたラッパーです。

LocalPackage/Sources/DataSource/Entities/AsyncStreamBundle.swift
import AsyncAlgorithms

public typealias AsyncShareStream<T: Sendable> = Sendable & AsyncSequence<T, AsyncStream<T>.__AsyncSequence_Failure>

public struct AsyncStreamBundle<T>: Sendable where T: Sendable {
    public let stream: any AsyncShareStream<T>
    private let continuation: AsyncStream<T>.Continuation
    public private(set) var latestValue: T? = nil

    public init() {
        let (stream, continuation) = AsyncStream.makeStream(
            of: T.self,
            bufferingPolicy: .bufferingNewest(1)
        )
        self.stream = stream.share(bufferingPolicy: .bufferingLatest(1))
        self.continuation = continuation
    }

    public mutating func send(_ value: T) {
        latestValue = value
        continuation.yield(value)
    }
}

extension AsyncStreamBundle where T == Void {
    public mutating func send() {
        send(())
    }
}

producer(Service)は appStateClient.send(\.someStream, value) で値を流し、consumer(Store)は reduce(.task) の中で購読します。share は後から購読した相手に過去の値を replay しないため、購読開始時はまず latestValue で現在値をシードしてから for await し、.onDisappear で購読タスクをキャンセルします。

Storeの購読例
@ObservationIgnored private var task: Task<Void, Never>?

public func reduce(_ action: Action) async {
    switch action {
    case .task:
        if let latest = appStateClient.withLock(\.someStream.latestValue) {
            someValue = latest
        }
        task?.cancel()
        task = Task { [weak self, appStateClient] in
            let stream = appStateClient.withLock(\.someStream.stream)
            for await value in stream {
                self?.someValue = value // Taskが@MainActorを継承するのでawait不要
            }
        }

    case .onDisappear:
        task?.cancel()
        task = nil
    }
}

Model レイヤーの実装

1. AppDependencies.swift の実装

全ての依存関係を集約し、環境変数として提供する AppDependencies を実装します。

LocalPackage/Sources/Model/AppDependencies.swift
import DataSource
import SwiftUI

public struct AppDependencies: Sendable {
    public var appStateClient = AppStateClient.liveValue
    public var userDefaultsClient = UserDefaultsClient.liveValue

    // AppDelegateが必要な場合に実装する
    static let shared = AppDependencies()
}

extension EnvironmentValues {
    @Entry public var appDependencies = AppDependencies.shared
}

// テストの実装を簡単にするための便利関数を定義しておく
extension AppDependencies {
    public static func testDependencies(
        appStateClient: AppStateClient = .testValue,
        userDefaultsClient: UserDefaultsClient = .testValue
    ) -> AppDependencies {
        AppDependencies(
            appStateClient: appStateClient,
            userDefaultsClient: userDefaultsClient
        )
    }
}

2. AppDelegate.swift の実装(任意)

アプリのライフサイクルイベントが必要な場合に実装します。

LocalPackage/Sources/Model/AppDelegate.swift
import DataSource
import SwiftUI

@MainActor public final class AppDelegate: NSObject, NSApplicationDelegate {
    private let appDependencies = AppDependencies.shared

    public func applicationDidFinishLaunching(_ notification: Notification) {
        // ここでやりたい処理をする
        // 例: ログシステムの初期化、設定の読み込み、など
    }

    public func applicationWillTerminate(_ notification: Notification) {
        // アプリ終了時の処理
    }
}

3. 一般的な Service の実装

ビジネスロジックを提供する Service を実装します。Service 自体には状態を持たせないので、状態が必要な時は引数で与えるか、AppStateClient 経由で取得します。

LocalPackage/Sources/Model/Services/BMIService.swift
import DataSource

public struct BMIService {
    private let appStateClient: AppStateClient

    public init(_ appDependencies: AppDependencies) {
        self.appStateClient = appDependencies.appStateClient
    }

    public func calculateBMI(weight: Double, height: Double) -> Double {
        guard height > 0 else { return 0 }
        let heightInMeters = height / 100
        return (100 * weight / (heightInMeters * heightInMeters)).rounded() / 100
    }
}

コーディングルール

  • initの引数は直接 Dependency ではなくAppDependencies にする
  • Dependency や Repository が必要な場合は AppDependenciesから構築する
  • Service は基本的にstructで定義し、必要に応じてactorで定義する

4. Composable.swift の実装

全ての Store が準拠すべきプロトコルを定義します。

LocalPackage/Sources/Model/Composable.swift
import Observation

@MainActor
public protocol Composable: AnyObject {
    associatedtype Action: Sendable

    var action: (Action) async -> Void { get }

    func reduce(_ action: Action) async
}

public extension Composable {
    func reduce(_ action: Action) async {}

    func send(_ action: Action) async {
        await reduce(action)
        await self.action(action)
    }
}

5. 一般的な Store の実装

画面の状態管理とイベントのハンドリングを行う Store を実装します。

LocalPackage/Sources/Model/Stores/PersonBMI.swift
import Foundation
import DataSource
import Observation

@MainActor @Observable public final class PersonBMI: Composable {
    private let userDefaultsRepository: UserDefaultsRepository
    private let bmiService: BMIService
    private let appStateClient: AppStateClient

    public var person: Person
    public var calculatedBMI: Double
    public var isPresentedTutorial: Bool
    public let action: (Action) async -> Void

    public init(
        _ appDependencies: AppDependencies,
        person: Person = .empty,
        calculatedBMI: Double = .zero,
        isPresentedTutorial: Bool = false,
        action: @escaping (Action) async -> Void
    ) {
        self.userDefaultsRepository = .init(appDependencies.userDefaultsClient)
        self.bmiService = .init(appDependencies)
        self.appStateClient = appDependencies.appStateClient
        self.person = person
        self.calculatedBMI = calculatedBMI
        self.isPresentedTutorial = isPresentedTutorial
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case .task:
            person = userDefaultsRepository.person
            calculatedBMI = bmiService.calculateBMI(weight: person.weight, height: person.height)
            isPresentedTutorial = appStateClient.withLock {
                if $0.hasAlreadyTutorial {
                    return false
                } else {
                    $0.hasAlreadyTutorial = true
                    return true
                }
            }

        case .calculateButtonTapped:
            calculatedBMI = bmiService.calculateBMI(weight: person.weight, height: person.height)

        case .saveButtonTapped:
            userDefaultsRepository.person = person
        }
    }

    public enum Action {
        case task
        case calculateButtonTapped
        case saveButtonTapped
    }
}

コーディングルール

  • プロパティは全てinitの引数で渡せるようにする(structの memberwise initializer と同様)
  • プロパティにデフォルト値を渡したい場合は定義時ではなくinitでデフォルト引数をもらうようにする
  • Actioncaseの命名規則
    • SwiftUI のイベント名は基本そのまま用いる
      case task
      case onDisappear
      case onTapGesture
      case onChangeSomeValue // onChangeの場合は何が変化したのかもわかるように後ろにつける
      
    • ユーザーの行動がベースの場合は UI コンポーネントごとに統一された命名パターンを用いる
      • Button: 〜ButtonTapped
        case cancelButtonTapped
        case createImageButtonTapped
        case deleteButtonTapped
        
      • Toggle: 〜ToggleSwitched
        case notificationsToggleSwitched(Bool)
        case darkModeToggleSwitched(Bool)
        
      • Picker: 〜PickerSelected
        case themePickerSelected(Theme)
        case languagePickerSelected(Language)
        
    • 通信を挟む場合は ~Response のような命名にしてResultを受け取る
      case submitRecordResponse(Result<String, any Error>)
      case fetchImageResponse(Result<UIImage, any Error>)
      

UserInterface レイヤーの実装

1. 一般的な View の実装

普通に SwiftUI の View として実装します。対となる Store を持ち、その Store の持つデータを反映することに徹しましょう。

LocalPackage/Sources/UserInterface/Views/PersonBMIView.swift
import DataSource
import Model
import SwiftUI

struct PersonBMIView: View {
    @State var store: PersonBMI

    var body: some View {
        Form {
            Section {
                LabeledContent("名前") {
                    TextField("名前を入力", text: $store.person.name)
                        .textFieldStyle(.roundedBorder)
                }
                LabeledContent("体重 (kg)") {
                    TextField("体重", value: $store.person.weight, format: .number)
                        .textFieldStyle(.roundedBorder)
                }
                LabeledContent("身長 (cm)") {
                    TextField("身長", value: $store.person.height, format: .number)
                        .textFieldStyle(.roundedBorder)
                }
            }
            Section {
                LabeledContent("BMI") {
                    Text(String(format: "%.1f", store.calculatedBMI))
                }
                Button("BMI算出") {
                    Task {
                        await store.send(.calculateButtonTapped)
                    }
                }
                .buttonStyle(.borderedProminent)
                Button("保存") {
                    Task {
                        await store.send(.saveButtonTapped)
                    }
                }
                .buttonStyle(.bordered)
            }
        }
        .task {
            await store.send(.task)
        }
        .alert("チュートリアル", isPresented: $store.isPresentedTutorial) {
            Button("OK") {}
        }
    }
}

#Preview {
    PersonBMIView(store: .init(.testDependencies()))
}

コーディングルール

  • Store はstoreという名前で定義する
    • ForEach などで取得するときも同じくstoreと名づける
  • イベントはstore.send(Action)を用いて Store に伝達する
    • awaitが必要なのでButtonなどでは仕方なく Unstructured Task を用いている
  • Preview マクロを書いて Xcode Preview が機能するようにする

2. 一般的な Scene の実装

定義した View を表示する Scene を定義します。Store には基本AppDependenciesが必要となるのでEnvironmentで取得しましょう。

LocalPackage/Sources/UserInterface/Scenes/PersonBMIScene.swift
import Model
import SwiftUI

public struct PersonBMIScene: Scene {
    @Environment(\.appDependencies) private var appDependencies

    public init() {}

    public var body: some Scene {
        WindowGroup {
            NavigationView {
                PersonBMIView(store: .init(appDependencies))
            }
        }
    }
}

App の実装

アプリケーションのエントリーポイントを実装します。逆に言えばそれ以上のことはしません。Local Package に閉じ込めましょう。

BMI/BMIApp.swift
import UserInterface
import SwiftUI

@main
struct BMIApp: App {
    // AppDelegateが必要な場合に定義
    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        PersonBMIScene()
    }
}

テストの実装

LUCA では Service と Store の単体テストを書くことで、アプリの主要な機能をテストできます。

一般的な Service のテスト

状態を持たない Service は純粋関数として簡単にテストできます。

LocalPackage/Tests/ModelTests/ServiceTests/BMIServiceTests.swift
import Testing
import DataSource
@testable import Model

@MainActor struct BMIServiceTests {
    @Test
    func calculateBMI_身長がゼロでない_正常な値が返される() {
        let sut = BMIService(.testDependencies())
        let actual = sut.calculateBMI(weight: 70, height: 175)
        #expect(actual == 22.86)
    }

    @Test
    func calculateBMI_身長がゼロ_ゼロが返される() {
        let sut = BMIService(.testDependencies())
        let actual = sut.calculateBMI(weight: 70, height: 0)
        #expect(actual == 0)
    }
}

一般的な Store のテスト

Store は AppDependencies をモック化してテストします。

LocalPackage/Tests/ModelTests/StoreTests/PersonBMITests.swift
import os
import Testing
@testable import DataSource
@testable import Model

struct PersonTests {
    @MainActor @Test
    func send_task_保存されたデータが復元される() async {
        let sut = Person(.testDependency(
            userDefaultsClient: testDependency(of: UserDefaultsClient.self) {
                $0$.data = { _ in
                    return try! JSONEncoder().encode(Person(name: "テスト", weight: 70.0, height: 175.0))
                }
            }
        ))
        await sut.send(.task)
        #expect(sut.person == Person(name: "テスト", weight: 70.0, height: 175.0))
    }

    @MainActor @Test
    func send_calculateButtonTapped_BMIが計算される() async {
        let sut = PersonBMI(
            .testDependency(),
            person: Person(name: "テスト", weight: 70.0, height: 175.0)
        )
        await sut.send(.calculateButtonTapped)
        #expect(sut.calculatedBMI == 22.86)
    }

    @MainActor @Test
    func send_saveButtonTapped_データが保存される() async {
        var savedData = OSAllocatedUnfairLock<Data?>(initialState: nil)
        let sut = PersonBMI(
            .testDependency(
                userDefaultsClient: testDependency(of: UserDefaultsClient.self) {
                    $0.setData = { data, _ in
                        savedData.withLock { $0 = data }
                    }
                }
            ),
            person: Person(name: "テスト", weight: 70.0, height: 175.0)
        )
        await sut.send(.saveButtonTapped)
        #expect(savedData.withLock(\.self) != nil)
    }
}

このように、依存性注入により各コンポーネントを独立してテストできるのが LUCA の大きな利点です。

コーディングルール

  • テストのケース名はsend_{ActionName}_{条件(任意)}_{期待される結果}のようにする
    func send_task_ログが出力される()
    func send_deleteButtonTapped_画像が選択中である_画像が削除される()
    func send_notificationsToggleSwitched_通知設定が無効である_通知設定が有効に更新される()
    func send_themePickerSelected_テーマが変更される()
    

応用的な実装

子のイベントを親でハンドリングする

子の Store のイベントを親の Store でハンドリングしたい場合、Action クロージャを用いて委譲します。

子 Store の実装:

@MainActor @Observable public final class Child: Composable {
    public let action: (Action) -> Void

    public init(
        action: @escaping (Action) -> Void
    ) {
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case .closeButtonTapped:
            break
        }
    }

    public enum Action {
        case closeButtonTapped
    }
}

親 Store の実装:

@MainActor @Observable public final class Parent: Composable {
    public var child: Child?
    public let action: (Action) -> Void

    public init(
        child: Child? = nil,
        action: @escaping (Action) async -> Void = { _ in }
    ) {
        self.child = child
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case .openChildButtonTapped:
            child = .init(action: { [weak self] in
                self?.send(.child($0))
            })

        // 子のActionをハンドリングする
        case .child(.closeButtonTapped):
            child = nil

        case .child:
            break
        }
    }

    public enum Action {
        case openChildButtonTapped
        case child(Child.Action)
    }
}

子に AppDependencies を渡す

子の初期化にAppDependenciesが必要な場合はActionを通して View のEnvironmentValuesから値を渡します。

親 Store の実装:

@MainActor @Observable public final class Parent: Composable {
    public var child: Child?
    public let action: (Action) -> Void

    public init(
        child: Child? = nil,
        action: @escaping (Action) async -> Void = { _ in }
    ) {
        self.child = child
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case let .openChildButtonTapped(appDependencies):
            child = .init(appDependencies, action: { [weak self] in
                self?.send(.child($0))
            })

        case .child:
            break
        }
    }

    public enum Action {
        case openChildButtonTapped(AppDependencies) // View経由でもらう
        case child(Child.Action)
    }
}

ナビゲーション

NavigationStack を用いた型安全なナビゲーション管理を実装します。

Path 定義を持つ Store の実装:

@MainActor @Observable public final class Fruits: Composable {
    public var path: [Path]
    public var bananas: [Banana]
    public let action: (Action) async -> Void

    public init(
        _ appDependencies: AppDependencies,
        path: [Path] = [],
        bananas: [Banana] = [],
        action: @escaping (Action) async -> Void
    ) { /* 省略 */ }

    public func reduce(_ action: Action) async {
        switch action {
        case let .appleButtonTapped(appDependencies):
            path.append(.apple(.init(appDependencies, action: { [weak self] in
                self?.send(.settings($0))
            })))

        case let .bananaButtonTapped(store):
            path.append(.banana(store))

        case .apple:
            break

        case .banana:
            break
        }
    }

    public enum Action {
        case appleButtonTapped(AppDependencies) // 新規にStoreを作るパターン
        case bananaButtonTapped(Banana) // すでに作られているStoreをもらうパターン
        case apple(Apple.Action)
        case banana(Banana.Action)
    }

    public enum Path: Hashable {
        case apple(Apple)
        case banana(Banana)

        // 遷移先のStoreはIdentifiableに準拠する必要がある
        var id: Int {
            switch self {
            case let .apple(value):
                Int(bitPattern: ObjectIdentifier(value))
            case let .banana(value):
                Int(bitPattern: ObjectIdentifier(value))
            }
        }

        public func hash(into hasher: inout Hasher) {
            hasher.combine(id)
        }

        public static func ==(lhs: Path, rhs: Path) -> Bool {
            lhs.id == rhs.id
        }
    }
}

NavigationStack と navigationDestination を使った View 実装:

struct FruitsView: View {
    @Environment(\.appDependencies) private var appDependencies
    @State var store: Fruits

    var body: some View {
        NavigationStack(path: $store.path) {
            VStack {
                Button("Apple") {
                    Task {
                        await store.send(.appleButtonTapped(appDependencies))
                    }
                }
                ForEach(store.bananas) { store in
                    Button("Banana: \(store.id)") {
                        Task {
                            await store.send(.bananaButtonTapped(store))
                        }
                    }
                }
            }
            .navigationDestination(for: Fruits.Path.self) { path in
                switch path {
                case let .apple(store):
                    AppleView(store: store)

                case let .banana(store):
                    BananaView(store: store)
                }
            }
        }
    }
}

まとめ

LUCAは、SwiftUI × Observation 時代に最適化された実践的なアーキテクチャです。Apple 純正の Framework のみを使用し、個人開発に適した軽量な設計を実現しています。

LUCA の特徴

特性 説明
Maintainability 明確な責務分離により保守性が向上
各層の役割が明確で変更の影響範囲を限定
Testability 依存性注入により Unit Test が容易
Service と Store のテストでアプリ機能を網羅
Scalability 階層的な状態管理で拡張性を確保
新機能追加時の既存コードへの影響を最小化
Type Safety Action 中心の統一イベント処理により型安全な状態管理を実現
Consistency 一貫したパターンにより開発効率が向上
チーム開発でもコードの統一性を維持
SwiftUI Integration SwiftUI の API に最大限寄り添い、フレームワークの特性を活かす

Action 中心の統一的なイベント処理、状態レスな Service と AppStateClient による一元的状態管理により、トレーサビリティの向上とデータフローの明確化を実現しています。

このアーキテクチャが、SwiftUI アプリ開発における課題解決の一助となり、より良いアプリケーション開発の実現に貢献できることを期待しています。

Discussion

KyomeKyome

Dependenciesに関してはPoint-Freeのswift-dependenciesを利用するパターンも大いにありです。StoreにappDependenciesを毎回渡す必要がなくなるので、実装しやすくなります。

3
momomomo

とても読みやすく、分かりやすかったです。
参考になりました。

KyomeKyome

Toggle(isOn:label:) のようなBindingしているプロパティの変化をきっかけにActionをsendしたい場合はonChangeを使うのではなく、Binding<Type>(get:asyncSet:)を使うと便利です。onChangeだと下手に実装した時無限ループに陥りがちですが、asyncSetstore.send()すればユーザーの入力だけをトリガーにできます。

import SwiftUI

extension Binding where Value : Sendable {
    @preconcurrency init(
        @_inheritActorContext get: @escaping @isolated(any) @Sendable () -> Value,
        @_inheritActorContext asyncSet: @escaping @isolated(any) @Sendable (Value) async -> Void
    ) {
        self.init(get: get, set: { newValue in Task { await asyncSet(newValue) } })
    }
}
@MainActor @Observable
public final class SomeStore: Composable {
    public var isOn: Bool
    public let action: (Action) async -> Void

    public init(
        isOn: Bool = false,
        action: @escaping (Action) async -> Void
    ) {
        self.isOn = isOn
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case let .isOnToggleSwitched(value):
            isOn = value
            // さらに処理
        }
    }

    public enum Action: Sendable {
        case isOnToggleSwitched(Bool)
    }
}

struct SomeView: View {
    @State var store: SomeStore

    var body: some View {
        Toggle(isOn: Binding<Bool>(
            get: { store.isOn },
            asyncSet: { await store.send(.isOnToggleSwitched($0)) }
        )) {
            Text("flag")
        }
    }
}
KyomeKyome

TestStore — 親への委譲 Action をテストする仕組み

LUCA の親子 Store 間の委譲メカニズム

LUCA では子 Store から親 Store へイベントを伝える際、子が伝達用の Action を自分自身に send するパターンを使います。

カウンターを例に考えます。Child Store はカウントのインクリメントを担い、インクリメントが完了したことを親に通知したいとします。

@MainActor @Observable public final class Child: Composable {
    public var count: Int
    public let action: (Action) async -> Void

    public init(
        count: Int = 0,
        action: @escaping (Action) async -> Void = { _ in }
    ) {
        self.count = count
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case .incrementButtonTapped:
            count += 1
            // 伝達用 Action を send すると、Composable.send が
            // 最後に self.action(.onCountUpdated) を呼んで親に届ける
            await self.send(.onCountUpdated(count))

        case .onCountUpdated:
            return  // 子自身は何もしない。親が処理する
        }
    }

    public enum Action: Sendable {
        case incrementButtonTapped
        case onCountUpdated(Int)  // ← 親への伝達用 Action
    }
}

Composable.send はまず reduce(action) で状態を更新し、その後 self.action(action) を呼んで親に通知します。

func send(_ action: Action) async {
    await reduce(action)      // 1. 内部で状態更新
    await self.action(action) // 2. 親へ委譲
}

親 Store では、この伝達 Action を受け取って処理します。

@MainActor @Observable public final class Parent: Composable {
    public var child: Child?
    public var latestCount: Int
    public let action: (Action) async -> Void

    // ...

    public func reduce(_ action: Action) async {
        switch action {
        case .openChildButtonTapped:
            child = .init(action: { [weak self] in
                await self?.send(.child($0))
            })

        case let .child(.onCountUpdated(count)):
            latestCount = count  // 子からのカウント更新を反映

        case .child:
            break
        }
    }

    public enum Action: Sendable {
        case openChildButtonTapped
        case child(Child.Action)
    }
}

なぜ「Action A を送ったら Action B が発行される」をテストするのか

上記の仕組みから、子 Store の単体テストでは次の 2 種類のテストが必要になります。

テストの種類 検証内容
状態のテスト Action を送った後、Store のプロパティが正しく変化しているか
委譲のテスト Action を送った結果、正しい伝達 Action が親に向けて発行されたか

後者を通常のテストパターンで書こうとすると行き詰まります。

// 通常パターン:action クロージャが no-op なので
// .onCountUpdated が発行されたかどうかを確認できない
let sut = Child(action: { _ in })  // ← 全ての委譲を捨てている
await sut.send(.incrementButtonTapped)
// .onCountUpdated(1) が親に向けて送られたか確認する手段がない

この問題を解決するのが TestStore です。

TestStore の設計

@MainActor @Observable @dynamicMemberLookup
final class TestStore<Store: Composable>: Composable {
    typealias Action = Store.Action

    private var wrappedStore: Store
    private(set) var actionHistory = [Action]()

    let action: (Store.Action) async -> Void = { _ in }

    init(_ factory: (_ action: @escaping (Action) async -> Void) -> Store) {
        weak var weakSelf: TestStore?
        wrappedStore = factory { action in
            weakSelf?.actionHistory.append(action)
        }
        weakSelf = self
    }

    subscript<T>(dynamicMember keyPath: KeyPath<Store, T>) -> T {
        wrappedStore[keyPath: keyPath]
    }

    subscript<T>(dynamicMember keyPath: WritableKeyPath<Store, T>) -> T {
        get { wrappedStore[keyPath: keyPath] }
        set { wrappedStore[keyPath: keyPath] = newValue }
    }

    func send(_ action: Action) async {
        guard actionHistory.isEmpty else {
            Issue.record("There are actions that are not being handled by receive().")
            return
        }
        await wrappedStore.send(action)
        if !actionHistory.isEmpty {
            actionHistory.removeLast()
        }
    }

    func receive(expect: (Action) -> Bool) {
        guard let index = actionHistory.firstIndex(where: expect) else {
            Issue.record("Expected action was not received.")
            return
        }
        actionHistory.remove(at: index)
    }
}

仕組みの核心:末尾除去

Composable.sendreduce → action の順で実行します。ネストした self.send(...) による委譲の action 呼び出しは、テスト起点の action 呼び出しより必ず先に完了します。

.incrementButtonTapped を送った場合の実行順序を追うと:

[TestStore.send(.incrementButtonTapped)]

[wrappedStore.send(.incrementButtonTapped)]
    ↓ reduce(.incrementButtonTapped)
        count += 1
        await self.send(.onCountUpdated(1))
            ↓ reduce(.onCountUpdated(1))  // 子は何もしない
            ↓ action(.onCountUpdated(1))  → actionHistory に追記 ✓
    ↓ action(.incrementButtonTapped)      → actionHistory に追記(後で末尾削除)
    ↓ wrappedStore.send が返る
    ↓ actionHistory.removeLast()          → テスト起点の action を除去

wrappedStore.sendawait が返った時点で全ての委譲 action は actionHistory に積み済みです。末尾の 1 件(テスト起点の action)を除去するだけで、委譲 action だけが残ります。

委譲が複数ある場合も同様に機能します。

[TestStore.send(.doMultipleThings)]
    ↓ reduce(.doMultipleThings)
        await self.send(.onThingA) → action(.onThingA) が記録
        await self.send(.onThingB) → action(.onThingB) が記録
    ↓ action(.doMultipleThings)   → 記録
    ↓ removeLast()                → [.onThingA, .onThingB] が残る ✓

sendawait 完了時点で全件揃っているため、receive はポーリングなしの即時チェックで済みます。

@dynamicMemberLookup による透過アクセス

subscript(dynamicMember:) のおかげで、TestStore を通して sut.count のように wrapped Store のプロパティに直接アクセスできます。

使用例

TestStore が必要なテストと不要なテストを対比します。

状態のみのテスト(TestStore 不要):

委譲が発生しない Action(例:カウントをリセットするだけ)は従来通りシンプルに書けます。

@MainActor @Test
func send_incrementButtonTapped_countIsIncremented() async {
    let sut = Child(count: 0, action: { _ in })
    await sut.send(.incrementButtonTapped)
    #expect(sut.count == 1)
}

委譲 Action を含むテスト(TestStore 必要):

.incrementButtonTapped.onCountUpdated(1) を親に向けて発行することを検証します。

@MainActor @Test
func send_incrementButtonTapped_onCountUpdatedIsDelegated() async {
    let sut = TestStore {
        Child(count: 0, action: $0)  // TestStore が action クロージャを注入
    }

    await sut.send(.incrementButtonTapped)

    // 伝達 Action が親に向けて発行されたことを検証
    sut.receive {
        if case .onCountUpdated(1) = $0 { true } else { false }
    }

    // 状態の検証も同時に行える
    #expect(sut.count == 1)
}

現状の課題

同時に扱える委譲 Action が 1 つ

send() 冒頭の guard actionHistory.isEmpty により、前の receive() が完了するまで次の send() を呼べません。これはテストの記述順を制約します。

まとめ

LUCA の子 Store は「伝達用の Action を自分自身に send することで親に通知する」という設計上、Action A を送ったら Action B が親に向けて発行されるかというテストが必要になります。TestStore はこの委譲 Action を傍受・記録することでその検証を可能にする仕組みです。

Composable.sendreduce → action の順で実行するため、テスト起点の action は常に末尾に届きます。wrappedStore.send の完了後に末尾を除去するだけで委譲 action を分離でき、フラグもポーリングも必要としないシンプルな実装になっています。

TestStore は luca CLI が生成する ModelTests ターゲットに含まれており、すぐに使えます。