10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftのProperty Wrapperを利用してDIする

Posted at

SwiftでDependency Injectionをするとき、 Swinjectneedleのようなライブラリで実現する方法がありますが、SwiftLee先生がProperty Wrapperを利用する例を紹介していました。

結果的に少ないコードでDIができ、自分好みの方法だったのでアレンジしてみました。

DependencyInjection.swift
import Foundation

// Inspired by SwiftLee
// https://www.avanderlee.com/swift/dependency-injection/

// この型にextensionでDI定義を羅列する(後述)
public class DependencyObjects {
    init() {}
    
    public static subscript<T>(_ dependency: DependencyObject<T>) -> T {
        get { dependency.object }
        set { dependency.object = newValue }
    }
}

// DependencyObjects内に定義する、DIする型とオブジェクト生成
public final class DependencyObject<T>: DependencyObjects {
    // DependencyObjectにする型のイニシャライザでも参照できるよう、遅延生成する
    fileprivate lazy var object = builder()
    private let builder: () -> T
    
    public init(_ builder: @escaping () -> T) {
        self.builder = builder
        super.init()
    }
}

// 実際にDIをするためのProperty Wrapper
@propertyWrapper
public struct Injected<T> {
    private let object: T
    
    public init(_ dependency: DependencyObject<T>) {
        self.object = DependencyObjects[dependency]
    }

    public var wrappedValue: T { object }
}

使用例

拙作のアプリで実際に書いているコードで紹介したいと思います。
まず、DIに用いるオブジェクトを DependencyObjects に追加します。

Dependencies.swift
extension DependencyObjects {
    static let purchaseStore = DependencyObject<PurchaseStore> { DefaultPurchaseStore() }

    static let subscriptionUseCase = DependencyObject<SubscriptionUseCase> {
        DefaultSubscriptionUseCase(purchaseStore: DependencyObjects[.purchaseStore])
    }
}

ここで追加した purchaseStoresubscriptionUseCase がDIできるようになります。

次に課金(サブスクリプション)のオファーをする画面の実装ですが、以下のような構造になっています

  • SubscriptionOfferPresenter: 画面のビューモデル。 SubscriptionUseCase に依存
  • SubscriptionUseCase: 課金まわりの機能を提供するユースケース。実際の課金ロジックは PurchaseStore にある
  • PurchaseStore: StoreKitを通じて課金まわりの処理を行う

SubscriptionOfferPresenter

サブスクリプションまわりの機能を提供する SubscriptionUseCase をDIしています。

SubscriptionOfferPresenter.swift

// 説明のため、実際のコードより一部省略しています

@MainActor
final class SubscriptionOfferPresenter: ObservableObject {
    init() {
    }

    /// UseCaseをDI
    @Injected(.subscriptionUseCase)
    private var subscriptionUseCase: SubscriptionUseCase

    // MARK: - Output

    enum State {
        case unprepared
        case prepared(SubscriptionProduct)
        case subscribed
    }
    @Published private(set) var state: State = .unprepared
    @Published private(set) var hud: HUD = .none
    @Published private(set) var failureAlert: FailureAlert?

    /// 提供するサブスクリプション情報の読み込み等の準備
    func prepare() {
        Task {
            hud = .loading(true)
            do {
                let product = try await subscriptionUseCase.product(for: .premium)
                state = .prepared(product)
            } catch {
                guard !Task.isCancelled else { return }
                failureAlert = .init(error: error)
            }
            hud = .loading(false)
        }
    }

    /// サブスクリプションの購入    
    func subscribe() {
        guard case .prepared(let product) = state else {
            return
        }
        
        Task {
            hud = .processing(true)
            do {
                try await subscriptionUseCase.subscribe(product: product)
                state = .subscribed
                failureAlert = nil
            } catch {
                guard !Task.isCancelled else { return }
                if
                    let subscriptionError = error as? SubscriptionError,
                    case .cancelled = subscriptionError
                {
                    // キャンセルは無視
                } else {
                    failureAlert = .init(error: error)
                }
            }
            hud = .processing(false)
        }
    }
}

SubscriptionUseCase

PurchaseStore に対しても @Injected を使いたいところですが、依存関係逆転の原則(DIP)の関係にある と考えると、ここはイニシャライザを介して渡すのが良いのかなと思います。
上記 DependencyObjects の中で依存関係の解決しているので、この形でも利用時の手間はあまりないのかなと。

SubscriptionUseCase.swift

protocol SubscriptionProduct { ...省略... }
protocol SubscriptionUseCase: AnyObject { ...省略... }
enum SubscriptionError: Error { ...省略... }

extension Notification.Name {
    static let subscriptionDidUpdateNotification = Notification.Name("subscriptionDidUpdateNotification")
}

// MARK: - Implementation

final class DefaultSubscriptionUseCase: SubscriptionUseCase {
    init(purchaseStore: PurchaseStore) {
        self.purchaseStore = purchaseStore
    }

    private let purchaseStore: PurchaseStore

    /// 指定のplanに対するサブスクリプションを取得
    func product(for plan: SubscriptionPlan) async throws -> SubscriptionProduct {
        guard let product = try await purchaseStore.subscriptionProducts(for: [plan.productID]).first else {
            throw SubscriptionError.notFound(plan)
        }
        
        return product
    }
    
    /// サブスクリプションの購入
    func subscribe(product: SubscriptionProduct) async throws {
        guard try await purchaseStore.subscribe(product: product) else {
            throw SubscriptionError.cancelled
        }
        
        // サブスクリプションの更新を通知
        NotificationCenter.default
            .post(name: .subscriptionDidUpdateNotification, object: nil)
    }
}

Unit Test

ビューモデルのテストコードを書くときは、 DependencyObjectssubscript を通じてデフォルトのオブジェクトを入れ替えます。

SubscriptionOfferPresenterTests.swift
class SubscriptionOfferPresenterTests: XCTestCase {
    private var presenter: SubscriptionOfferPresenter!
    override func setUpWithError() throws {
        // ビューモデルをインスタンス化する前に、DIしているオブジェクトを入れ替えておく
        DependencyObjects[.subscriptionUseCase] = SubscriptionUseCaseStub()
        presenter = SubscriptionOfferPresenter()
    }

    func testPrepare() async throws {
        presenter.subscribe()
        // TODO: XCTAssert
    }
}

まとめ

ライブラリを使用しなくても比較的簡単にDIが導入でき、かつ依存オブジェクトの引き渡しが無い分、シンプルなコードになるのではないかと思います。
(アプリケーションの実装や挙動を主眼におきつつ、テスタブルにもしているという感じでしょうか)

一方で @Injected を導入した型はコードとしての独立性は低下するので、従来どおりイニシャライザで依存オブジェクトを引き渡す方が良いという見方も理解できます。

他にも「型パラメータインジェクション」というやり方もあります。
https://qiita.com/uhooi/items/16aa67b44e2614c8d7b9

プロジェクトの指向に合わせて、どういう形でDIを実現するのか考えることが大切ですね!

10
4
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
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?