🍣

SwiftUIでMVVMのiOSアプリをつくってみた

2021/09/28に公開

前回の続きです。

前回の記事

https://zenn.dev/st43/articles/f51c261ef65edd

参考書

アーキテクチャーに関する知識は、こちら↓を参照しています。
以下の文中で「参考書」と出てきたらこの本のことを指します。

https://peaks.cc/books/iOS_architecture

環境

  • Xcode: 13.0
  • iOS: 15.0
  • Swift: 5.5

MVVMとは

  • Model, View, ViewModelから構成されるアーキテクチャー
  • 2005年にMicrosoftのSilverlightの担当だったJohn Gossmanのブログで発表された
    • 「Presentation ModelをWPF/Silverlight環境に特殊化したもの」というのがGrossman本人の認識らしい
  • ViewModelはViewのプレゼンテーションロジック・状態管理、そしてModelへのコマンド送信・監視を行う
  • ↑これだけだと、MVPのPresenterと大差ないが、大きな違いはViewとViewModel間がデータバインディングによって結びついていること
  • そのためViewModel側は宣言的にViewの更新を行うことができる
  • WPF/Silverlight環境だとフレームワーク的にデータバインディングの仕組みがあったが、他の環境だと必ずしもそうではないので、何らかの方法でバインディングする必要がある
  • かつてはiOS開発にはデータバインディングの仕組みがなかったが、2010年代後半にOSSのRxSwiftの流行とともに、MVVMも人気が出た
  • 今ではApple公式でCombineが提供されているので、今回はこれを使う
  • デメリットとしては、データバインディングの仕組みの導入が高コスト(iOS開発に関してはCombineの登場で多少下がったとは思う)なので、小さなアプリなら普通に手続き的に書いたほうが早い

実装してみた

こんな感じです。

https://github.com/0si43/iOS-architecture-training/tree/MVVM

今までのCocoa MVC, MVPと比較すると、SwiftUIの機能をちゃんと使えた感じがしました。

設計図としてはこんな感じです。
SwiftUIベースのアプリとしてつくれると嬉しいですね。

View - ViewModel間はCombineでデータバインディングします。
MVVMの実装、といってもModel部分・View部分はさほど修正が要らなくて、大変なのはデータバインディングのところだけでした。

Combineによるデータバインディング

Original MVC(リポジトリのmainブランチ)のときに、Model - View間をObservableObjectを使って監視する仕組みにしたので、
同じことをViewModel - Viewに適用するだけ、といえばそれだけでした。

publish側のViewModelはこんな感じにしてみました。

import Combine

/// ユーザー検索のViewModel
class UserSearchViewModel: ObservableObject {
    let model: ModelInput
    private(set) var objectWillChange = ObservableObjectPublisher()
    @Published var users: [User]
    @Published var isNotFound: Bool
    @Published var error: ModelError?

    init(model: GithubModel = GithubModel(),
         users: [User] = [User](), isNotFound: Bool = false, error: ModelError? = nil) {
        self.model = model
        self.users = users
        self.isNotFound = isNotFound
        self.error = error
    }

    /// Modelにロード開始を要求する
    func loadStart(query: String) {
        guard !query.isEmpty else { return }
        users = [User]()
        isNotFound = false
        error = nil

        model.fetchUser(query: query) { [weak self] result in
            switch result {
            case .success(let users):
                if !users.isEmpty {
                    self?.users = users
                } else {
                    self?.isNotFound = true
                }
            case .failure(let error):
                self?.error = error
            }
            self?.objectWillChange.send()
        }
    }
}

objectWillChangeは自分で書かなくてもCombineの中で上手いこと補完してくれるのですが、
イニシャルをしているところで毎回レンダリング走らせたくなかったので、このように手動でpublish通知を送るようにしました。

はじめ、手動で通知しなかった場合、loadStart(query:)を1回呼んだ時点でイニシャルで3回、コールバックで1回のSwiftUIの再レンダリングが走る……と思っていたんですが、
どうやら値の更新がない場合はレンダリングがスキップになるみたいで、1つのプロパティをイニシャルするたびに再レンダリングということはしていませんでした。

まあそれでもイニシャルの再レンダリングはムダっちゃムダなので、手動にしました。

RepositoryViewModelの方は、ローディング画面を表示する仕様上、手動通知にしないほうが楽だったので、objectWillChangeは定義していません。

ObservableObjectを継承するprotocolがしんどい

SwiftUI x MVVMはだいぶ相性がいいと感じました。
が、問題がなかった訳ではなくて、一番の問題はprotocolが書きづらかったことです。
ObservableObjectもprotocolなんで、protocolの継承して、ViewModelのインターフェイスを書きたかったんですが、
ObservableObjectの中の型が解決できなくて、コンパイルできませんでした。
探した中だと、実装方法は

https://tech.toreta.in/entry/2019/12/24/104612

みたいな感じで、あるっちゃあるみたいなんですが、ちょっとゴリゴリ書かないとダメそうです。

Combineを使うときはprotocol中心設計を諦めた方が良い?

ObservableObjectで@Publishedつけるのが、Combineを利用してPub/Sub関係つくる一番楽な方法で、
Publisher/Subscriberを自作するのもまあまあ大変なので、できればこの方法をとりたい、というのが僕の感覚です。
何も考えないで乱用すると、レンダリングしまくる問題がありますが、必要に応じてobjectWillChangeで手動Publishすればパフォーマンス的にも問題ないのかなあ、と。

ただこの方法だと、実質的にprotocol中心設計を諦めざるを得ないように思います。
Modelはprotocol書けたんですが、ViewModelは結局書くの挫折してしまいました。
要はテスト書ければいいんだろ? と思って、イニシャライザで外から初期値注入できるようにはしたので、これでテスト的には問題ないのかなと思いました。

抽象に依存する、という原則が壊れてるので、そこがちょっと嫌な感じがしますね。

まとめ

という訳で、MVVMでした。

SwiftUIとMVVMは相性がいいことがわかりました。
Combineを使えば、データバインディングもお手軽ですし、何かアーキテクチャー入れるとなったら今のところMVVMを選びたいですね。

protocolのところだけ、もっといい方法がないか探してみたいです。
なければ諦めて今みたいなやり方にしますかね。

Discussion