SwiftでDependency Injectionをするとき、 Swinjectやneedleのようなライブラリで実現する方法がありますが、SwiftLee先生がProperty Wrapperを利用する例を紹介していました。
結果的に少ないコードでDIができ、自分好みの方法だったのでアレンジしてみました。
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
に追加します。
extension DependencyObjects {
static let purchaseStore = DependencyObject<PurchaseStore> { DefaultPurchaseStore() }
static let subscriptionUseCase = DependencyObject<SubscriptionUseCase> {
DefaultSubscriptionUseCase(purchaseStore: DependencyObjects[.purchaseStore])
}
}
ここで追加した purchaseStore
と subscriptionUseCase
がDIできるようになります。
次に課金(サブスクリプション)のオファーをする画面の実装ですが、以下のような構造になっています
- SubscriptionOfferPresenter: 画面のビューモデル。
SubscriptionUseCase
に依存 - SubscriptionUseCase: 課金まわりの機能を提供するユースケース。実際の課金ロジックは
PurchaseStore
にある - PurchaseStore: StoreKitを通じて課金まわりの処理を行う
SubscriptionOfferPresenter
サブスクリプションまわりの機能を提供する SubscriptionUseCase
をDIしています。
// 説明のため、実際のコードより一部省略しています
@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
の中で依存関係の解決しているので、この形でも利用時の手間はあまりないのかなと。
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
ビューモデルのテストコードを書くときは、 DependencyObjects
の subscript
を通じてデフォルトのオブジェクトを入れ替えます。
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を実現するのか考えることが大切ですね!