LoginSignup
13
13

More than 3 years have passed since last update.

大規模なiOSアプリでFatViewControllerを解消するために導入したMVVMパターンのサンプル

Last updated at Posted at 2020-10-20

前書き

大規模なiOSアプリ開発を2年以上継続して得られた知見
上の記事ではアーキテクチャーパターンについては概要のみの記載でしたので、本記事で掘り下げます。

  • 「既存の作りをなるべく活かしてFatViewControllerを解消したい」と考えている方にとっては、一つの実践事例として参考になるかもしれません。
  • 一方で、教科書的な内容ではありませんので、本記事の内容を読んで「これが正しいMVVMだ」という理解はしない方が良いと思います。

前提環境:
・Xcode 12.0.1
・Swift 5.3

課題

  • アプリの画面数は120画面ぐらいです。ソコソコの規模かと思います。
  • UI/UXデザインには結構こだわっていて、一つ一つの画面実装が複雑です。
    • 一例として、API通信中はローディングプレースホルダー(スケルトン)を表示してユーザー操作を妨げないようにしています。すなわち画面全体をブロックをしないことでユーザーは他画面遷移等の操作が可能です。
  • 当初は標準的なCocoa MVCで作っていました。当然FatViewController化しました。
  • アプリのエンハンスメントを重ね規模が大きくなるにつれて改修が困難になってきました。

課題解決のための方針

FatViewControllerを解消して改修しやすくするために、アーキテクチャーパターンを導入することにしたわけです。

既存の作りを大幅に変えずにアーキテクチャーパターンを導入するためには、MVVMが最も適していると思われました。
既存のViewControllerからビジネスロジックをViewModelに切り出し、View-ViewModel間の通知を盛り込めば「イケる」と考えたためです。

また、Clean Architectureなどの、MVVMより複雑なパターンは学習コスト面でチームに合わないと考えました。

MVVMの適用に際して、以下のように方針を考えました。

  • 自前で作り込んでいたカスタムUIパーツやAutoLayoutのHelperがあるために、ライブラリの後付け導入は厳しいと考え、データバインディングは見限りました。
  • 元々ユニットテストがなく、XCUITestによるUIテストの自動化に取り組んでみたものの、機能追加変更の際のテストコードのメンテコストが大きく、私たちにとっては割りに合いませんでした。
  • 新たにXCTestでロジック部分のテストを書くようにしたいですが、View層のテストを書くことはやはりコストパフォーマンスが不安だったので、テストコードはロジック部分のみに絞り、UIは実動作で確認するよう割り切ることにしました。

ということで、
ライブラリ等を使わずに、自前でMVVM(的な?)アーキテクチャーを導入する方針で、FatViewControllerの解消を目指しました。

Cocoa MVCとMVVMの違い(責務分割のイメージ)

Cocoa MVC

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

MVVM

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

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

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

サンプルアプリ

GoogleニュースのRSSフィードを取得して、UITableViewで一覧表示するアプリです。
セルを選択されるとSFSafariViewControllerにてニュースを表示します。
スクリーンショット 2020-10-19 11.48.06.pngスクリーンショット 2020-10-19 12.20.22.png
APIを叩いてレスポンスを待っている間はローディングの表示になります。
初期表示と、UITableViewを下に引っ張られた時です。
すなわちこのアプリは「ローディングの状態を持っている」ということです。
スクリーンショット 2020-10-19 11.48.02.png

Modelのサンプルコード

  • このアプリの問題領域であるGoogleニュースのRSSフィードを扱うクラスです。
    • RSSフィードの解説については、こちらの記事がすばらしく分かりやすいです。
    • Google News Rss(API)
  • ViewModel、Viewには依存しません。
  • 他の層に依存しないのでDI (Dependency Injection) しなくてもXCTestが書けます。
    • だだしAPIのモック化は骨なので、私はXCTestでAPIのモック化が必要な時はOHHTTPStubsを使っています。
  • ニュースフィードの取得結果はクロージャ(コールバック関数)で呼び元に返します。
    • 実業務では、通知先が複数オブジェクトの場合などには、NotificationCenterを使う場合もあります。
Model.swift
import Foundation

/// DIのためにModelの振る舞いを抽象化したProtocol
protocol ModelProtocol {
    func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void)
    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パースで発生したエラー
    private var parseError: Error?

    /// GoogleNEWSのRSSを取得する
    func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void) {
        let url = URL(string:  "https://news.google.com/rss?hl=ja&gl=JP&ceid=JP:ja")!
        URLSession.shared.dataTask(with: url, completionHandler: { [weak self] (data, response, error) in
            guard let self = self else {
                return
            }
            sleep(3)    // 擬似的なレスポンス遅延
            if let error = error {
                completion(Result.failure(error))
                return
            }
            guard let data = data else {
                completion(Result.success([Article]()))
                return
            }
            print("\(String(data: data, encoding: .utf8) ?? "decode error.")")    // DEBUG
            completion(self.createItems(with: data))
        }).resume()
    }

    /// GoogleNEWSのRSSを元にニュース記事の配列を生成する
    func createItems(with data: Data) -> Result<[Model.Article], Error> {
        let parser = XMLParser(data: data)
        parser.delegate = self
        parser.parse()
        if let parseError = 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にて描画するのに必要な情報を加工して、保持します。
  • Viewへの問合せ結果(ステータス)の通知はViewModelDelegateを使っています。
ViewModel.swift
import Foundation

/// Viewにデータの取得状態が変化したことを通知するためのProtocol
protocol ViewModelDelegate: AnyObject {
    func didChange(status: Status)
}

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

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

    // 取得状態を扱うオブジェクト
    weak var delegate: ViewModelDelegate?
    private(set) var status: Status? {
        didSet {
            // 随所でdelegate.didChange(:status)を呼び出すとモレる可能性があるのでdidSetにて行う
            guard let status = status else {
                return
            }
            delegate?.didChange(status: status)
        }
    }

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

    /// データ取得
    func load() {
        status = .loading
        model.retrieveItems { [weak self] (result) in
            switch result {
            case .success(let items):
                self?.viewItems = items.map({ (article) -> ViewItem in
                    return ViewItem(title: article.title,
                                    link: article.link,
                                    source: article.source,
                                    pubDate: self?.format(for: article.pubDate))
                })
                self?.status = .loaded
            case .failure(let error):
                self?.status = .error("エラー: \(error.localizedDescription)")
            }
        }
    }
}

// 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のサンプルコード

  • いわゆるプレゼンテーション層のクラスです。
  • ViewModelDelegateを実装することで、問合せ結果(ステータス)の通知を受け取り、画面に描画します。
  • 前述の通り、個々のView項目のバインディングは行いません。
  • また、Viewのテストコードを書くことは諦めているので、UITableViewDataSourceおよびUITableViewDelegateは「なり」で実装しています。
ViewController.swift
import UIKit
import SafariServices

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

    private let viewModel = ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        tableView.delegate = self
        // 引っ張って更新
        tableView.refreshControl = UIRefreshControl()
        tableView.refreshControl?.addTarget(self, action: #selector(refresh(sender:)), for: .valueChanged)

        viewModel.delegate = self
        viewModel.load()
    }
}

// MARK: - UITableViewの処理群
extension ViewController: UITableViewDataSource, UITableViewDelegate {
    /// 行数を返す
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.viewItems.count
    }

    /// cellを返す
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> 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
    }

    /// cellの選択時
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard 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: - ViewModelDelegate
extension ViewController: ViewModelDelegate {
    /// ViewModelのステータスが変化した時の処理
    func didChange(status: Status) {
        switch status {
        case .loading:
            tableView.refreshControl?.beginRefreshing()
            tableView.reloadData()
        case .loaded:
            DispatchQueue.main.async { [weak self] in
                self?.tableView.refreshControl?.endRefreshing()
                self?.tableView.reloadData()
            }
        case .error(let message):
            DispatchQueue.main.async { [weak self] in
                self?.tableView.refreshControl?.endRefreshing()
            }
            print("\(message)")
        }
    }
}

// MARK: - Action
extension ViewController {
    /// UITableViewを引っ張って更新
    @objc func refresh(sender: UIRefreshControl) {
        viewModel.load()
    }
}

成果

以下の面において成果はあったと考えます。

  • FatViewControllerを解消し維持保守しやすくなった。
  • 既存の作りを大きく変えないことで、リアーキテクチャー工数と学習コストを抑えられた。
  • アーキテクチャーは変わったものの細部の実装は以前と変わらないので、新規機能開発の生産性は(ほぼ)下がらなかった。
  • ロジック部分のテストが書けるようになった。

リポジトリ

参考リンク

13
13
0

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
13
13