LoginSignup
14
11

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-24

前書き

私が最近参画したプロジェクトにおいて、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

  • View層
    • Storyboard
    • UIViewを継承したclass(UI部品)
  • Controller層
    • UIViewControllerを継承したclass
  • Model層
    • class/struct

MVVM

スクリーンショット 2021-02-06 8.50.59.png

  • View層
    • Storyboard
    • UIViewを継承したclass(UI部品)
    • UIViewControllerを継承したclass
  • ViewModel層
    • class/struct
  • Model層
    • class/struct

MVVMのメリット

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

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

MVVMのデメリット

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

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

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

サンプルアプリ

GoogleニュースのRSSフィードを取得して、UITableViewで一覧表示するアプリです。
トピックの絞り込み機能も持ちます。
スクリーンショット 2022-10-23 10.56.57.pngスクリーンショット 2022-10-23 11.04.19.png
セルを選択されるとSFSafariViewControllerにてニュースを表示します。
スクリーンショット 2022-10-23 10.57.43.png

APIを叩いてレスポンスを待っている間はローディングの表示になります。
また、エラー時はAlertを表示します。
すなわち、ローディング中/ロード完了/エラーという状態を持っています。
スクリーンショット 2022-10-24 10.14.48.pngスクリーンショット 2022-10-24 10.07.31.png

Modelのサンプルコード

  • このアプリの問題領域であるGoogleニュースのRSSフィードを扱うクラスです。
    • RSSフィードの解説については、こちらの記事がすばらしく分かりやすいです。
    • Google News Rss(API)
  • ViewModel、Viewには依存しません。
  • 他の層に依存しないのでDI (Dependency Injection) しなくてもXCTestが書けます。テストについては本稿のテーマから外れるので触れません。
  • ニュースフィードの取得関数func retrieveItemsは非同期処理であり、エラーはthrowで呼び元に返すため、async throwsを付与しています。
    • 引数にコールバック用のクロージャーを取らないので宣言が簡潔です。
    • URLSessionのasync/await対応のdata(from:delegate:)APIを使うことで記述が簡潔です。
    • ただしdata(from:delegate:)はiOS 15以降availableです。
【サンプルコードを開く/閉じる】
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)
   }
}

ViewModelのサンプルコード

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

Viewのサンプルコード

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

リポジトリ

参考リンク

14
11
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
11