Observation時代のSwiftUIベース個人開発アーキテクチャ
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+
アーキテクチャの概要
依存の方向を一方向にするように心がけたレイヤードなアーキテクチャです。
DataLayer
、Domain
、Presentation
の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へのアクセス手段の提供 |
主にテストはService
とViewModel
の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 |
テスト実装の指針
アーキテクチャを設計する上で重要なテスト実装の指針について軽く触れます。
- ハッピーパスが最長になるようテストケースを列挙する
- 管理下にない副作用を含むAPI以外はモックせず、なるべく実物を使う
- 原則DomainのUnit TestでPresentationの動作を保証する
管理下にない副作用を含むAPIとは?
FileManager.default.moveItem(at:to:)
UserDefaults.standard.set(_:forKey:)
NSApplication.shared.terminate(_:)
NSWorkspace.shared.open(_:)
上記のような、ファイルの読み書きや他プロセスの呼び出しのようなAPIのことです。実際の挙動の保証についてはアプリの管轄外と捉えます。そのため3rd Party製ライブラリのAPIなども場合によってはこの基準に含まれます。
実践例
具体的な実装
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
を用意して、ボイラーテンプレートに従わせやすくします。
DependencyClient
のliveValue
とtestValue
はstatic
なので、カスタムDependencyClient
の利用側は常に1つのデータソースを使うことになります。
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
の具体例
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
Repository
はDependency
を必要に応じて使ってデータの読み書きを行います。
Repository
自体には状態を持たせません。
Repository
をlet
で取り扱うためにComputed Propertyのset
にnonmutating
をつけます。
Repository
の具体例
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
Service
はDependency
とRepository
を必要に応じて使ってデータの加工や処理を行います。
また、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
で持つことにします。
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
で持つことにします。
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
はアプリのライフサイクルのイベントトリガーと、AppDependencies
とAppServices
の保持をします。
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
ではPresentation
のScene
にAppDependencies
とAppServices
を流します。
こうすることでScene
の中のViewの初期化でDependency
やService
を渡せます。
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
をつけます。
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
のデータをインターフェースに反映して、イベントがあればハンドリングして流します。
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