[iOS] [UIKit] async/awaitとCombineを利用したMVVMアーキテクチャのサンプル

# 前書き

私が最近参画したプロジェクトにおいて、MVVMアーキテクチャを採用しました。
その構造をイメージしたサンプルコードを共有します。

特に、
**async/awaitとCombineを利用することで、MVVMをiOS12以前よりもシンプルに実現できるようになった**
という点を共有したいです。

前提環境：
・Xcode 14.0.1
・Swift 5.7
・iOS 13以降

# Cocoa MVCとMVVMの違い（責務分割のイメージ）

## Cocoa MVC

![スクリーンショット 2021-02-06 8.50.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122917/ad32b5f2-84fe-8088-722d-58ddcb54c18e.png)

- View層
    - Storyboard
    - UIViewを継承したclass（UI部品）
- Controller層
    - UIViewControllerを継承したclass
- Model層
    - class/struct

## MVVM

![スクリーンショット 2021-02-06 8.50.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122917/ff170121-cd0c-950e-5be0-6c877c020459.png)

- View層
    - Storyboard
    - UIViewを継承したclass（UI部品）
    - UIViewControllerを継承したclass
- ViewModel層
    - class/struct
- Model層
    - class/struct

## MVVMのメリット

実践を通して実感したメリット：

- 上図の通りViewControllerの責務が減るため、ViewControllerの肥大化を抑止できる。
- UI制御以外がModel層とViewModel層に集約されるため、テストコードを書きやすい。
- プログラム構造がパターン化されるため、他人が書いたコードでも構造をイメージしやすい。すなわち保守しやすい。
- 責務分割を考えるクセがつくため、プログラマにとってスキルアップにつながる。

## MVVMのデメリット

- コード量が増える。
- 慣れるまで生産性が上がらない。

# サンプルコードと簡単な解説

本記事のために作ったサンプルアプリです。
一応アーキテクチャの説明のための要素は含んだつもりですが、実務のコードとは異なっていることをご了承ください。

## サンプルアプリ

GoogleニュースのRSSフィードを取得して、UITableViewで一覧表示するアプリです。
トピックの絞り込み機能も持ちます。
<img width="50%" alt="スクリーンショット 2022-10-23 10.56.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122917/39285517-319e-34e8-3421-509c34c3c05c.png"><img width="50%" alt="スクリーンショット 2022-10-23 11.04.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122917/1bf52c11-2b38-c929-7ca6-10fe47db0c37.png">
セルを選択されるとSFSafariViewControllerにてニュースを表示します。
<img width="50%" alt="スクリーンショット 2022-10-23 10.57.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122917/e46ac9a4-e772-107f-bf07-3e60c7eb7a5a.png">

APIを叩いてレスポンスを待っている間はローディングの表示になります。
また、エラー時はAlertを表示します。
すなわち、**ローディング中／ロード完了／エラーという状態を持っています。**
<img width="50%" alt="スクリーンショット 2022-10-24 10.14.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122917/2b4ef520-7f0a-d056-94f8-8ab1e52cc914.png"><img width="50%" alt="スクリーンショット 2022-10-24 10.07.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122917/ebc8721c-14f5-d1a2-e276-0766ff36419f.png">

## Modelのサンプルコード

- このアプリの問題領域であるGoogleニュースのRSSフィードを扱うクラスです。
    - RSSフィードの解説については、こちらの記事がすばらしく分かりやすいです。
    - [Google News Rss(API)](https://qiita.com/KMD/items/872d8f4eed5d6ebf5df1)
- ViewModel、Viewには依存しません。
- 他の層に依存しないのでDI (Dependency Injection) しなくてもXCTestが書けます。テストについては本稿のテーマから外れるので触れません。
- **ニュースフィードの取得関数`func retrieveItems`は非同期処理であり、エラーはthrowで呼び元に返すため、`async throws`を付与しています。**
    - 引数にコールバック用のクロージャーを取らないので宣言が簡潔です。
    - URLSessionのasync/await対応の`data(from:delegate:)`APIを使うことで記述が簡潔です。
    - ただし`data(from:delegate:)`はiOS 15以降availableです。
        - 従来の`dataTask`を使用したい／せざるを得ない場合は、`withCheckedContinuation/withCheckedThrowingContinuation`でwrapすることでasync/awaitが利用できるようになります。
        - （参考リンク）[Swift Concurrency まとめ（正式版対応済） - 既存のコードを async/await に対応させる](https://zenn.dev/akkyie/articles/swift-concurrency#%E6%97%A2%E5%AD%98%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92-async%2Fawait-%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%95%E3%81%9B%E3%82%8B)

<details><summary>【サンプルコードを開く／閉じる】</summary>

```swift:Model.swift
import Foundation

/// フィルター
enum FilterType: String, CaseIterable {
    case none = "NONE"
    case world = "WORLD"
    case nation = "NATION"
    case business = "BUSINESS"
    case technology = "TECHNOLOGY"
    case entertainment = "ENTERTAINMENT"
    case sports = "SPORTS"
    case science = "SCIENCE"
    case health = "HEALTH"
    
    var title: String {
        switch self {
        case .none: return "トップニュース"
        case .world: return "世界"
        case .nation: return "日本"
        case .business: return "ビジネス"
        case .technology: return "テクノロジー"
        case .entertainment: return "エンタメ"
        case .sports: return "スポーツ"
        case .science: return "科学"
        case .health: return "健康"
        }
    }
}

/// DIのためにModelの振る舞いを抽象化したProtocol
protocol ModelProtocol {
    func retrieveItems(for filterType: FilterType) async throws -> [Model.Article]
    func createItems(with data: Data) -> Result<[Model.Article], Error>
}

/// アプリのドメイン（問題領域）のデータ保持と手続きを担う
class Model: NSObject, ModelProtocol {
    /// ニュース記事
    class Article {
        var title = ""
        var link = ""
        var pubDateStr = ""
        var pubDate: Date? {
            return createDate(from: pubDateStr)
        }
        var description = ""
        var source = ""
    }
    private var articles = [Article]()

    /// GoogleNEWSのXML要素の定義
    enum Element: String {
        case item = "item"
        case title = "title"
        case link = "link"
        case pubDate = "pubDate"
        case description = "description"
        case source = "source"

        var name: String {
            return self.rawValue
        }
    }
    private var currentElementName : String?

    // XMLのparseで発生したエラー
    private var parseError: Error?

    /// GoogleNEWSのRSSを取得する
    func retrieveItems(for filterType: FilterType) async throws -> [Article] {
        let url: URL
        if filterType == .none {
            let urlString = "https://news.google.com/rss?hl=ja&gl=JP&ceid=JP:ja"
            guard let urlTemp = URL(string: urlString) else {
                preconditionFailure("URL不正")
            }
            url = urlTemp
        } else {
            let urlString = "https://news.google.com/news/rss/headlines/section/topic/\(filterType.rawValue)?hl=ja&gl=JP&ceid=JP:ja"
            guard let urlTemp = URL(string: urlString) else {
                preconditionFailure("URL不正")
            }
            url = urlTemp
        }

        // GoogleNEWS API呼び出し
        let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)

        // APIレスポンス(XML)を元にニュース記事の配列を生成する
        let result = createItems(with: data)
        switch result {
        case .success(let articles):
            return articles
        case .failure(let error):
            throw error
        }
    }

    /// XMLデータをparseしてニュース記事の配列を生成する
    func createItems(with data: Data) -> Result<[Article], Error> {
        let parser = XMLParser(data: data)
        parser.delegate = self
        parser.parse()
        if let parseError {
            return Result.failure(parseError)
        } else {
            return Result.success(articles)
        }
    }
}

// MARK: - XMLパーサーの処理群
extension Model: XMLParserDelegate {
    // 解析_開始時
    func parserDidStartDocument(_ parser: XMLParser) {
        articles.removeAll()
    }

    /// 解析_要素の開始時
    func parser(_ parser: XMLParser,
                didStartElement elementName: String,
                namespaceURI: String?,
                qualifiedName qName: String?,
                attributes attributeDict: [String : String]) {

        currentElementName = nil
        if elementName == Element.item.name {
            // 次のニュース記事が現れた場合、新規の記事classをデフォルトで生成
            articles.append(Article())
        } else {
            // 各要素の場合
            currentElementName = elementName
        }
    }

    /// 解析_要素内の値取得
    func parser(_ parser: XMLParser, foundCharacters string: String) {
        // 末尾の記事classを上書き更新
        guard let lastItem = articles.last else {
            return
        }
        switch currentElementName {
        case Element.title.name:
            lastItem.title = string
        case Element.link.name:
            lastItem.link = string
        case Element.pubDate.name:
            lastItem.pubDateStr = string
        case Element.description.name:
            lastItem.description = string
        case Element.source.name:
            lastItem.source = string
        default:
            break
        }
    }

    /// 解析_要素の終了時
    func parser(_ parser: XMLParser,
                didEndElement elementName: String,
                namespaceURI: String?,
                qualifiedName qName: String?) {

        currentElementName = nil
    }

    /// 解析_終了時
    func parserDidEndDocument(_ parser: XMLParser) {
        self.parseError = nil
    }

    /// 解析_エラー発生時
    func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
        self.parseError = parseError
    }
}

// MARK: - ユーティリティ関数
extension Model {
    /// GoogleNEWSの日付StringからDateを生成する
    static func createDate(from dateString: String) -> Date? {
        let formatter: DateFormatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.dateFormat = "E, d M y HH:mm:ss z"
        return formatter.date(from: dateString)
   }
}
```

</details>

## ViewModelのサンプルコード

- Viewから送られたアクションを仲介してModelに問合せを行い、問合せ結果（ステータス：ロード中、成功、失敗）をViewに通知するクラスです。
- Modelに依存しています。
    - XCTestのためにModelをDIできるようにしています。
    - サンプルコードの`init(:model)`に着目してください。
- Viewにて描画するのに必要な情報を加工して、保持する役割を持ちます。
- **通信状態を保持する変数`state`にCombineの`@Published`アノテーションを付与することで、View側に通知できるようにしています。**
    - **従来良く使っていたdelegateパターンやクロージャによる通知は使っていません。**
- **データ取得関数`func load`は非同期処理のため、`async`を付与しています。**
    - **Model層からの結果の受け取りにクロージャーを使わないので実装が簡潔です。**
    - Model層からthrowされたエラーはここでハンドリングするため`do-catch`で処理しています。

<details><summary>【サンプルコードを開く／閉じる】</summary>

```swift:ViewModel.swift
import Foundation

/// データの取得状態
enum State {
    case loading
    case loaded
    case error(String)
}

/// ViewとModelの間の情報の伝達と、Viewのための状態を保持する役割
class ViewModel {
    // Viewに提供する表示用データオブジェクト
    struct ViewItem: Hashable {
        let title: String
        let link: String
        let source: String
        let pubDate: String?
    }
    private(set) var viewItems = [ViewItem]()

    // 取得状態を扱うオブジェクト
    @Published private(set) var state: State?
    // フィルターのタイトルを画面側で監視するためのオブジェクト
    @Published private(set) var title: String? = FilterType.none.title

    // 現在のフィルター状態を保持
    private var filterType: FilterType = .none {
        didSet {
            title = filterType.title
        }
    }

    // テストのためにModelクラスをDIする
    private let model: ModelProtocol
    init(model: ModelProtocol = Model()) {
        self.model = model
    }

    /// データ取得
    func load(for filterType: FilterType) async {
        self.filterType = filterType
        state = .loading
        
        do {
            let articles = try await model.retrieveItems(for: filterType)
            viewItems = articles.map({ (article) -> ViewItem in
                return ViewItem(title: article.title,
                                link: article.link,
                                source: article.source,
                                pubDate: format(for: article.pubDate))
            })
            state = .loaded
        } catch {
            state = .error(error.localizedDescription)
        }
    }
    
    /// リロード
    func reload() async {
        await load(for: filterType)
    }
}

// MARK: - ユーティリティ関数
extension ViewModel {
    /// Dateから表示用文字列を編集する
    func format(for date: Date?) -> String? {
        guard let date = date else {
            return nil
        }
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm"
        formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
        formatter.locale = Locale(identifier: "en_US_POSIX")
        return formatter.string(from: date)
    }
}
```

</details>

## Viewのサンプルコード

- いわゆるプレゼンテーション層のクラスです。
- **`viewModel.$state`というコードによって`state`の変化を監視し、画面に描画します。**
- （MVVMの話題からはズレますが）`UITableViewDiffableDataSource`および`NSDiffableDataSourceSnapshot`について
    - UIKit純正の差分制御の仕組みです。
    - iOS12以前では自力で差分抽出して行をinsert/delete/reloadしない限り、全データまたはセクション全体をリロードするしかありませんでした。
    - iOS13以降では`UITableViewDiffableDataSource`および`NSDiffableDataSourceSnapshot`を使うことによって差分リロードが可能になりました。
    - 基本的なメリットは「アニメーションがイイ感じになる」点です。

<details><summary>【サンプルコードを開く／閉じる】</summary>

```swift:ViewController.swift
import UIKit
import Combine
import SafariServices

class ViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!

    private var cancellables = Set<AnyCancellable>()
    private let viewModel = ViewModel()
    private typealias Snapshot = NSDiffableDataSourceSnapshot<Int, ViewModel.ViewItem>
    private typealias DataSource = UITableViewDiffableDataSource<Int, ViewModel.ViewItem>
    private var dataSource: DataSource?

    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.delegate = self
        tableView.tableFooterView = UIView(frame: .zero)
        tableView.rowHeight = UITableView.automaticDimension
        // 引っ張って更新
        tableView.refreshControl = UIRefreshControl()
        tableView.refreshControl?.addTarget(self, action: #selector(refresh(sender:)), for: .valueChanged)

        // 通信状態の監視
        viewModel.$state
            .dropFirst()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] state in
                guard let state else { return }
                switch state {
                case .loading:
                    self?.beginRefreshing()
                case .loaded:
                    self?.tableView.refreshControl?.endRefreshing()
                    self?.apply()
                case .error(let message):
                    self?.tableView.refreshControl?.endRefreshing()
                    self?.showErrorAlert(with: message)
                }
            }
            .store(in: &cancellables)
        // フィルター変更によるタイトルの監視
        viewModel.$title
            .receive(on: DispatchQueue.main)
            .assign(to: \.title, on: self)
            .store(in: &cancellables)

        // 初回ロード
        Task {
            await viewModel.load(for: .none)
        }
    }
}

// MARK: - UITableViewの処理群
extension ViewController: UITableViewDelegate {
    ///　cellの選択時
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard indexPath.row < viewModel.viewItems.count,
              let url = URL(string: viewModel.viewItems[indexPath.row].link) else {
            return
        }
        let safariVC = SFSafariViewController.init(url: url)
        safariVC.dismissButtonStyle = .close
        self.present(safariVC, animated: true, completion: nil)
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

// MARK: - Custom Method
extension ViewController {
    /// TableViewへのデータ投入
    private func apply() {
        var snapshot = Snapshot()
        snapshot.appendSections([0])
        snapshot.appendItems(viewModel.viewItems, toSection: 0)
        dataSource?.defaultRowAnimation = .fade
        if let dataSource {
            dataSource.apply(snapshot, animatingDifferences: true)
        } else {
            dataSource = DataSource(
                tableView: tableView,
                cellProvider: { [weak self] tableView, indexPath, item in
                    self?.getCell(tableView, at: indexPath, item: item)
                }
            )
            dataSource?.applySnapshotUsingReloadData(snapshot)
        }
    }
    
    ///　cellを返す
    private func getCell(_ tableView: UITableView, at indexPath: IndexPath, item: ViewModel.ViewItem) -> UITableViewCell {
        let identifier = "TableViewCell"
        let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath)
        let item = viewModel.viewItems[indexPath.row]
        cell.textLabel?.text = item.title
        cell.detailTextLabel?.text = "[\(item.source)] \(item.pubDate ?? "")"
        return cell
    }
    
    /// フィルタボタンtap
    @IBAction private func tappedFilterButton(_ sender: Any) {
        showActionSheet()
    }

    /// ActionSheet生成
    private func showActionSheet() {
        let actionSheet = UIAlertController(title: "トピック", message: nil, preferredStyle: UIAlertController.Style.actionSheet)
        for filterType in FilterType.allCases {
            let action = UIAlertAction(title: filterType.title, style: UIAlertAction.Style.default) { [weak self] _ in
                Task {
                    await self?.viewModel.load(for: filterType)
                }
            }
            actionSheet.addAction(action)
        }
        let close = UIAlertAction(title: "閉じる", style: UIAlertAction.Style.destructive) { _ in }
        actionSheet.addAction(close)
        present(actionSheet, animated: true, completion: nil)
    }
    
    /// UITableViewを引っ張って更新
    @objc private func refresh(sender: UIRefreshControl) {
        Task {
            await viewModel.reload()
        }
    }
    
    /// refreshControlを表示する
    private func beginRefreshing() {
        guard let refreshControl = tableView.refreshControl else { return }
        tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.height), animated: true)
        refreshControl.beginRefreshing()
    }
    
    /// エラーアラート表示
    private func showErrorAlert(with message: String) {
        let alertVC = UIAlertController(title: "エラー", message: message, preferredStyle: .alert)
        alertVC.addAction(UIAlertAction(title: "閉じる", style: .default))
        present(alertVC, animated: true)
    }
}
```

</details>

# リポジトリ

https://github.com/y-some/MVVMSample2

# 参考リンク

- [WikiPedia - Model View ViewModel](https://ja.wikipedia.org/wiki/Model_View_ViewModel)
- [MVVMを勉強するときに参考になった 概要まとめ & アンチパターン & リンク集](https://qiita.com/ebi-toro/items/046c9a43b15b9e376198)