はじめに
iOSアプリ開発において、Singletonに依存した設計が蔓延してしまうことはないでしょうか?
便利な反面、テスト性の低下や変更耐性の低下、依存の隠蔽など多くの問題を引き起こします。
本記事では、The Composable Architecture (TCA) の DependencyValues
の設計思想をベースに、依存を構造化し、既存のSingletonを段階的に置き換えていく方法を紹介します。
なぜSingleton地獄になるのか?
1. Swiftには標準のDI機構が存在しない
Swiftには、KotlinのHiltのような依存性注入(DI)フレームワークが存在しません。基本的には init()
による手動注入が求められます。
-
ライフサイクルに応じたスコープ制御も自前で実装する必要がある
-
Viewの入れ子構造により注入の伝播が煩雑になる
結果として「手軽なSingletonで済ませる」という判断がなされがちです。
2. 設計コストゼロで使える誘惑
Class.shared
のように、どこからでも簡単にアクセスできるため、依存設計を省略しやすくなります。その結果、以下のような問題が発生します。
- モック化が困難
- 依存関係がコード上に現れない
- 状態がグローバルに共有され、予期せぬ副作用が生じる
Singletonの問題点まとめ
問題点 | 説明 |
---|---|
依存が暗黙的 | どのクラスが何に依存しているか分からず、テストも難しい |
テスト困難 | 状態が共有されるため、並列テストやモックの差し替えが難しい |
グローバル状態の汚染 | スレッド不安全、状態の競合、副作用の伝播 |
密結合 | 型が固定され差し替えや再利用が困難 |
TCAに学ぶ依存注入の考え方
TCAに組み込まれているswift-dependenciesライブラリでは、DependencyValues
を使って依存注入を構造化します。
このライブラリを使えば、下記のような簡単な構文で依存を参照できます。
@Dependency(\.apiClient) var apiClient
また、.withDependencies {}
を使えば、一時的にモックなどに差し替えることも可能です。
withDependencies {
$0.apiClient = MockApiClient()
} operation: {
// テスト実行
}
DependencyValuesで得られる主なメリット
-
依存の明示:コード上に明示的に依存が現れる(
@Dependency
) -
テスト容易:一時的にモックへ差し替えられる(
withDependencies
) -
スコープ制御:
Task
ごとに異なる依存を持たせられる - 段階導入可能:既存のSingletonから柔軟に移行できる
DependencyValues の自作方法
TCAの DependencyValues の考え方を模倣し、自前で実装することで、外部ライブラリに依存せずにDIの構造を導入できます。これにより、既存のコードベースにも柔軟に適用できます。
以下の記事が大変参考になりました!
この自作版は、DependencyValues
の仕組みを簡易的に再現するものです。
1. DependencyKey
の定義
protocol DependencyKey {
// 依存の型
associatedtype Value: Sendable
// 本番用のデフォルト依存(登録がなければこれが使われる)
static var liveValue: Value { get }
}
2. DependencyValues
の定義
struct DependencyValues: Sendable {
// 現在のコンテキストを限定した依存セット。withDependency内で差し替えられる
@TaskLocal static var current = DependencyValues()
// 実際の依存を保持するDictionary
private var storage: [ObjectIdentifier: any Sendable] = [:]
// 依存を取得・設定するsubscript
subscript<K>(key: K.Type) -> K.Value where K : DependencyKey {
get {
// 指定Keyに対応する依存が保存されていればそれを取り出す
guard let base = storage[ObjectIdentifier(key)],
let dependency = base as? K.Value else {
// なければそのキーのliveValue(デフォルト)を返す
return key.liveValue
}
return dependency
}
set {
// 指定キーに新たな依存を保存
storage[ObjectIdentifier(key)] = newValue
}
}
}
// 一時的に依存を差し替えて操作を実行するメソッド
extension DependencyValues {
// 同期処理での依存差し替え
static func withDependency<R>(_ setDependency: (inout DependencyValues) -> Void, operation: () -> R) -> R {
var currentDependencyValue = DependencyValues.current
// 差し替えを適用
setDependency(¤tDependencyValue)
// 差し替えた依存でoperationを実行(Taskローカルで一時的に切り替え)
return DependencyValues.$current.withValue(currentDependencyValue) { operation() }
}
// 非同期処理での依存差し替え
static func withDependency<R>(_ setDependency: (inout DependencyValues) -> Void, operation: () async -> R) async -> R {
var currentDependencyValue = DependencyValues.current
setDependency(¤tDependencyValue)
return await DependencyValues.$current.withValue(currentDependencyValue) { await operation() }
}
}
3. @Dependency
プロパティラッパーの定義
@propertyWrapper
struct Dependency<Value> {
// ラッパー越しに依存を取得可能に(@Dependency(\.xxx) var xxx で使える)
var wrappedValue: Value { DependencyValues.current[keyPath: self.keyPath] }
// 参照するKeyPath(例: \.apiClient)
private let keyPath: KeyPath<DependencyValues, Value> & Sendable
init(_ keyPath: KeyPath<DependencyValues, Value> & Sendable) {
self.keyPath = keyPath
}
}
使用例
1. 本番用の依存クライアント
struct LoggerClient: Sendable {
let log: @Sendable (String) -> Void
}
extension LoggerClient {
static let live = LoggerClient { message in
print("[LIVE] \(message)")
}
static let mock = LoggerClient { message in
print("[MOCK] \(message)")
}
}
enum LoggerKey: DependencyKey {
static let liveValue: LoggerClient = .live
}
extension DependencyValues {
var logger: LoggerClient {
get { self[LoggerKey.self] }
set { self[LoggerKey.self] = newValue }
}
}
2. 使用箇所での注入
struct LoggerUser {
@Dependency(\.logger) var logger
func run() {
logger.log("実行しました")
}
}
let user = LoggerUser()
user.run()
// -> [LIVE] 実行しました
3. モックでのテスト
let user = DependencyValues.withDependency {
$0.logger = .mock
} operation: {
LoggerUser()
}
user.run()
// -> [MOCK] 実行しました
Sendable 対応と構造化の注意点
DependencyValues
をTCAのようにスレッドセーフ・並列テスト可能に保つには、依存にSendableを意識した設計が必要です。
- 依存は基本
struct
かactor
- クロージャには
@Sendable
明示 - どうしても
class
が必要なら@unchecked Sendable
を使う(非推奨)
これらの注意点は、Swift 6 移行のために必要なアプローチともいえます。
Singleton -> DependencyValues
移行ステップ
Step1. Singletonクラスをstructでラップして置き換える
// 既存コード(Before)
import Foundation
final class UserDefaultsManager {
static let shared = UserDefaultsManager()
private init() {}
func save(_ value: String, for key: String) {
UserDefaults.standard.set(value, forKey: key)
}
func load(for key: String) -> String? {
UserDefaults.standard.string(forKey: key)
}
}
// 書き替え後コード(After)
struct UserDefaultsClient {
let save: (String, String) -> Void
let load: (String) -> String?
}
extension UserDefaultsClient {
static let live = UserDefaultsClient(
save: UserDefaultsManager.shared.save,
load: UserDefaultsManager.shared.load
)
}
// sharedを隠蔽
private final class UserDefaultsManager {
static let shared = UserDefaultsManager()
private init() {}
func save(_ value: String, for key: String) { }
func load(for key: String) -> String? { "" }
}
// Keyを設定して使用箇所で@Dependency宣言する
enum UserDefaultsClientKey: DependencyKey {
static let liveValue = UserDefaultsClient.live
}
extension DependencyValues {
var userDefaults: UserDefaultsClient {
get { self[UserDefaultsClientKey.self] }
set { self[UserDefaultsClientKey.self] = newValue }
}
}
Step2. テスト時にモックを注入できるように書き換える
extension UserDefaultsClient {
static let mock = UserDefaultsClient(
save: { key, value in
print("[MOCK] save \(key): \(value)")
},
load: { key in
print("[MOCK] load \(key)")
return "mocked_value"
}
)
}
Step3. shared
インスタンスをactor
やstruct
にリファクタリングする(Sendable対応)
struct UserDefaultsClient {
// クロージャをSendableにする
let save: @Sendable (String, String) -> Void
let load: @Sendable (String) -> String?
}
extension UserDefaultsClient {
// sharedを削除
static let live = Self(
save: { key, value in
UserDefaults.standard.set(value, forKey: key)
},
load: { key in
UserDefaults.standard.string(forKey: key)
}
)
}
これで完全にDependencyValues
に移行でき、Swift 6にも対応できるようになりました。
実際にはもっと色々な状態をSingletonに保持しているかと思うので、簡単にはいかないと思いますが、頑張って置き換えていきましょう。
徐々に差し替え可能という点がDependencyValues
の最大のメリットです。
まとめ
- Swiftは言語的にDIを支援しておらず、Singletonに依存しやすい構造になっている
-
DependencyValues
を用いれば、既存の設計を壊さずに段階的な移行が可能 -
@Dependency
やwithDependency
によって、テスト可能・スコープ制御可能な構造を自然に導入できる
もし現場でSingletonの多用に悩んでいる方がいれば、DependencyValues
から始めてみることをおすすめします。
静的アクセスの便利さを維持しつつ、疎結合・スケーラブル・テスト可能な構造を段階的に実現できます。