🏃‍♂️

SwiftLee方式のDIをすることでTCAのEnvironmentバケツリレーをやめたい話

2022/05/07に公開

はじめに

  • SwiftLee方式のDIが去年公開されていた
    • 簡単で良さそう
      • いわゆるOSSのDIコンテナライブラリを使わないで済む
    • これはいくつかの課題を解決するのではなかろうか...
      • RxSwiftとかThe Composable Architecture(TCA)のスケジューラ
        • Rxのことは今回は触れない
        • TCA
          • バケツリレーがひどいので解決できるかも

https://twitter.com/imk2o/status/1520624198611517440

今回の記事ではTCAのスケジューラとAPIClient的なものをEnvironment内部でSwiftLee方式を使ってテストを書く。

TCAのstateやactionはもちろん合成されてないといけないので木構造となっている必要がある。でもそもそもEnvironmentは共通のものが使えればそれでいい。という前提です。

TCAでのEnvironmentのDependencyバケツリレー

  • ツリー型にViewとStoreを連ねていく
    • 孫しか使わないDependencyもRootはもちろん、孫の親もInjectionする
      • つまり機能追加するときに孫のDependencyの追加が親を辿って変更するの大変!!!
        • プレビュー用のViewにも変更が必要
        • テストコードでも変更が必要
  • Detailは直接WebAPIを使わないが、ModalやDetailSubのためにEnvironmentにWebAPIを持っている

「え?いや、そんなんだったらもっと簡単な方法あるんじゃないの?シングルトンでその場で共通のやつ渡せばいいってことでしょ?」「その簡単な方法の一つがSwiftLeeのDIで行けるってコト...?」という感じでしょうか。

SwiftLee方式のDIについての簡単な説明

  • 概要
    • プロパティラッパー使ってるのでアノテーション使う
      • @Injected(キー) var インスタンス: プロトコル
        • @Injected(\.networkProvider) var networkProvider: NetworkProviding
        • @Injected(\.scheduler) var scheduler: Scheduler
    • キーのためにkeyPathを使い、extensionでキーを用意する方法も書かれている
      • 何をキーとしたいのかを明確にしている
      • Injectedは所詮はグローバルな値の詰め合わせなので、もしキーが何でもありならカオスなわけよ
    • コードが短いのが良い
      • コードが短くOSS使わないDIは良い
      • コードが短いので他の方法に差し替えもしやすい
  • つまづきそうなところ
    • プロパティラッパーなのでセッターからsetできるが、分かってないと危険
      • プロパティだけの変更のつもりがグローバルに変更される
        • テストコードの経路でプロパティに代入されてる箇所があれば、事前にテスト用に切り替えていてもさらに本番用に切り替わる
          • object.networkProvider = NetworkProvider()
            • 内部がInjectedなら他の全部も変わる

サンプルコードと動作

画面構成

  • Main画面
    • ボタン"tap me!"が真ん中にあってDetail画面にpush遷移する
  • Detail画面
    • ボタン"Detail"が真ん中にあってModal画面をモーダル表示する
  • Modal画面
    • WebAPI
      • WebAPIのNumberFactAPIを呼び出して文字列を結果として取得
      • テスト時には実際の通信はせず文字列の結果を取得
    • スケジューラ
      • スケジューラで0.3秒くらいdebounceさせる
        • 本来だったらmainじゃないスケジューラのほうが良いかもしれないが!今回は知らん!
      • スケジューラでmainスレッドに変更する

Main

Detail

遷移と依存

Main -> Detail -> Modal

ModalにはWebAPIとスケジューラが依存していて、その画面に行き着くためにMainとDetailにも同じものがEnvironmentで用意したくなる。なぜなら画面が増えてMain -> Modal2とかになったとき、同じスケジューラとWebAPIを渡したくなるかもしれないから。

実際のコード

SwiftLee DI
// MARK: - InjectionKey

/// DIする対象に対してのKeyを抽象化する。
/// TestDoubleなものに切り替えられればそれをstaticに保存しており、切り替えられたものを利用する。
/// 例えば次のような準拠構造になる。
/// InjectionKey
///   - NetworkProviderKey
///   - DBProviderKey
///   - SchedulerProviderKey
public protocol InjectionKey {

    /// Valueの型。準拠する側で決めてくれよな!
    associatedtype Value

    /// このInjectionKeyは現在の唯一の値(Value型)をstaticに返す。
    /// デフォルト値とも言えるが、書き換えられるのでcurrentValueとしているんだろうか(書き換えられたら元のデフォルトに戻せない)。
    static var currentValue: Self.Value { get set }
}

// MARK: - InjectedValues

/// 注入された依存物のアクセスを抽象化する。
/// 基本的にはこれがkeyで管理されたグローバル変数のDictionaryのように振る舞うだけの話。
/// SwiftのpropertyWrapperアノテーションはgetterとsetterによってそれを隠蔽化できる。
struct InjectedValues {

    /// This is only used as an accessor to the computed properties within extensions of `InjectedValues`.
    /// staticなメソッドを理容師た場合にこのインスタンスを利用する。
    private static var current = InjectedValues()

    /// プロパティcurrentValueを持つKey型を引数にし、currentValueのgetter, setterとして振る舞いをさせる。
    /// extension InjectedValuesのプロパティから利用する。
    static subscript<K>(key: K.Type) -> K.Value where K : InjectionKey {
        get { key.currentValue }
        set { key.currentValue = newValue }
    }

    /// A static subscript accessor for updating and references dependencies directly.
    /// [KeyPath]でアクセスする際にsetterとgetterが利用される。
    /// つまりDIする際と、利用する際の2つ。
    static subscript<T>(_ keyPath: WritableKeyPath<InjectedValues, T>) -> T {
        get { current[keyPath: keyPath] }
        set { current[keyPath: keyPath] = newValue }
    }
}

// MARK: - Property Wrapper

@propertyWrapper
public struct Injected<T> {

    /// StaticなInjectedValuesにアクセスするためのkeyPathを定義する
    private let keyPath: WritableKeyPath<InjectedValues, T>

    /// @propertyWrapperの必須プロパティ。
    /// StaticにInjectedValuesにアクセスする。
    public var wrappedValue: T {
        get { InjectedValues[keyPath] }
        set { InjectedValues[keyPath] = newValue }
    }

    init(_ keyPath: WritableKeyPath<InjectedValues, T>) {
        self.keyPath = keyPath
    }
}
SwiftLee DIを利用するための型
import Combine

// MARK: - MainScheduler

public struct SchedulerKey: InjectionKey {
    public typealias Value = Scheduler
    public static var currentValue: Scheduler = ProductionScheduler()
}

public protocol Scheduler {
    var main: AnySchedulerOf<DispatchQueue> { get }
}

public struct ProductionScheduler: Scheduler {
    public var main: AnySchedulerOf<DispatchQueue>  {
        DispatchQueue.main.eraseToAnyScheduler()
    }
}

// MARK: - network providing

protocol NetworkProviding {
    var fetch: (Int) -> Effect<String, Error> { get }
}

struct NetworkProvider: NetworkProviding {
    var fetch: (Int) -> Effect<String, Swift.Error>

    static let live = Self(
        fetch: { number in
            return Effect.task {
                await Self.fetch(number)
            }
            .setFailureType(to: Error.self)
            .eraseToEffect()
        }
    )

    static func fetch(_ number: Int) async -> String {
        do {
          let (data, _) = try await URLSession.shared
            .data(from: URL(string: "https://numbersapi.com/\(number)/trivia")!)
          return String(decoding: data, as: UTF8.self)
        } catch {
          try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
          return "\(number) is a good number Brent"
        }
    }
}
ViewとStoreを含むコード
import SwiftUI
import ComposableArchitecture

enum MainFeature {
    struct State {
        var detail: DetailFeature.State
    }

    enum Action {
        case detailAction(DetailFeature.Action)
    }

    struct Environment {}

    static let reducer = Reducer<State, Action, Environment>.combine(
        DetailFeature.reducer.pullback(
            state: \.detail,
            action: /MainFeature.Action.detailAction,
            environment: { _ in
                DetailFeature.Environment()
            }
        ),
        Reducer { state, action, environment in
            return .none
        }
    )

    struct View: SwiftUI.View {
        let store: Store<State, Action>

        var body: some SwiftUI.View {
            NavigationView {
                NavigationLink(
                    destination: DetailFeature.View(
                        store: store.scope(
                            state: \.detail,
                            action: {
                                MainFeature.Action.detailAction($0)
                            }
                        )
                    )
                ) {
                    Text("tap me!")
                }
            }
        }
    }
}

enum DetailFeature {
    struct State: Equatable {
        var modal: ModalFeature.State
    }

    enum Action {
        case modalAction(ModalFeature.Action)
        case onTapDetail
    }

    struct Environment {}

    static let reducer = Reducer<State, Action, Environment>.combine(
        ModalFeature.reducer.pullback(
            state: \.modal,
            action: /DetailFeature.Action.modalAction,
            environment: { _ in ModalFeature.Environment() }
        ),
        Reducer { state, action, environment in
            switch action {
            case .onTapDetail:
                state.modal.isPresented.toggle()
                return .none
            case .modalAction:
                return .none
            }
        }
    )

    struct View: SwiftUI.View {
        @SwiftUI.Environment(\.presentationMode) var presentationMode

        let store: Store<State, Action>

        var body: some SwiftUI.View {
            WithViewStore(store) { viewStore in
                Button {
                    viewStore.send(.onTapDetail)
                } label: {
                    Text("Detail")
                }
                .sheet(
                    isPresented: viewStore.binding(
                        get: { $0.modal.isPresented },
                        send: Action.modalAction(ModalFeature.Action.togglePresent)
                    )
                ) {
                    ModalFeature.View(
                        store: store.scope(
                            state: \.modal,
                            action: {
                                DetailFeature.Action.modalAction($0)
                            }
                        )
                    )
                }
            }
        }
    }
}

enum ModalFeature {

    struct State: Equatable {
        var isPresented = false
        var number = 0
        var fact = ""
    }

    enum Action: Equatable {
        case togglePresent
        case onTapNumberFactButton
        case numberFactResponse(Result<String, NSError>)
    }

    struct Environment {
        @Injected(\.scheduler) var scheduler: Scheduler
        @Injected(\.networkProvider) var networkProvider: NetworkProviding
    }

    static let reducer = Reducer<State, Action, Environment> { state, action, environment in
        switch action {
        case .togglePresent:
            state.isPresented.toggle()
            return .none

        case .onTapNumberFactButton:
            struct FetchID: Hashable {}

            return environment.networkProvider.fetch(state.number)
                .debounce(id: FetchID(), for: 0.3, scheduler: environment.scheduler.main)
                .mapError { $0 as NSError }
                .receive(on: environment.scheduler.main)
                .catchToEffect(Action.numberFactResponse)

        case .numberFactResponse(let result):
            switch result {
            case .failure(let error):
                state.fact = error.localizedDescription
            case .success(let fact):
                state.fact = fact
            }

            return .none
        }
    }

    struct View: SwiftUI.View {
        let store: Store<State, Action>

        var body: some SwiftUI.View {
            WithViewStore(store) { viewStore in
                VStack {
                    Text(viewStore.fact)
                    Text("Number fact API")
                        .onTapGesture {

                        }
                        .onAppear {
                            viewStore.send(.onTapNumberFactButton)
                        }

                }
            }
        }
    }
}

テストコード

テスト用の型
// テキストにしたら紛らわしくて申し訳ないけども、このSchedulerは自前のprotocol Scheduler
struct MockedScheduler: Scheduler {
    var main: AnySchedulerOf<DispatchQueue>
}
struct MockedNetworkProvider: NetworkProviding {
    var fetch: (Int) -> Effect<String, Swift.Error>
}
ModalのReducerをテストするコード
import XCTest
import ComposableArchitecture

@testable import TCALeeDemo

class ModalFeatureTests: XCTestCase {
    let scheduler = DispatchQueue.test

    func testExample() {
        InjectedValues[\.scheduler] = MockedScheduler(main: scheduler.eraseToAnyScheduler() )
        InjectedValues[\.networkProvider] = MockedNetworkProvider(fetch: { n in
            Effect(value: "\(n)!!")
        })

        let store = TestStore(
            initialState: ModalFeature.State(
                number: 0
            ),
            reducer: ModalFeature.reducer,
            environment: ModalFeature.Environment()
        )

        store.send(.onTapNumberFactButton)

        scheduler.advance(by: 0.3)

        store.receive(.numberFactResponse(.success("0!!"))) {
            $0.fact = "0!!"
        }
    }
}

まとめ

  • TCAでもSwiftLeeのDIもできるのでバケツリレーを辞めれられる気がする
  • 他に良いやり方があればコメントとか別途ブログとかで教えて下さい

https://twitter.com/yimajo/status/1521402418092597248

Discussion