新しいアーキテクチャが必要な背景
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 │ ← データアクセスとインフラ抽象化
└───────────────────┘
DataSource
、Model
、UserInterface
の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 層で定義したenum
やstruct
から画像や文言を扱う場合は Extensions に拡張を生やしてリソースにアクセスします。
Model(旧 Domain)
ビジネスロジックと状態管理を担当します。
ディレクトリ/特殊ファイル | 役割 |
---|---|
Extensions | ・拡張の実装 |
Services | ・Dependency や Repository を使用してデータの加工・処理を行う |
Stores | ・ビジネスロジックの実装 ・View で表示するデータの手配 ・イベントやユーザーのアクションをハンドリング |
AppDelegate.swift (任意) | ・アプリのライフサイクルのイベントトリガー |
AppDependencies.swift | ・Dependencies のシングルトン保持と View へのアクセス手段の提供 |
テストはServices
とStores
の 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 管理
という改善がなされています。
実装例
具体的な実装方法
シンプルな 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 の実装
struct
やenum
などでデータ構造を定義します。必要に応じて 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
でデフォルト引数をもらうようにする -
Action
のcase
の命名規則- 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)
-
Button:
- SwiftUI のイベント名は基本そのまま用いる
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
と名づける
- ForEach などで取得するときも同じく
- イベントは
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 アプリ開発における課題解決の一助となり、より良いアプリケーション開発の実現に貢献できることを期待しています。
Views: 0