前書き
私が最近参画したプロジェクトにおいて、MVVMアーキテクチャを採用しました。
その構造をイメージしたサンプルコードを共有します。
特に、
async/awaitとCombineを利用することで、MVVMをiOS12以前よりもシンプルに実現できるようになった
という点を共有したいです。
前提環境:
・Xcode 14.0.1
・Swift 5.7
・iOS 13以降
Cocoa MVCとMVVMの違い(責務分割のイメージ)
Cocoa MVC
- View層
- Storyboard
- UIViewを継承したclass(UI部品)
- Controller層
- UIViewControllerを継承したclass
- Model層
- class/struct
MVVM
- View層
- Storyboard
- UIViewを継承したclass(UI部品)
- UIViewControllerを継承したclass
- ViewModel層
- class/struct
- Model層
- class/struct
MVVMのメリット
実践を通して実感したメリット:
- 上図の通りViewControllerの責務が減るため、ViewControllerの肥大化を抑止できる。
- UI制御以外がModel層とViewModel層に集約されるため、テストコードを書きやすい。
- プログラム構造がパターン化されるため、他人が書いたコードでも構造をイメージしやすい。すなわち保守しやすい。
- 責務分割を考えるクセがつくため、プログラマにとってスキルアップにつながる。
MVVMのデメリット
- コード量が増える。
- 慣れるまで生産性が上がらない。
サンプルコードと簡単な解説
本記事のために作ったサンプルアプリです。
一応アーキテクチャの説明のための要素は含んだつもりですが、実務のコードとは異なっていることをご了承ください。
サンプルアプリ
GoogleニュースのRSSフィードを取得して、UITableViewで一覧表示するアプリです。
トピックの絞り込み機能も持ちます。
セルを選択されるとSFSafariViewControllerにてニュースを表示します。
APIを叩いてレスポンスを待っている間はローディングの表示になります。
また、エラー時はAlertを表示します。
すなわち、ローディング中/ロード完了/エラーという状態を持っています。
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です。- 従来の
dataTask
を使用したい/せざるを得ない場合は、withCheckedContinuation/withCheckedThrowingContinuation
でwrapすることでasync/awaitが利用できるようになります。 - (参考リンク)Swift Concurrency まとめ(正式版対応済) - 既存のコードを async/await に対応させる
- 従来の
【サンプルコードを開く/閉じる】
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)
}
}
リポジトリ