【SwiftUI】ModelとViewModelの繋ぎ方【MVVM】

MVVMに慣れた人がSwiftUIでもMVVMを採用しようとすると、ModelとViewModelをどう繋ぐのか?という点で悩むと思います。 本記事では、そんな悩みを解決します。

SwiftUIでMVVMを適用する際の悩み

まずは悩みを再確認しておきましょう。

SwiftUIでMVVMを適用しようとすると、View/ViewModel/Modelはそれぞれ下記のように実装しようと考えると思います。

MVVMパターン
要素実装方針
ViewView
ViewModelObservableObject
ModelClass

これはとても自然でうまく適用できるように感じます。

ViewとViewModelの間は、Bindingは@Publishで行えますし、Commandは単にメソッド呼び出しでよいでしょう。

ViewModelとModelの間も、Commandは同様にメソッド呼び出しでいいですし、Notificationは例えばdelgateを使えば良さそうです。

ViewとViewModelの間についてはその通りで何も問題はありません。しかし、ViewModelとModelの間のNotificationに落とし穴があります。

まず、単なるdelegateにするとうまくいかないケースが多いことに気が付くはずです。なぜなら、たいていのケースでは、Model1つに対し複数のViewModelが通知を受け取りたいからです。

複数のViewModelが通知を受け取りたい

次に思いつくのは、Model側にaddDelegate()メソッドを用意して複数のdelegateに通知できるようにする方法です。これであればうまくいきそうですが、この方法も注意して実装しないとメモリリークの可能性があります。

ViewModelの初期化の際にaddDelegate()を呼び出すことになると思います。そしてViewModelはViewの初期化と合わせて生成されることが多いと思います。例えば、こんな感じです。

struct SampleView: View {
    @ObservedObject var vm = SampleViewModel()
        :
}

class SampleViewModel: ObservableObject, SampleModelDelegate {
    @Publish var text = ""

    init() {
        SampleModel.instance.addDelegate(self)
    }
}

コードだけ見ると何も問題ないように思いますが、このままだとメモリリークする可能性があります。

理由はSwiftUIのViewのライフサイクルにあります。Viewはバインディングしているオブジェクトに変更があった際、内部状態を変えて表示を更新するのではなく、View自身を再度作り直すのです。

そのため、画面更新のある度にViewModelも一緒に作り直されて、Model側にdelegateのリストが増加していってしまうのです。

SwiftUIでViewModelとModelを繋ぐ方法

この悩みの解決策として、Model側でdelegateをDictionaryに格納する方法を提案します。

Dictionaryにdelegateを格納し、そのキーにはViewModelを識別する情報を使用します。もし、そのViewModelクラスが1つしか作られないのであればクラス名をそのままキーに使えばよいでしょう。例えばこんな感じです。

class SampleViewModel: ObservableObject, SampleModelDelegate {
    @Publish var text = ""

    init() {
        SampleModel.instance.setDelegate(String(describing: type(of: self)), self)
    }
}

こうしておけば、ViewModel(View)が再生成されても、同じキーで上書きされるだけなので、Dictionaryの中のdelegateが無限に増えていくことはなくなります。

ViewModelクラスが複数インスタンス化される場合は、キーを工夫すれば大丈夫です。例えば、こんな感じです。

class SampleViewModel: ObservableObject, SampleModelDelegate {
    @Published var name: String
    @Publish var text = ""

    init(name: name) {
        self.name = name
        SampleModel.instance.setDelegate(self.name + "_" + String(describing: type(of: self)), self)
    }
}

とてもシンプルな解決策です。ぜひ参考にしてみてください。

この解決策では対処できない場合

この解決策でたいていのケースは問題ありませんが、うまく適用できないケースもあるので説明しておきます。

うまく適用できないのは、ViewModelのキーの数に上限がなく、いくらでも増えていってしまうケースです。例えば、ユーザのリストを表示するアプリケーションで、ユーザごとにViewModelを作るような場合です。ユーザが増えればそれだけキーも増えるので、その分delegateも増えていってしまいます。

その際は、そのViewModelが本当に通知を受け取る必要があるのか検討してみてください。おそらくSwiftUIのListで繰り返しViewを表示するようなものだと思いますが、そのListを表示する親のようなViewのViewModelだけが通知を受け取れれば十分だったりしないでしょうか?

たいていのケースでは親のViewだけで十分だと思います。

もし必要なときのことについては、また別の機会に書きたいと思います。