0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Swift] TCAのDependencyValues風の依存注入でSingleton地獄から脱却する

Last updated at Posted at 2025-06-19

はじめに

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の定義

DependencyKey.swift
protocol DependencyKey {
    // 依存の型
    associatedtype Value: Sendable
    // 本番用のデフォルト依存(登録がなければこれが使われる)
    static var liveValue: Value { get }
}

2. DependencyValuesの定義

DependencyValues.swift
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(&currentDependencyValue)
        // 差し替えた依存で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(&currentDependencyValue)
        return await DependencyValues.$current.withValue(currentDependencyValue) { await operation() }
    }
}

3. @Dependencyプロパティラッパーの定義

Dependency.swift
@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. 本番用の依存クライアント

LoggerClient.swift
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. 使用箇所での注入

LoggerUser.swift
struct LoggerUser {
    @Dependency(\.logger) var logger

    func run() {
        logger.log("実行しました")
    }
}

let user = LoggerUser()
user.run()
// -> [LIVE] 実行しました

3. モックでのテスト

LoggerUserTests.swift
let user = DependencyValues.withDependency {
    $0.logger = .mock
} operation: {
    LoggerUser()
}

user.run()
// -> [MOCK] 実行しました

Sendable 対応と構造化の注意点

DependencyValuesをTCAのようにスレッドセーフ・並列テスト可能に保つには、依存にSendableを意識した設計が必要です。

  • 依存は基本structactor
  • クロージャには @Sendable 明示
  • どうしてもclassが必要なら@unchecked Sendableを使う(非推奨)

これらの注意点は、Swift 6 移行のために必要なアプローチともいえます。

Singleton -> DependencyValues移行ステップ

Step1. Singletonクラスをstructでラップして置き換える

UserDefaultsManager.swift
// 既存コード(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)
    }
}
UserDefaultsClient.swift
// 書き替え後コード(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. テスト時にモックを注入できるように書き換える

UserDefaultsClient.swift
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インスタンスをactorstructにリファクタリングする(Sendable対応)

UserDefaultsClient.swift
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を用いれば、既存の設計を壊さずに段階的な移行が可能
  • @DependencywithDependencyによって、テスト可能・スコープ制御可能な構造を自然に導入できる

もし現場でSingletonの多用に悩んでいる方がいれば、DependencyValuesから始めてみることをおすすめします。

静的アクセスの便利さを維持しつつ、疎結合・スケーラブル・テスト可能な構造を段階的に実現できます。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?