👀
SwiftUI で @StateObject ViewModel の依存を Environment から参照する
StateObject
は初期値が必要ですが、大抵そのタイミングでは Environment
から正しい値を読むことはできません
この記事で紹介する LazyStateObject
を利用するとその問題を解消できます 👌
LazyStateObject
を利用したサンプルコードは以下の通りです
sample.swift
import SwiftUI
struct Dependency: DynamicProperty {
@Environment(\.networkClient) var networkClient
@Environment(\.storageClient) var storageClient
}
@MainActor
class ViewModel: ObservableObject {
let dependency: Dependency
@Published var count = 0
init(dependency: Dependency) {
self.dependency = dependency
}
func increment() async {
count = await dependency.networkClient.fetchCount(...)
}
}
struct ContentView: View {
@LazyStateObject(dependency: Dependency(), { dependency in
ViewModel(dependency: dependency)
})
var viewModel
var body: some View {
VStack {
// このタイミングで初回アクセスが走るため適切な値を持って初期化処理が行われる
Text("\(viewModel.count)")
Button(action: increment) {
Text("increment")
}
}
}
func increment() {
Task {
await viewModel.increment()
}
}
}
ポイントは DynamicProperty
への適合です
これによりレンダーツリーに関連付けされた Environment
等のオブジェクトが参照できるようになります
依存の注入はアプリ起動時にすると良いでしょう
またプレビュー時にはモックを注入するなども実現できます
app.swift
import SwiftUI
@main
struct App: SwiftUI.App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.networkClient, NetworkClientImpl())
.environment(\.storageClient, StorageClientImpl())
}
}
}
ただし Environment
は単体テスト時に任意の実装を注入できないため、次に紹介する OverridableEnvironment
を利用します
サンプルコードには以下の変更を加えます
struct Dependency: DynamicProperty {
+ @OverridableEnvironment(\.networkClient) var networkClient
+ @OverridableEnvironment(\.storageClient) var storageClient
- @Environment(\.networkClient) var networkClient
- @Environment(\.storageClient) var storageClient
}
そうするとテストコードでは以下のようにモック等の実装に置き換えられます
import XCTest
class SampleTests: XCTestCase {
@MainActor
func test() async {
var deps = Dependency()
deps.networkClient = ...
deps.storageClient = ...
let viewModel = ViewModel(dependency: deps)
await viewModel.increment()
XCTAssertEqual(viewModel.count, ...)
}
}
ソースコード
LazyStateObject.swift
@propertyWrapper
public struct LazyStateObject<ObjectType, Dependency>: DynamicProperty
where ObjectType: ObservableObject {
public var wrappedValue: ObjectType {
if let object = holder.object {
return object
}
let newObject = initializer(dependency)
holder.object = newObject
return newObject
}
@StateObject private var holder = ObjectHolder<ObjectType>()
private let dependency: Dependency
private let initializer: (Dependency) -> ObjectType
public init(dependency: Dependency, _ initializer: @escaping (Dependency) -> ObjectType) {
self.dependency = dependency
self.initializer = initializer
}
}
private final class ObjectHolder<ObjectType>: ObservableObject
where ObjectType: ObservableObject {
var object: ObjectType? {
willSet {
cancellable = newValue?.objectWillChange
.sink(receiveValue: { [weak self] _ in
self?.objectWillChange.send()
})
// Xcode14 で以下のランタイムワーニングが発生するようになったので雑に dispatch queue で囲う
// Publishing changes from within view updates is not allowed, this will cause undefined behavior.
DispatchQueue.main.async { [self] in
objectWillChange.send()
}
}
}
private var cancellable: Cancellable? {
willSet { cancellable?.cancel() }
}
}
OverridableEnvironment.swift
#if DEBUG
@propertyWrapper
public struct OverridableEnvironment<Value>: DynamicProperty {
public var wrappedValue: Value {
get { stateOverrideValue ?? overrideValue ?? environment.wrappedValue }
set {
overrideValue = newValue
stateOverrideValue = newValue
}
}
private var environment: Environment<Value>
private var overrideValue: Value?
@State private var stateOverrideValue: Value?
public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
environment = Environment(keyPath)
}
public mutating func reset() {
overrideValue = nil
stateOverrideValue = nil
}
}
extension OverridableEnvironment: Sendable where Value: Sendable {}
#else
// テスト以外では通常の方法で値を注入すれば良いので DEBUG 以外では標準の `Environment` として振る舞う
public typealias OverridableEnvironment = Environment
#endif
Discussion