🏃♂️
SwiftLee方式のDIをすることでTCAのEnvironmentバケツリレーをやめたい話
はじめに
-
SwiftLee方式のDIが去年公開されていた
- 簡単で良さそう
- いわゆるOSSのDIコンテナライブラリを使わないで済む
- これはいくつかの課題を解決するのではなかろうか...
- RxSwiftとかThe Composable Architecture(TCA)のスケジューラ
- Rxのことは今回は触れない
- TCA
- バケツリレーがひどいので解決できるかも
- RxSwiftとかThe Composable Architecture(TCA)のスケジューラ
- 簡単で良さそう
今回の記事ではTCAのスケジューラとAPIClient的なものをEnvironment内部でSwiftLee方式を使ってテストを書く。
TCAのstateやactionはもちろん合成されてないといけないので木構造となっている必要がある。でもそもそもEnvironmentは共通のものが使えればそれでいい。という前提です。
TCAでのEnvironmentのDependencyバケツリレー
- ツリー型にViewとStoreを連ねていく
- 孫しか使わないDependencyもRootはもちろん、孫の親もInjectionする
- つまり機能追加するときに孫のDependencyの追加が親を辿って変更するの大変!!!
- プレビュー用のViewにも変更が必要
- テストコードでも変更が必要
- つまり機能追加するときに孫のDependencyの追加が親を辿って変更するの大変!!!
- 孫しか使わないDependencyもRootはもちろん、孫の親もInjectionする
- 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なら他の全部も変わる
-
- プロパティだけの変更のつもりがグローバルに変更される
- プロパティラッパーなのでセッターからsetできるが、分かってないと危険
サンプルコードと動作
画面構成
- Main画面
- ボタン"tap me!"が真ん中にあってDetail画面にpush遷移する
- Detail画面
- ボタン"Detail"が真ん中にあってModal画面をモーダル表示する
- Modal画面
- WebAPI
- WebAPIのNumberFactAPIを呼び出して文字列を結果として取得
- テスト時には実際の通信はせず文字列の結果を取得
- スケジューラ
- スケジューラで0.3秒くらいdebounceさせる
- 本来だったらmainじゃないスケジューラのほうが良いかもしれないが!今回は知らん!
- スケジューラでmainスレッドに変更する
- スケジューラで0.3秒くらいdebounceさせる
- WebAPI
Main
Detail
Modal
遷移と依存
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もできるのでバケツリレーを辞めれられる気がする
- 他に良いやり方があればコメントとか別途ブログとかで教えて下さい
Discussion