今回はアプリのアーキテクチャについての記事になります。
ソフトウエアのアーキテクチャというと様々な提案があります。iOSアプリ開発になると、アップルが推奨するMVCが一番よく使われているでしょう。
MVCと課題
MVCとはモデル(データ)・ビュー(ユーザインターフェース)・コントローラー(ロジック)という3つの責任にコンポーネントに分別するソフトウエアデザインパターンの一つです。
ビューはモデルのデータを表示して、コントローラーはビューとモデルを結びつくロジックを持っているコンポーネントです。
コントローラーは多くのUIのロジックとデータの処理のロジック両方を持ってコードの行数が簡単に何千行に上ります。iOSのMVCは実際にM + VC、つまり、ビューとコントローラーは一つになっているわけですね。
ビジネスロジックとUIロジックが混合しているとユニットテストも書きにくくなります。どちらかというと書かないようになってしまうことが多いですね。
そうなるとコードのメンテナンス性とテスト性が低下してしまい技術的負債が増え続けるパターンになりがちです。
太くなってしまうコントローラーが業界中にファットコントローラー、MVCはMassive View Controllerと呼ばれるようになってますね。
MVVMでコントローラーをダイエットさせましょう
ファットコントローラーを痩せたコントローラーにするには様々な提案がありますが、今回はMVVMを紹介したいと思います。
MVVMのパターンは下記のようにアプリのコンポーネントを分別します。
- M(モデル):情報を持つエンティティ。
- V(ビュー):ビューとビューコントローラを含めます。ビューを管理する以外のロジックは持ってない。
- VM(ビューモデル):モデルを取得して表示用にデータを変換する
ビュー(コントローラー)は情報の表示とインプットの受け付けしか行いません。表示のフォーマットとインプットのアクションとその他の情報処理はビューモデルに任せます。ビューはモデルについて何も知りません。つまり、モデルのインスタンスは持ちません。
ビューモデルでは表示のロジックを行いますがUIについて何も知りません。UIKitインポートしないし、ビューのインスタンスも持ちません。 ビューモデルがモデルから取得した情報を加工してビューに提供します。
モデルは情報しか持ってないです。Swiftだとstruct
で実装するのは一般的です。
ここは疑問一つ挙げられることがあると思います。もしビューモデルは表示用にデータを取得して処理するなら、ビューを参照しないでどうやって画面を更新するのか?
答えはデータバインディングが仕込まれているのです。
データバインディング
データバインディングの仕組みを使うと、データがビューと連動させられてデータが変わったら自動的にビューに反映されます。
iOSはデータバインディングを対応してないので外部ライブラリを導入する必要があります。ReactKit/Bond、ReactiveCocoa、RxSwiftなどありますが、今回はRxSwiftを使います。
RxSwiftはバインディングだけではなく非同期処理やイベント処理を宣言的に書けるライブラリです。サンプルコードで使う分の解説はしますが、RxSwiftについて詳しく知りたい方は初めてのRxSwiftの発表資料や参考文献の節にあるリンクをご確認いただければ幸いです。
実際にMVVMどうなる?
サンプルアプリは単純にAPIサービスを利用して画像とテキストを取得して表示するだけですが、簡単なソースコードになりますのでMVVMはどうなっているのかわかりやすいと思います。実際のアプリには足りないところが多いですが、MVVMの基本的な仕組みの理解には十分かと思います。要求あれば今後の記事で拡張していきたいと思います。
今回はXKCDというギークなコミックサイトのAPIを使ってコミックを表示します。サンプルコードはこちらからダウンロードできます: xkcd観覧アプリ
さて、コードを追って解説しましょう。
ビュー
ComicViewController
のviewDidLoad
でviewModel
の初期化とデータバインディングを行います。ViewModelからとViewModelへのバインディングは2パターンがあります。ViewModelからはビューモデル側でデータが更新になったらビューを更新するようにバインディングします。
例えば
comicViewModel.title.asDriver().drive(titleLabel.rx.text).disposed(by: disposeBag)
ビューモデルがコミックのタイトルを取得できたらRxの力でタイトルラベルに自動的に反映されるように設定します。
Driver
はRxSwiftの世界で監視可能なオブジェクトのことです。最後にあるディスポーザはメモリ管理の為に追加します。
ViewModelへのところではボタンの処理(前後のコミックを依頼する)の設定と最新のコミックを取得する依頼を行いす。
ボタンのハンドリングはIBActionでも良いですが、Rxを使うとコンパクトにviewDidLoad()の中に書けます。
nextButton.rx.tap.asDriver().drive(onNext: { self.comicViewModel.getNextComic() }).disposed(by: disposeBag)
実際の処理はcomicViewModel
に任せます。
ビューコントローラーは以上でビューのデータバインディングとボタンのタップイベントを受けるだけのコードになります。
ビューモデル
ビューモデルはデータを取得して表示用に処理します。まずはデータバインディングできるようにRxの監視型プロパティーを用意します。例えばタイトルは下記のように宣言します。
var title: Variable<String>
ビューコントローラ側にデータバインディング(drive(..)
のところ)を設定してますのでtitle.value = "タイトルです"
だけで自動的にtitleLabel
に反映されます。
文字列はそんな感じですが、次へ、前へのボタンは無効になったり有効になったりしますね。それはisNextEnabled
/isPreviousEnabled
のドライバーで実現します。監視型のVariable
で最新号と観覧中のコミックを持っていて、isNextEnabled
はcombineLatest
でその両方が同じの場合(見ているコミックは最新)はfalse
を返します。isNextEnabled
はnextButton
のenabled
プロパティーにバインドされてますので自動的に反映されます。
isNextEnabled = Driver.combineLatest(self.latestComicNum.asDriver(), self.currentComic.asDriver(), resultSelector: { (latestNum, current) -> Bool in guard let latestNum = latestNum, let currentNum = current?.num else { return false } return latestNum != currentNum }).distinctUntilChanged()
isPreviousEnabled
はコミック番号が1かどうかを確認するだけですのでmap
で適切なブールを返します。
isPreviousEnabled = currentComic.asDriver().map({ (comic) -> Bool in guard let num = comic?.num else { return false } return num > 1 }).distinctUntilChanged()
distinctUntilChanged
は回覧中が変わる度にブールの値が変わらなければ無駄にenabled
のバインディング処理が起こさないようにあります。
ビューモデルの初期化は以上で終わります。他の関数はapiサービスの呼び出しとビューモデルの情報の更新です。
ApiサービスもRx化になっているのでsubscribe(onNext:)
でコミックの情報を取得します。情報はComic
モデルクラスの形で来ますが、モデルから表示用に変えるのがupdateViewModel
というヘルパー関数で行います。日付のフォーマットを変えたり画像のurlのstring
をURLオブジェクトに変換します。
func getLatestComic() { service.getLatestComic().subscribe(onNext: { (comic) in guard let comic = comic else { return } self.latestComicNum.value = comic.num self.updateViewModel(comic: comic) }).disposed(by: disposeBag) }
各Variable
のvalue
をアサインすると、Rxのバインディングで画面が更新されます。
private func updateViewModel(comic: Comic) { self.currentComic.value = comic self.title.value = comic.title ?? "" if let urlString = comic.img, let url = URL(string: urlString) { self.imageUrl.value = url } if let date = comic.date { self.date.value = formatter.string(from: date) } else { self.date.value = "" } }
モデル
モデルは生のデータしか持ってないstruct
です。引数としてDictionary
を渡せるコンストラクタもありますが、基本的にはロジックが持ちません。ビジネスロジックはビューモデルか別のクラスに委任すると良いです。例えばコミックを取得するのはComicService
の役割で、それを利用するのはモデルではなくビューモデルですね。
何かのロジックを入れるとしたら、内部のデータの形式の変更やデータのバリデーションぐらいなら良いかと思います。
TODO
エラーハンドリング
このサンプルコードは未完成なプロジェクトになっています。エラーハンドリングは行っていません。実装のアプローチとしてビューモデルからアラートを出すのは一番簡単ですが、エラーの場合にビューを更新したい場合はエラーの状態を持つ監視型のプロパティーをビューモデルに追加してビューコントローラーでバインディングしてビューを更新されるのが考えられますね。
テスト
テストコードも一切書いてないですね。書くとしたら主にビューモデルのテストを書いたらカバレッジの高いテストになります。ビューコントローラーにはロジックがなくて、ビューモデルにはテストの難しいビューもありませんのでテストが書きやすくなっています。
テストに関するもう一つ改善できるところはビューモデルが持つComicService
のインスタンスです。現状のコードだとビューモデルでインスタンスを生成してますが、DI(依存性注入)を使ってインスタンスをビューモデルに渡すとテストのターゲットの際、テスト用のモックサービスに入れ替えて通信なし気楽にテストできるようになります。
それらの課題は今後、の記事の続きにしたいと思います。