土曜日, 7月 26, 2025
土曜日, 7月 26, 2025
- Advertisment -
ホームニューステックニュースLUCA: SwiftUI に適したモダンアーキテクチャ

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



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

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 16.4+、Swift 6.1+、iOS 17+、macOS 14+

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”
「層は明確、フローは単純、設計は柔軟」

だそうです。

アーキテクチャの全体像

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

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

また、実装容易性および保守容易性のため、アーキテクチャの実現のために原則特別な Framework を必要としません(つまり Swift の言語機能だけで基本的には実装可能)。ただし、テスト容易性のため、SwiftUI の EnvironmentVlues を使って依存性の注入を行えるようにします。

ファイル構成

.
├── LocalPackage
│   ├── Package.swift
│   ├── Sources
│   │   ├── DataSource
│   │   │   ├── Dependencies
│   │   │   │   └── AppStateClient.swift
│   │   │   ├── Entities
│   │   │   │   └── AppState.swift
│   │   │   ├── Extensions
│   │   │   ├── Repositories
│   │   │   └── DependencyClient.swift
│   │   ├── Model
│   │   │   ├── Extensions
│   │   │   ├── Services
│   │   │   ├── Stores
│   │   │   ├── AppDelegate.swift (任意)
│   │   │   └── AppDependencies.swift
│   │   └── UserInterface
│   │       ├── Extensions
│   │       ├── Resources
│   │       ├── Scenes
│   │       └── Views
│   └── Tests
│       └── ModelTests
│           ├── ServiceTests
│           └── StoreTests
├── 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 へのアクセス手段の提供

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

DataSource(旧 DataLayer)

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

ディレクトリ/特殊ファイル 役割
Dependencies ・管理下にない副作用を含む API を間接的に提供
・Service や Store での依存注入時に差し替え可能にする
Entities ・取り扱うデータ型の定義
Extensions ・拡張の実装
Repositories ・データの読み書きを司る
AppStateClient.swift ・AppState のための特別な DependencyClient
AppState.swift ・アプリのライフサイクル全体で必要な状態の管理
・状態更新を伝達する Stream の提供
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 {
    public func send(_ action: Action) {
        switch action {
        case let .onAppear(screenName):
            

        case .saveButtonTapped:
            
        }
    }

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

struct HogeView: View {
    @State var store: Hoge

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

Before(シングルトンな AppServices + 状態を持つ 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)
}


extension EnvironmentValues {
    @Entry public var appServices = AppServices.shared
}

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





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() {
        
        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):


public actor FooService {
    private var someValue: String

    private let someValueSubject = CurrentValueSubjectInt, Never>(0)

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

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


public struct AppState: Sendable {
    public var someValue: String = "Hello World"
    public let someValueSubject = CurrentValueSubjectInt, Never>(0)
}



public struct AppStateClient: DependencyClient {
    var getAppState: @Sendable () -> AppState
    var setAppState: @Sendable (AppState) -> Void

    public func withLockR: Sendable>(_ body: @Sendable (inout AppState) throws -> R) rethrows -> R {
        var state = getAppState()
        let result = try body(&state)
        setAppState(state)
        return result
    }

    public static let liveValue: Self = {
        let state = OSAllocatedUnfairLockAppState>(initialState: .init())
        return Self(
            getAppState: { state.withLock(\.self) },
            setAppState: { value in state.withLock { $0 = value } }
        )
    }()

    public static let testValue = Self(
        getAppState: { .init() },
        setAppState: { _ in }
    )
}


let someValue = appStateClient.withLock(\.someValue)


Task {
    for await value in appStateClient.withLock(\.someValueSubject.values) {
        
    }
}

これらの変更により

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

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

実装例

https://github.com/Kyome22/Telescopure

https://github.com/Kyome22/ShiftWindow

具体的な実装方法

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

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

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

Package.swift


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: [],
    targets: [
        .target(
            name: "DataSource",
            swiftSettings: swiftSettings
        ),
        .target(
            name: "Model",
            dependencies: [
                "DataSource",
            ],
            swiftSettings: swiftSettings
        ),
        .target(
            name: "UserInterface",
            dependencies: [
                "DataSource",
                "Model",
            ],
            resources: [.process("Resources")],
            swiftSettings: swiftSettings
        ),
        .testTarget(
            name: "ModelTests",
            dependencies: [
                "DataSource",
                "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 
    public var height: Double 

    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 testDependencyD: 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

import Combine

public struct AppState: Sendable {
    public var hasAlreadyTutorial
}

6. AppStateClient.swift の実装

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

LocalPackage/Sources/DataSource/Dependencies/AppStateClient.swift

import os

public struct AppStateClient: DependencyClient {
    var getAppState: @Sendable () -> AppState
    var setAppState: @Sendable (AppState) -> Void

    public func withLockR: Sendable>(_ body: @Sendable (inout AppState) throws -> R) rethrows -> R {
        var state = getAppState()
        let result = try body(&state)
        setAppState(state)
        return result
    }

    public static let liveValue: Self = {
        let state = OSAllocatedUnfairLockAppState>(initialState: .init())
        return Self(
            getAppState: { state.withLock(\.self) },
            setAppState: { value in state.withLock { $0 = value } }
        )
    }()

    public static let testValue = Self(
        getAppState: { .init() },
        setAppState: { _ in }
    )
}

Model レイヤーの実装

1. AppDependencies.swift の実装

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

LocalPackage/Sources/Model/AppDependencies.swift

import DataSource
import SwiftUI

public final class AppDependencies: Sendable {
    public let appStateClient: AppStateClient
    public let userDefaultsClient: UserDefaultsClient

    public nonisolated init(
        appStateClient: AppStateClient = .liveValue,
        userDefaultsClient: UserDefaultsClient = .liveValue
    ) {
        self.appStateClient = appStateClient
        self.userDefaultsClient = userDefaultsClient
    }

    
    static let shared = AppDependencies()
}

public extension EnvironmentValues {
    @Entry 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 var 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から構築する

4. 一般的な Store の実装

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

LocalPackage/Sources/Model/Stores/PersonBMI.swift

import Foundation
import DataSource
import Observation

@MainActor @Observable public final class PersonBMI {
    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 init(
        _ appDependencies: AppDependencies,
        person: Person = .empty,
        calculatedBMI: Double = .zero,
        isPresentedTutorial: Bool = false
    ) {
        self.userDefaultsRepository = .init(appDependencies.userDefaultsClient)
        self.bmiService = .init(appDependencies)
        self.appStateClient = appDependencies.appStateClient
        self.person = person
        self.calculatedBMI = calculatedBMI
        self.isPresentedTutorial = isPresentedTutorial
    }

    public func send(_ action: Action) {
        switch action {
        case .onAppear:
            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 onAppear
        case calculateButtonTapped
        case saveButtonTapped
    }
}

コーディングルール

  • プロパティは全てinitの引数で渡せるようにする(structの memberwise initializer と同様)
  • プロパティにデフォルト値を渡したい場合は定義時ではなくinitでデフォルト引数をもらうようにする
  • Actioncaseの命名規則

    • SwiftUI のイベント名は基本そのまま用いる
      case onAppear
      case onDisappear
      case onTapGesture
      case onChangeSomeValue 
      
    • ユーザーの行動がベースの場合は 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)
        

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算出") {
                    store.send(.calculateButtonTapped)
                }
                .buttonStyle(.borderedProminent)
                Button("保存") {
                    store.send(.saveButtonTapped)
                }
                .buttonStyle(.bordered)
            }
        }
        .onAppear {
            store.send(.onAppear)
        }
        .alert("チュートリアル", isPresented: $store.isPresentedTutorial) {
            Button("OK") {}
        }
    }
}

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

コーディングルール

  • Store はstoreという名前で定義する
    • ForEach などで取得するときも同じくstoreと名づける
  • イベントはstore.send(Action)を用いて Store に伝達する
  • 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 {
    
    @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_onAppear_保存されたデータが復元される() {
        let sut = Person(.testDependency(
            userDefaultsClient: testDependency(of: UserDefaultsClient.self) {
            $0$.data = { _ in
                return try! JSONEncoder().encode(Person(name: "テスト", weight: 70.0, height: 175.0))
            }
        }
        ))
        sut.send(.onAppear)
        #expect(sut.person == Person(name: "テスト", weight: 70.0, height: 175.0))
    }

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

    @MainActor @Test
    func send_saveButtonTapped_データが保存される() {
        var savedData = OSAllocatedUnfairLockData?>(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)
        )
        sut.send(.saveButtonTapped)
        #expect(savedData.withLock(\.self) != nil)
    }
}

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

コーディングルール

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

応用的な実装

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

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

子 Store の実装:

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

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

    public func send(_ action: Action) {
        self.action(action)

        switch action {
        case .closeButtonTapped:
            break
        }
    }

    public enum Action {
        case closeButtonTapped
    }
}

親 Store の実装:

@MainActor @Observable public final class Parent {
    public var child: Child?

    public init(_ appDependencies: AppDependencies) {}

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

        
        case .child(.closeButtonTapped):
            child = nil

        case .child:
            break
        }
    }

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

ナビゲーション

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

Path 定義を持つ Store の実装:

@MainActor @Observable public final class Fruits {
    public var path: [Path]
    public var bananas: [Banana]

    public init(_ appDependencies: AppDependencies, path: [Path] = [], bananas: [Banana] = []) {  }

    public func send(_ 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 .banana:
            break

        case .apple:
            break
        }
    }

    public enum Action {
        case appleButtonTapped(AppDependencies) 
        case bananaButtonTapped(Banana) 
        case apple(Apple.Action)
        case banana(Banana.Action)
    }

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

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

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

        
        var id: Int {
            switch self {
            case let .apple(value):
                Int(bitPattern: ObjectIdentifier(value))
            case let .banana(value):
                Int(bitPattern: ObjectIdentifier(value))
            }
        }
    }
}

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") {
                    store.send(.appleButtonTapped(appDependencies))
                }
                ForEach(store.bananas) { store in
                    Button("Banana: \(store.id)") {
                        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 アプリ開発における課題解決の一助となり、より良いアプリケーション開発の実現に貢献できることを期待しています。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -