👀

SwiftUI で @StateObject ViewModel の依存を Environment から参照する

2022/05/04に公開

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