Swift

【swift3入門】ViewModelを作成しよう

はじめに

前回まででTableViewのある程度の機能が実装できました。
ここで機能拡張はおいて設計について考えていきます。

アーキテクチャ

以前にModelを作成しようでModelを作成したと思います。
今回はViewModelを作成します。

現状では記事データの情報はArticleモデルに記述されていますがそれ以外に関してはすべてViewControllerに記述されています。今の段階ではそこまで機能もないのでとくに気になりませんが、この先で機能をどんどん増やしていった場合にViewControllerがどんどん肥大化していってしまい、見づらい管理のしにくいコードになってしまいます。
そこで、役割ごとに記述するファイルを分けることでこのような問題点を回避できるようにします。

どのように役割ごとにわけるかというのは様々な考え方があって、その概念をアーキテクチャといいます。Swiftで用いられる主なアーキテクチャはMVC,MVVM,MVP,iOSクリーンアーキテクチャなどがあり、それは自分たちが書いているコードに合わせて適切なものを選択します。

今回はこの中でも比較的導入しやすくて汎用性の高いMVVMを採用したいと思います。

MVVM

MVVMはModel View ViewModelのことでViewはiOSでいうとViewControllerを表します。

それぞれの役割は、

  • Model・・・データの成形の役割
  • View・・・Viewを表示させる役割
  • ViewModel・・・ModelとViewを繋ぐ役割

こんなかんじなのですがまとめすぎてわかりずらいかんじなので具体的に今回のコードで言うと、

  • Model・・・前回作ったようにJSONを受け取ってデータを整形する
  • View・・・ViewModelで管理している記事データをTableViewを表示させる。(データ自体はViewでは持たない)
  • ViewModel・・・apiを叩いて記事データを取得し、Modelに渡してデータを整形してもらったデータを格納する。

このような形になります。ViewModelでは主にデータの管理を行います。

実装してみる

それでは実際にコードを書いていきたいと思います。
まず、ViewModelクラスを作成します。

import UIKit

  class ViewModel {

      var articles: [Article] = []

      func fetchArticles() {
          guard let url: URL = URL(string: "http://qiita.com/api/v2/items") else { return }
          let task: URLSessionTask  = URLSession.shared.dataTask(with: url, completionHandler: {data, response, error in
              do {
                  guard let data = data else { return }
                  guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments) as? [Any] else { return }
                  let articlesJson = jsonArray.flatMap { (json: Any) -> [String: Any]? in
                      return json as? [String: Any]
                  }
                  let articles = articlesJson.map { (articleJson: [String: Any]) -> Article in
                      return Article(json: articleJson)
                  }
                  DispatchQueue.main.async() { () -> Void in
                      self.articles = articles
                  }
              }
              catch {
                  print(error)
              }
          })
          task.resume()
      }
  }

とりあえず作成しました。
MVVMではデータはViewModelが持つべきなので、articlesを書いたのと、apiでデータを取得するためのメソッドであるfetchArticles()をコピペして移してきました。

import UIKit

  class ViewController: UIViewController {

      @IBOutlet weak var tableView: UITableView!

      private let viewModel = ViewModel() //追加

      fileprivate var articles: [Article] {
          return viewModel.articles //viewModelのarticleを参照するように書き換え
      }

      override func viewDidLoad() {
          super.viewDidLoad()
          viewModel.fetchArticles() //viewModelのfetchArticlesを呼ぶようにする
          initViewModel()
      }

      private func initTableView() {
          tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell")
          tableView.estimatedRowHeight = 150
          tableView.rowHeight = UITableViewAutomaticDimension
      }
  }

  extension ViewController: UITableViewDataSource {
      func numberOfSections(in tableView: UITableView) -> Int {
          return 1
      }

      func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
          return articles.count
      }

      func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
          let cell: TableViewCell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell") as! TableViewCell
          let article = articles[indexPath.row]
          cell.bindData(article: article)
          return cell
      }

      func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
          return UITableViewAutomaticDimension
      }
  }

  extension ViewController: UITableViewDelegate {
      func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
          print("section: \(indexPath.section) index: \(indexPath.row)")
      }

      func scrollViewDidScroll(_ scrollView: UIScrollView) {
          return
      }
  }

このように書きました。

private let viewModel = ViewModel()

というようにViewControllerのプロパティにviewModelを作成したのでViewControllerがインスタンス化されたときにviewModelもインスタンス化されます。

以前まではviewDidLoadでfetchArticles()を呼んでいたのでviewModelに移したfetchArticles()を同じタイミングで呼ぶように書き換えました。

fileprivate var articles: [Article] {
      return viewModel.articles //viewModelのarticleを参照するように書き換え
  }

この部分は絶対必要になるわけではありませんが、articlesはviewModelが持っているのでarticlesを使うときに毎回viewModel.articlesと書かないといけなくなってしまうのでそれを避けるために書いています。

これは計算型プロパティと言って、参照されたタイミングで{}の中の計算をした結果が返されます。なので毎回articlesを参照したらviewModel.articlesが返されるようになっています。

reloadDataの記述

ここまででよさげなかんじですが、これではreloadDataの記述がなくなってしまっています。
この部分がわりと難しい部分で、viewModelのarticlesの値が変更されたタイミングでviewControllerが持つtableViewをリロードさせないといけません。

直感的に書いてしまうと、

class ViewController: UIViewController {

      @IBOutlet weak var tableView: UITableView!

      private let viewModel = ViewModel()

      override func viewDidLoad() {
          super.viewDidLoad()
          viewModel.viewController = self
      }
  }

  class ViewModel {

      weak var viewController: ViewController?
      var articles: [Article] = [] {
          didSet {
              viewController.tableView.reloadData()
          }
      }
  }
weak var viewController: ViewController?

の部分でweakと付けているのは循環参照を避けるために書いています。循環参照の説明は今回は省略します。

このような形でかけなくはないですが、これはViewModelからViewControllerへ指示をしている形になっていて、そういった形はMVVMではふさわしくありません。MVVMであるべき形としてはViewController側からViewModelが見える形になっていて、ViewModelからはViewControllerがどうなっているかは見えないという設計がふさわしいです。なのでViewController側でViewModelのプロパティの値が変更されたのをキャッチできるようにしたいです。

ということで以下のように実装しました。

class ViewModel {

      var articles: [Article] = [] {
          didSet {
              reloadHandler() //追加
          }
      }

      var reloadHandler: () -> Void = { _ in } //追加

      func fetchArticles() {
          guard let url: URL = URL(string: "http://qiita.com/api/v2/items") else { return }
          let task: URLSessionTask  = URLSession.shared.dataTask(with: url, completionHandler: {data, response, error in
              do {
                  guard let data = data else { return }
                  guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments) as? [Any] else { return }
                  let articlesJson = jsonArray.flatMap { (json: Any) -> [String: Any]? in
                      return json as? [String: Any]
                  }
                  let articles = articlesJson.map { (articleJson: [String: Any]) -> Article in
                      return Article(json: articleJson)
                  }
                  DispatchQueue.main.async() { () -> Void in
                      self.articles = articles
                  }
              }
              catch {
                  print(error)
              }
          })
          task.resume()
      }
  }

==========================================

  import UIKit

  class ViewController: UIViewController {

      @IBOutlet weak var tableView: UITableView!
 
      private let viewModel = ViewModel()

      fileprivate var articles: [Article] {
          return viewModel.articles
      }

      override func viewDidLoad() {
          super.viewDidLoad()
          initViewModel() //追加
          viewModel.fetchArticles()
          initTableView()
      }

      private func initViewModel() { //追加
          viewModel.reloadHandler = { [weak self] in
              self?.tableView.reloadData()
          }
      }

      private func initTableView() {
          tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell")
          tableView.estimatedRowHeight = 150
          tableView.rowHeight = UITableViewAutomaticDimension
      }
  }

  extension ViewController: UITableViewDataSource {
      func numberOfSections(in tableView: UITableView) -> Int {
          return 1
      }

      func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
          return articles.count
      }

      func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
          let cell: TableViewCell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell") as! TableViewCell
          let article = articles[indexPath.row]
          cell.bindData(article: article)
          return cell
      }

      func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
          return UITableViewAutomaticDimension
      }
  }

  extension ViewController: UITableViewDelegate {
      func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
          print("section: \(indexPath.section) index: \(indexPath.row)")
      }

      func scrollViewDidScroll(_ scrollView: UIScrollView) {
          return
      }
  }

このようにreloadHandlerというクロージャを導入して実装しました。ViewModel側ではreloadHandlerの初期値としては何もしない空のクロージャを定義していて、ViewController側でtableViewをリロードするクロージャを定義してセットしています。このクロージャで書かれている[weak self]はこれも循環参照を避けるために書かれています。

このように書き換えることでtableViewをリロードするコードはViewController側に書かれているので先程の要件を満たせれたように見えましたが、実際は結局処理をViewModel側に渡しているだけなので実行するのはViewModel側になってしまします。しかしこれを実現しようとしたときにRxSwiftなどのデータバインディングのライブラリを導入する必要があります。なので今回はこのような形で止めておきます。
RxSwiftは何かについてはRxSwiftを理解するの記事で説明をしています。

動作確認

ViewModelの適用が完了したので実際にビルドしてみて書き換える前と同様に動作するかどうか確かめてみましょう。
おそらくうまくいくのではないかなと思います。

次回以降

今回でMVVMを導入することができて今後の機能追加にもある程度対応できるような設計が整ってきました。次回以降はまだ記事一覧しかなかったこのアプリに機能を追加していこうと思います。

今回のコードはこちらでも確認できるのでよかったらご確認ください。