はじめに
iOSのアーキテクチャパターンに言及する記事はすでにQiita上のみならず、ネット上にたくさん存在しています。
それらの記事は大変わかりやすく、読んだ直後はわかった気になるのですが、学んだアーキテクチャをいざ実際に使おうとなると途端になにもできなくなり、ほとんど理解していなかったことに気づくのです…。
そこで今回は、iOSの設計パターンについてきちんと勉強しなおし、備忘録とするべく、その内容を自分なりの言葉と簡単なサンプルコードでまとめてみようと思います。
本記事で取り扱う設計パターン
- MVC
- MVP
- MVVM
- Flux
作ったものとソースコード
今回作るのはごく簡単なGitHubのクライアントアプリ。GitHub APIを使い検索ワードに応じてRepositoryを一覧表示します。TableViewのCellをタップするとSafariViewControllerを使って内容を詳細表示する仕様としました。
https://github.com/TatsuhiroAbe/iOSDesignPatterns
アーキテクチャごとにブランチを用意しています。
アーキテクチャパターンを学ぶ前に
勉強を進めていくなかで、「そもそもなぜアーキテクチャパターンを開発したり利用したりするのか」をきちんと認識することがかなり重要だと感じました。
一言で言ってしまえば、責務を適切に分離するためということになるでしょうか。
ソフトウェア開発を行なっていると、開発が進むにつれて機能が増えていき、対処すべき問題がどんどん大きくなってしまいます。そこで、それらの問題をより小さな単位に分離して、出来るだけ単一の責務に向かわせようとするのが一般的ですよね。
アーキテクチャパターンというのは、その責務の切り分けを適切に行うための、文字通り「パターン」です。対処すべき問題はソフトウェアごとに違えど、その本質的な部分は多くの場合共通しています。そのような多くの場面で直面しうる責務の切り分け方をパターン化してくれたのが「アーキテクチャパターン」です。
特にiOSのアーキテクチャパターンにおいては、**Presentation Domain Separation(PDS)**というアイデアが基本になっているようです。
Presentationとは**「UIに関するロジック」であり、Domainは「システム本来の関心領域」**を指します。これらはそれぞれMV*系のアーキテクチャにおけるViewとModelに相当する部分ですね。
私も含め「結局よく分からない」という人は、「責務を適切に分離するため」、特に「UIに関するロジックとシステム本来のロジックを分離するため」という目的を意識しながらそれぞれのアーキテクチャパターンに向き合うと、やや理解が容易になるのかもしれません。
MVC
まず最初は、今回取り上げるアーキテクチャの中でもおそらく一番有名なMVC。iOSアプリに限らず、多くのWebフレームワークでも採用されているアーキテクチャです。
iOSアプリで利用されるMVCは厳密にはCocoa MVCと呼ぶそうで、よく下のような図とともに説明されています。
([https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html](https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html)から引用)前述のPDSによると、View(UIに関するロジック)とModel(ビジネスロジック)を分離することが基本的な目的になります。MVCにおいてはControllerがViewとModelを参照し、両者の仲介役となることでそれを実現します。
このような設計であることから、Controllerの部分はどうしてもいろいろな処理が集中しがちで、いわゆるFatViewControllerになりやすいです。「MVCはMassive View Controllerのことだ」という皮肉交じりの表現はかなり有名ですね。
実装上のポイントは、Modelが自らの状態更新をControllerへ通知する部分(図中の**"Notify"**の部分)だと思います。イベント通知の手段として、SwiftではDelegate、クロージャ、NotificationCenterなどを使うことが多いですが、今回はDelegateパターンによって実装しました。
protocol RepositoryModelDelegate: class {
func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository])
}
class RepositoryModel {
let BASE_URL = "https://api.github.com/search/repositories?q="
weak var delegate: RepositoryModelDelegate?
private(set) var repositories: [Repository] = [] {
didSet {
delegate?.repositoryModel(self, didChange: repositories)
}
}
func fetchRepositories(_ query: String) {
let url = URL(string: BASE_URL + query)!
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else { return }
if let error = error {
print("error: \(error.localizedDescription)")
return
}
do {
let repositoriesList = try JSONDecoder().decode(RepositoriesList.self, from: data)
self.repositories = repositoriesList.repositories
} catch let err {
print("decode error: \(err.localizedDescription)")
return
}
}
task.resume()
}
}
MVP
MVPはModel、View、Presenterという3つのレイヤーから成るアーキテクチャです。それぞれの役割はこんな感じ
- Model:他のアーキテクチャにおけるModelと同じ。ビジネスロジックを担うレイヤー。
- View:ViewControllerも含めたUIViewのサブクラス。UIイベントを受け付け、Presenterに伝える。
- Presenter:ViewとModelの仲介役。Viewからの入力を受け、Modelにコマンドを送る。Modelからの状態更新を受け取り、Viewを更新。Viewに表示するためのデータを保持するのもここ。
ViewとModelを完全に分離したいという目的はMVCと共通ですが、MVPではさらに描画処理とプレゼンテーションロジックを分離するという目的があります。
ここで言う「描画処理」というのは、label.text = newText
やtableView.reloadData()
という文字通り描画のための処理のことで、「プレゼンテーションロジック」というのは、ビューの更新やページ遷移(どの内容を表示するのかを決定する処理)などのUIのビジネスロジックを指します。
MVCではViewControllerにまとめらていたこれら2つの処理をViewとPresenterにそれぞれ分離することで、FatViewControllerを避けられるということです。
以下に示すのはサンプルのPresenterの実装です。
protocol RepositoryPresenter: class {
var view: RepositoryView? { get set }
var numberOfRepositories: Int { get }
func repository(_ row: Int) -> Repository?
func didSelectRow(at indexPath: IndexPath)
func didTapSearchButton(with searchText: String?)
}
class RepositoryViewPresenter: RepositoryPresenter {
weak var view: RepositoryView?
private(set) var repositories: [Repository] = []
private var model: RepositoryModelProtocol!
init(model: RepositoryModelProtocol = RepositoryModel()) {
self.model = model
}
var numberOfRepositories: Int {
return repositories.count
}
func repository(_ row: Int) -> Repository? {
guard row < repositories.count else { return nil }
return repositories[row]
}
func didSelectRow(at indexPath: IndexPath) {
guard let repository = repository(indexPath.row) else { return }
view?.showSafariView(repository.url)
}
func didTapSearchButton(with searchText: String?) {
guard let searchText = searchText, !searchText.isEmpty else { return }
model.fetchRepositories(searchText) { [weak self] result in
switch result {
case let .success(repositories):
self?.repositories = repositories
DispatchQueue.main.async {
self?.view?.reloadData()
}
case let .failure(error):
print(error)
}
}
}
}
ご覧にように、表示するリポジトリ一覧であるrepositories
を保持するのもこの層の役割です。
また、セルの選択や検索ボタンの押下に応じて、該当するリポジトリを返したり、Modelを呼び出してリポジトリ一覧の内容を更新したりするプレゼンテーションロジックを処理します。
MVVM
MVVMは、以下の3つのレイヤーから成るアーキテクチャです。
- Model:他のアーキテクチャにおけるModelと同じ。ビジネスロジックを担うレイヤー。
- View:UIイベントを受け付けてViewModelに伝える。ViewModelを監視し、その状態更新を受けて描画処理を行う。
- ViewModel:Viewからの入力を受け、Modelの処理を呼び出す。Modelからの状態更新を受け取り、自身の状態を更新。Viewに表示するためのデータを保持する。
Viewの説明にある「ViewModelを監視し、その状態更新を受けて描画処理を行う」という処理を可能にするのが、データバインディングと呼ばれる仕組みです。
以下がViewModelの状態更新を受けてViewを更新するために、データバインディングを行なっている部分。
class RepositoryViewController: UIViewController {
override func viewDidLoad() {
// 省略
viewModel.repositories
.bind(to: tableView.rx.items(cellIdentifier: "RepositoryCell")) { (_, repository, cell: RepositoryCell) in
cell.configure(repository)
}
.disposed(by: disposeBag)
viewModel.deselectRow
.bind(to: Binder(self) { viewController, indexPath in
viewController.tableView.deselectRow(at: indexPath, animated: true)
})
.disposed(by: disposeBag)
viewModel.openURL
.bind(to: Binder(self) { viewController, url in
let safariViewController = SFSafariViewController(url: url)
viewController.present(safariViewController, animated: true, completion: nil)
})
.disposed(by: disposeBag)
}
}
ViewControllerには、ViewとViewModelのデータバインディングのみを書くことになるので、だいぶスッキリする印象ですね。
ちなみに、上記の実装でもそうですが、iOSでMVVMというと、ほぼ確実にRxSwift(とRxCocoa)がセットで話題に上がります。
MVVM = RxSwiftというわけでは決してないのですが、MVVMをシンプルに記述するためにも、ほとんどの場合でRxSwiftが導入されるのが現状です。(そして大概RxSwiftの学習コストの高さがデメリットとして指摘されます。)
MVVMは、アーキテクチャ自体に関する理解に加え、RxSwiftの理解とその背後にあるObserverパターンの理解を求められるという点で、広く使われてる割には導入コストが高いアーキテクチャパターンだと感じました。
Flux
([https://facebook.github.io/flux/docs/in-depth-overview/](https://facebook.github.io/flux/docs/in-depth-overview/)から引用)- Action:実行する処理を特定するためのtype(例:"update_repository"のようなラベル)と、それに関連するdata(例:update後の値)がセットになったもの([type : data])。
- Dispatcher:Actionを受け取り、その値をStoreに伝える。
- Store:Dispatcherから伝わってきたActionを受けて、自身の状態を更新して通知。
- View:Storeの状態を監視し、その状態更新を受けて画面を更新。
Fluxアーキテクチャの特徴は**「単一方向のデータフロー」**という考え方です。
Fluxでは、常にView→Action→Dispatcher→Store→Viewという一方向のデータフローが生成されます。このことのメリットは、アプリケーションが複雑化したり、それに伴って開発者が増えたとしてもデータフローが追いやすく、保守性の高いコード書けることだそうです。
今回作ったアプリは機能的にすごくシンプルなものだったため、このメリットはほとんど享受できていません。そればかりか、慣れない複雑な設計を用いたために、実装しづらかったというのが正直な感想です。
エンジニアであれば、「よりスケールしやすい新しくて重厚なアーキテクチャパターンを使いたい」という気持ちは多かれ少なかれあるでしょうが、アーキテクチャパターンの選定においては、開発するアプリの性質やメンバーの習熟度なども考慮しなければならないと考えると難しいところですね。
実装に関しては、こちらのコードを参考にさせていただきました。
感想
一言で感想を述べると、「やっぱり設計は難しい!!」ということにつきますね。
今回取り上げたものの他に、ReduxやClean Architecture, VIPERなども今では主要なアーキテクチャパターンとして知られています。
アーキテクチャパターンを勉強しようとする際の大きな動機の1つとして、「開発するソフトウェアの性質に応じた適切な設計ができるようになりたい」というものがあるでしょう。
私も例外ではなくそう思っていたのですが、主なものだけでもこれだけあるアーキテクチャパターンを十分に理解し、実装できるだけの技術力を身につけ、その上で適切な設計を行えるようにするというのは実際かなり険しい道のりになりそうです…