🐾

Observation時代のSwiftUIベース個人開発アーキテクチャ

2024/11/15に公開

Swift 6 × SwiftUI × Observation の個人開発向けアーキテクチャが形になってきたのでまとめます。
The Composable Architecture、Clean Architecture、MVVMあたりのエッセンスを参考にしつつ、SwiftUIで提供されるAPIに寄り添うことを第一に考えて設計しています。

アーキテクチャを導入するモチベーション

特性 要求
実装容易性 機能追加の手順をテンプレート化し、自然と責務の分離が行える
テスト容易性 依存性の注入がしやすく、Unit Testにより広範囲の動作保証ができる
保守容易性 APIの破壊的変更に伴うマイグレーションコストを減らせる

アーキテクチャの要件

  • 個人開発向けのため、必要以上に複雑でないこと
  • Apple純正のFramework、swiftlang/apple OSSライブラリのみで実装できること
  • Xcode 16+、Swift 6、iOS 17+、macOS 14+

アーキテクチャの概要

依存の方向を一方向にするように心がけたレイヤードなアーキテクチャです。
DataLayerDomainPresentationの3つのレイヤーで構成されます。
レイヤーはローカルパッケージのモジュールとして提供します(いわゆるSwift Packageを用いたマルチモジュール構成を活用)。

ファイル構成
.
├── ProjectName
│   ├── Assets.xcassets
│   └── ProjectNameApp.swift
├── ProjectName.xcodeproj
└── ProjectNamePackages
    ├── Sources
    │   ├── DataLayer
    │   │   ├── Dependency/
    │   │   ├── Entity/
    │   │   └── Repository/
    │   ├── Domain
    │   │   ├── AppDelegate.swift
    │   │   ├── AppDependencies.swift
    │   │   ├── AppServices.swift
    │   │   ├── Service/
    │   │   └── ViewModel/
    │   └── Presentation
    │       ├── Resources/
    │       ├── Scene/
    │       └── View/
    └── Tests
        └── DomainTests/

メインのProjectには〇〇App.swiftだけを置き、実装の本体は全てローカルパッケージに閉じ込めます。

レイヤーごとにモジュールを分割することで、各レイヤーの責務を自然と分離できるようにします。また、実装容易性および保守容易性のため、アーキテクチャの実現のために特別なFrameworkを必要としません(つまりSwiftの言語機能だけで基本的には簡単に実装可能)。ただし、テスト容易性のため、SwiftUIのEnvironmentVluesを使って各View/ViewModelに依存性の注入を行えるようにします。

各レイヤーの役割

DataLayer

データベースの管理を担当します。

ディレクトリ 役割
Dependency ・管理下にない副作用を含むAPIを間接的に提供
・ServiceやViewModelでの依存注入時に差し替え可能にする
Entity ・取り扱うデータ型の定義
Repository ・データの読み書きを司る

Domain

ビジネスロジックを担当します。

ディレクトリ/特殊ファイル 役割
Service ・DependencyやRepositoryを使用してデータの加工・処理を司る
ViewModel ・Viewで表示すべきデータの手配
・ユーザーのアクションをServiceに伝達
AppDelegate.swift ・アプリのライフサイクルのイベントトリガー
・DependencyとServiceの保持
AppDependencies.swift ・Dependencyの保持とViewへのアクセス手段の提供
AppServices.swift ・Serviceの保持とViewへのアクセス手段の提供

主にテストはServiceViewModelのUnit Testを書くことになります。

Presentation

UIの提供を担当します。

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

依存性

レイヤー/Target 依存先
DataLayer 他に依存しない
Domain DataLayer
Presentation DataLayer、Domain
AppのTarget Domain、Presentation

テスト実装の指針

アーキテクチャを設計する上で重要なテスト実装の指針について軽く触れます。

  1. ハッピーパスが最長になるようテストケースを列挙する
  2. 管理下にない副作用を含むAPI以外はモックせず、なるべく実物を使う
  3. 原則DomainのUnit TestでPresentationの動作を保証する

管理下にない副作用を含むAPIとは?

  • FileManager.default.moveItem(at:to:)
  • UserDefaults.standard.set(_:forKey:)
  • NSApplication.shared.terminate(_:)
  • NSWorkspace.shared.open(_:)

上記のような、ファイルの読み書きや他プロセスの呼び出しのようなAPIのことです。実際の挙動の保証についてはアプリの管轄外と捉えます。そのため3rd Party製ライブラリのAPIなども場合によってはこの基準に含まれます。

実践例

https://github.com/Kyome22/ShiftWindow

具体的な実装

Package.swift

Package.swiftによって依存の関係性がはっきりわかるのがいいところですね。

// swift-tools-version: 6.0

import PackageDescription

let package = Package(
    name: "ProjectNamePackages",
    defaultLocalization: "en",
    platforms: [
        .macOS(.v14)
    ],
    products: [
        .library(
            name: "DataLayer",
            targets: ["DataLayer"]
        ),
        .library(
            name: "Domain",
            targets: ["Domain"]
        ),
        .library(
            name: "Presentation",
            targets: ["Presentation"]
        ),
    ],
    targets: [
        .target(
            name: "DataLayer",
        ),
        .target(
            name: "Domain",
            dependencies: [
                "DataLayer",
            ]
        ),
        .testTarget(
            name: "DomainTests",
            dependencies: [
                "DataLayer",
                "Domain",
            ]
        ),
        .target(
            name: "Presentation",
            dependencies: [
                "Domain"
            ]
        ),
    ]
)

DataLayer/Dependency

DependencyClientというprotocolを用意して、ボイラーテンプレートに従わせやすくします。
DependencyClientliveValuetestValuestaticなので、カスタムDependencyClientの利用側は常に1つのデータソースを使うことになります。

DependencyClient.swift
import Foundation

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
}

カスタムDependencyClientの具体例

UserDefaultsClient.swift
import Foundation

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

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

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

extension UserDefaults: @retroactive @unchecked Sendable {}

DataLayer/Repository

RepositoryDependencyを必要に応じて使ってデータの読み書きを行います。
Repository自体には状態を持たせません。
Repositoryletで取り扱うためにComputed Propertyのsetnonmutatingをつけます。

Repositoryの具体例

UserDefaultsRepository.swift
import Foundation

public struct UserDefaultsRepository: Sendable {
    private let userDefaultsClient: UserDefaultsClient

    public var somethingIsEnabled: Bool {
        get { userDefaultsClient.bool("somethingIsEnabled") }
        nonmutating set { userDefaultsClient.setBool(newValue, "somethingIsEnabled") }
    }

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

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

Domain/Service

ServiceDependencyRepositoryを必要に応じて使ってデータの加工や処理を行います。
また、Serviceは状態を持つことが許されます。ただし、アプリのライフサイクルの中で複数同じServiceが存在することは許容しません。複数コンテキスト向けの状態を制御したい場合は、1つのServiceの中でそれらのコンテキストを管理できるようにします(アプリのライフサイクルの中でServiceはシングルトンで存在させるということ)。

Serviceは複数箇所で利用され得るため、原則actorで定義します。

import DataLayer

public actor SomeService {
    private let xxxClient: XxxClient
    private let yyyRepository: YyyRepository

    public init(
        _ xxxClient: XxxClient,
        _ yyyClient: YyyClient
    ) {
        self.xxxClient = xxxClient
        self.yyyRepository = .init(yyyClient)
    }

    func doSomething() {
        // xxxClientやyyyRepositoryを使った処理
        // Actorで守る必要がない場合はもちろんnonisolatedをつけても良い
    }
}

Domain/AppDependencies

AppDependenciesは全てのDependencyを保持し、必要な場所に配る役割を持ちます。
AppDependencies自体はAppDelegateで持つことにします。

AppDependencies.swift
import DataLayer
import SwiftUI

public final class AppDependencies: Sendable {
    public let xxxClient: XxxClient
    public let yyyClient: YyyClient
    public let zzzClient: ZzzClient

    public nonisolated init(
        xxxClient: XxxClient = .liveValue
        yyyClient: YyyClient = .liveValue
        zzzClient: ZzzClient = .liveValue
    ) {
        self.xxxClient = XxxClient
        self.yyyClient = YyyClient
        self.zzzClient = ZzzClient
    }
}

struct AppDependenciesKey: EnvironmentKey {
    static let defaultValue = AppDependencies(
        xxxClient: XxxClient = .testValue
        yyyClient: YyyClient = .testValue
        zzzClient: ZzzClient = .testValue
    )
}

public extension EnvironmentValues {
    var appDependencies: AppDependencies {
        get { self[AppDependenciesKey.self] }
        set { self[AppDependenciesKey.self] = newValue }
    }
}

Domain/AppServices

AppServicesは全てのServiceを保持し、必要な場所に配る役割を持ちます。
AppServices自体はAppDelegateで持つことにします。

AppServices.swift
import DataLayer
import SwiftUI

public final class AppServices: Sendable {
    public let xxxService: XxxService
    public let yyyService: YyyService
    public let zzzService: ZzzService

    public nonisolated init(appDependencies: AppDependencies) {
        xxxService = .init(appDependencies.aaaClient)
        yyyService = .init(appDependencies.bbbClient,
                           appDependencies.cccClient)
        zzzService = .init()
    }
}

struct AppServicesKey: EnvironmentKey {
    static let defaultValue = AppServices(appDependencies: AppDependenciesKey.defaultValue)
}

public extension EnvironmentValues {
    var appServices: AppServices {
        get { self[AppServicesKey.self] }
        set { self[AppServicesKey.self] = newValue }
    }
}

Domain/AppDelegate.swift

AppDelegateはアプリのライフサイクルのイベントトリガーと、AppDependenciesAppServicesの保持をします。

AppDelegate.swift
import AppKit
import DataLayer

public final class AppDelegate: NSObject, NSApplicationDelegate {
    public let appDependencies = AppDependencies()
    public let appServices: AppServices

    public override init() {
        appServices = .init(appDependencies: appDependencies)
        super.init()
    }

    public func applicationDidFinishLaunching(_ notification: Notification) {
        Task {
            // Service間でのデータフローはここで連携させる(アプリのライフサイクルと一致しているなら)
            for await value in await appServices.xxxService.someValuesStream() {
                await appServices.yyyService.doSomething(value: value)
            }
        }
    }

    public func applicationWillTerminate(_ notification: Notification) {
        // アプリ終了直前にやりたいことがあればここで実行する
        appDependencies.zzzClient.doSomethingBeforeTermination()
    }
}

Target/App

AppではPresentationSceneAppDependenciesAppServicesを流します。
こうすることでSceneの中のViewの初期化でDependencyServiceを渡せます。

App
import Domain
import Presentation
import SwiftUI

@main
struct ProjectNameApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        SomeScene()
            .environment(\.appDependencies, appDelegate.appDependencies)
            .environment(\.appServices, appDelegate.appServices)
    }
}

Domain/ViewModel

Viewで表示すべきデータを手配したり、ユーザーのアクションをServiceに伝達したりします。
原則@MainActorをつけます。

SomeViewModel
import DataLayer
import Foundation
import Observation

@MainActor @Observable public final class SomeViewModel {
    private let xxxClient: XxxClient
    private let yyyRepository: YyyRepository
    private let zzzService: ZzzService

    public var yyyIsEnabled: Bool {
        didSet { yyyRepository.toggle(yyyIsEnabled) }
    }

    public init(
        _ xxxClient: XxxClient,
        _ yyyClient: YyyClient,
        _ zzzService: ZzzService
    ) {
        self.xxxClient = xxxClient
        self.yyyRepository = .init(yyyClient)
        self.zzzService = zzzService
        yyyRepository = yyyRepository.isEnabled
    }

    public func onAppear() {
        if xxxClient.checkStatus() {
            zzzService.prepareSomething()
        }
    }
}

Presentation/View

Observable classのデータをインターフェースに反映して、イベントがあればハンドリングして流します。

SomeView
import DataLayer
import Domain
import SwiftUI

struct SomeView: View {
    @State private var viewModel: SomeViewModel

    init(
        xxxClient: XxxClient,
        yyyClient: YyyClient,
        zzzService: ZzzService
    ) {
        viewModel = .init(xxxClient, yyyClient, zzzService)
    }

    var body: some View {
        VStack {
            Toggle(isOn: $viewModel.yyyIsEnabled) {
                Text("Yyy")
            }
        }
        .onAppear {
            viewModel.onAppear()
        }
    }
}

#Preview {
    SomeView(
        xxxClient: .testValue,
        yyyClient: .testValue,
        zzzService: .init(.testValue)
    )
}

テストの実装

Dependencyに必要なモックを注入してテストを書きます。

import DataLayer
import os
import XCTest

@testable import Domain

final class XxxServiceTests: XCTestCase {
    func test_doSomething() async {
        let count = OSAllocatedUnfairLock(initialState: 0)
        let yyyClient = testDependency(of: YyyClient.self) {
            $0.open = { _ in count.withLock { $0 += 1  } }
        }
        let sut = XxxService(yyyClient)
        await sut.doSomething()
        let actual = count.withLock { $0 }
        XCTAssertEqual(actual, 1)
    }
}

Discussion