見出し画像

SwiftUIでViewModelをDIしてみる

SwiftUIでMVVMを採用する場合、ViewModelは次のように定義することがよくあるが、プレビューやUIテストでViewModelをモックに差し替えられない問題がある。そこで、ViewModelのDI(Dependency Injection)について考えてみたい。

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            Text(viewModel.text)
                .padding()
            
            Button {
                viewModel.update()
            } label: {
                Text("Update")
            }
        }
    }
}

@MainActor
class ViewModel: ObservableObject {
    @Published var text = "text"

    func update() {
        // 更新処理
        text = "New text"
    }
}

まずViewModelが準拠すべきプロトコルを定義して、ViewModelやMockedViewModelをそれに準拠させる。

// Protocolで@MainActorを付ければ、準拠したクラスでも自動的にMainActorになる
@MainActor
protocol ViewModelProtocol: ObservableObject {
    var text: String { get }
    
    func update()
}

// ViewModelをViewModelProtocolに準拠させる
final class ViewModel: ViewModelProtocol {
    @Published private (set) var text = "text"

    func update() {
        // 更新処理
        text = "New text"
    }
}

// モックもViewModelProtocolに準拠させる
final class MockedViewModel: ViewModelProtocol {
    @Published private (set) var text = "Dummy text"

    func update() {
        // モックでの更新処理
        text = "New dummy text"
    }
}

そしてViewの初期化時にViewModelを受け取ってプロパティに代入することで依存性を注入する。なお、こちらの記事でも紹介したが、ViewModelはViewの再描画時も状態を保持させるために、@ObservedObjectよりも@StateObjectで宣言する方がいい。
@StateObjectの初期化については、公式ドキュメントにinit(wrappedValue:)は直接呼ばないようにと記載されているが、WWDCのデジタルラウンジでDI時の初期化についての質問があり、Appleとしては許容される使い方とのこと(こちらを参照)。

struct ContentView<ViewModel: ViewModelProtocol>: View {
    @StateObject private var viewModel: ViewModel

    init(viewModel: ViewModel) {
        // 渡されたViewModelでStateObjectを初期化することで依存性を注入
        _viewModel = StateObject(wrappedValue: viewModel)
    }

    var body: some View {
        VStack {
            Text(viewModel.text)
                .padding()
            
            Button {
                viewModel.update()
            } label: {
                Text("Update")
            }
        }
    }
}

追記

下記のViewModelの初期化方法では、確かに一度のみinitされ依存性の注入で使えるが、Viewが非表示(画面を閉じる、前画面に戻った場合)になってもViewModelがメモリ上に残り続け、メモリ解放されないことがあるようだ。

init(viewModel: ViewModel) {
    _viewModel = StateObject(wrappedValue: viewModel)
}

こちらの記事にあるような2回初期化される挙動は確認できていないが、ViewModel.deinit()が呼ばれない事象は発生した。一方View.init()をなくすだけで、同じように初期化でき、かつViewが非表示になるとViewModel.deinit()が呼ばれることを確認できた。

struct ContentView<ViewModel: ViewModelProtocol>: View {
    @StateObject private var viewModel: ViewModel

    var body: some View {
        ...
    }
}

参考


この記事が気に入ったらサポートをしてみませんか?