こんにちは!Life is Tech ! #1 Advent Calendar 2020の4日目を担当しますiPhoneメンターのろばーとです!
アプリ開発を学んでいく上で設計アーキテクチャについて単語を目にし概念を理解しても、それを実際どうやって実装しているかについての(日本語での)記事って何だかんだ少ないなぁと感じたので、自分の復習も兼ねて「手を動かしてMVPでアプリを作ってみる」記事を書いていきます!
なぜMVPかと言うと、これは個人的な気持ちですが
- 対MVC
- MVCはiOSにViewControllerがあるため命名が被っていて理解がしにくい
- とにかく脱FatViewControllerがしたい
- 対MVVM, Reduxなど
- 個人開発のアプリの規模では余程大規模になっていない限りオーバーキル感がある
- なるべく初心者向けに書きたい気持ち
- こいつらを1記事で読めるボリュームに抑えて解説するのは難しい
あたりの理由になります。
対象読者っぽい層は
- FatViewControllerでひと通り自作アプリを開発したことがあるiOS開発者
- よちよち〜駆け出しくらいで設計パターンについて知りたいiOS開発者
- 他プラットフォームで開発経験があるのでiOSでそれなりのコードの綺麗さ、アーキテクチャを抑えながらサクッと何か作ってみたいプログラマー
あたりになるかなぁと思います!よろしくお願いします!
はじめに
本題に入っていく前に、アプリ開発における設計アーキテクチャの立ち位置や有用性について書いておきたいと思います。
なぜ設計アーキテクチャが存在するのか
ソフトウェア開発の根幹にある考え方の一つとして「関心の分離」という概念があります。
ザックリ言うと、
一つの(アプリケーション)ソフトウェアのシステムは全体で何かしらの問題への解決手段を提供する。
しかし、その複雑な問題はより単純な問題(画面に表示したい、ユーザー入力を受け付けたい、データを処理したい、通信したい etc...)の群で構成されているため、システム側も単純な問題にだけ対処する部品を組み合わせて構築しよう。
という考え方です。
この「関心の分離」を実現する原則の1つとして「プレゼンテーションとドメインの分離(PDS: Presentation Domain Separation)」というものがあります。
プレゼンテーションは「UIに関係するロジック」、ドメインは「システム本題の関心領域」を指していて、「システム本来の関心領域(ドメイン)を、UI(プレゼンテーション)から引き離す」というのがPDSの考え方です。
よく聞くMVC、MVM、MVVMといった設計アーキテクチャは全てこのPDSの実現を目指したGUIアーキテクチャと呼ばれるもので、その方法が少しずつ異なっているだけです。
詳しくはPEAKSから出版されているiOSアプリ設計パターン入門を読んでみてください。
PEAKS(ピークス)|iOSアプリ設計パターン入門
雑にまとめると、
全部ViewControllerに書いてると次第に何が何だか分からなくなってしまうから、適切な処理の書き分け方の代表例を知っておいてそれを使いこなせると読みやすい、機能の追加がしやすい、バグを生みにくい/見つけやすいコードを書けるよ!
ってお話です。
MVPをザックリ知る
MVPパターンはModel-View-Presenterの略で、その名前の通りアプリケーション内のコードをModel, View, Presenterの3つの役割に分割します。
この記事のコンセプトは**"手を動かして"MVPパターンを知ってもらうこと**ですが、何も知らない状態でコードを書いても仕方ないので、MVPのイメージをザックリとでも掴んでおきましょう。
(今回説明するMVPはPassive Viewと呼ばれるパターンのものになります。)
Model
ModelはUIに関係しない純粋なドメインロジックを持ちます。
ドメインロジックとは、画面表示がどのようなものでも共通なアプリの機能実現のための処理です。
また、ModelはPresenterからのみアクセスされ、Viewとは直接の関わりを持たないようにします。
View
Viewはユーザ操作の受け付けと、画面表示だけを担当します。
タップやスワイプなどによるUIイベントを受け付け、Presenterに処理を任せます。
そのため、ViewはPresenterからの描画指示に従うだけで、描画時の設定を行うだけの受け身な立場になります。
Presenter
PresenterはViewとModelの橋渡し役となり以下の働きをします。
- アプリの動作に必要な処理をModelに伝える
- 画面表示(プレゼンテーション)に必要なデータを保持する
- Viewに描画指示を出す
MVPのまとめ
ここまで見てきたMVPにおける役割分担を図にすると下のようになります。
もし、今までFatViewControllerで書いてきていた場合、基本的にViewControllerがViewの役割になり、ModelとPresenterを置くと思うと良いのかなと思います!
MVPを体験する
MVPについて何となく分かったところで、実際にMVPでアプリを作ってみましょう!
今回はTODOアプリを作っていこうと思います!
この手のサンプルでよく登場するカウントアプリよりも歯ごたえがあり、理解の助けになりそうなものにしてみたつもりです。
作るもの
- 簡単なTODOアプリ
- データはString型の配列としてUserDefaultsに保存
- TextFieldに入力された内容をボタンタップでTableViewに追加
- TableView上でセルをスワイプすると削除
- UIはStoryboardで作成
(サンプル感まる出しのUIですがそこはご愛嬌ということで...)
完成版のコードはGitHubに置いています!
以下、下記のような流れでアプリの実装を進めていきます!
ちょっと長くなってますが実際に書きながら進めてもらえると良いかなと思います!
- 0.MVPでのプロジェクト構成
- 1.表示機能を作る
- 2.追加機能を作る
- 3.削除機能を作る
0. MVPでのプロジェクト構成
まず初めにプロジェクト全体の構成を見ておきましょう。
ファイル構成
画像のように
-
TodoModel.swift
にModelの処理 -
TodoListViewController.swift
にViewの処理 -
TodoListPresenter.swift
にPresenterの処理
Storyboard
初期化処理
モデル、ビュー、プレゼンターの紐付けや初期化処理は以下のように準備します。
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let view = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! TodoListViewController
let model = TodoModel()
let presenter = TodoListPresenter(view: view, model: model)
view.inject(presenter: presenter)
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = view
window?.makeKeyAndVisible()
return true
}
}
class TodoListViewController: UIViewController {
private var presenter: TodoListPresenterInput!
func inject (presenter: TodoListPresenterInput) {
self.presenter = presenter
}
}
final class TodoListPresenter: TodoListPresenterInput {
private weak var view: TodoListPresenterOutput!
private var model: TodoModelInput
init(view: TodoListPresenterOutput, model: TodoModelInput) {
self.view = view
self.model = model
}
また、SeceneDelegateの設定は削除しておきましょう。
参考:iOS13でSceneDelegateを使わないでアプリを作る
1. 表示機能を作る
まずはじめにアプリ起動後にTODOをTableViewに表示する部分までの機能を作っていきます。
①アプリ起動(View)
ViewControllerでビューがロードされるとViewDidLoad()
が呼び出されます。
この時にPresenterに実装するviewDidLoad()
を呼び出して画面表示に必要なデータの準備をPresenterで行います。
class TodoListViewController: UIViewController {
/*
0.で書いた初期化処理
*/
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
presenter.viewDidLoad()
}
}
②TODOリストのデータ取得(Presenter)
Viewに呼び出されたviewDidLoad()
内の処理では、画面に表示するTODOのデータの取得を行います。
ModelのfetchItems()
を呼び出すことで、Modelでデータの取得を行い、返り値を自身の変数items
に保持します。
protocol TodoListPresenterInput {
func viewDidLoad()
}
final class TodoListPresenter: TodoListPresenterInput {
private(set) var items: [String] = []
/*
0.で書いた初期化処理
*/
func viewDidLoad() {
items = model.fetchItems()
}
}
③TODOリストのデータを返す(Model)
Presenterに呼び出されたfetchItems()
内ではTODOのデータを取得してPresenterに渡す必要があります。
TODOのデータはUserDefaultsにString型の配列として保存しているので、fetchItems()
にはUserDefaultsから配列を取り出して返り値とする実装をします。
protocol TodoModelInput {
func fetchItems() -> [String]
}
final class TodoModel: TodoModelInput {
private let userDefaluts = UserDefaults.standard
private let ITEM_KEY = "TodoItems"
func fetchItems() -> [String] {
return userDefaluts.array(forKey: ITEM_KEY) as! [String]
}
④TableViewの表示更新指示(Presenter/View)
Presenterでの処理
fetchItems()
でModelから表示すべきデータを得たので、同じviewDidLoad()
内でViewに実装するupdateItems()
を呼び出してViewに表示の更新指示を出します。
また、Viewでの表示に必要となる、TODOのデータの数を返す変数numberOfItems
と、中身を返すメソッドitem(forRow row: Int)
を準備しておきます。
protocol TodoListPresenterInput {
var numberOfItems: Int{get}
func item(forRow row: Int) -> String?
func viewDidLoad()
}
protocol TodoListPresenterOutput: AnyObject {
func updateItems()
}
final class TodoListPresenter: TodoListPresenterInput {
private(set) var items: [String] = []
/*
0.で書いた初期化処理
*/
var numberOfItems: Int {
return items.count
}
func item(forRow row: Int) -> String? {
guard row < items.count else {
return nil
}
return items[row]
}
func viewDidLoad() {
items = model.fetchItems()
view.updateItems()
}
}
Viewでの処理
ViewではTableViewにTODOの内容を表示するためにUITableViewDataSourceのデリゲートメソッドの実装を行います。
この時、numbersOfRowsInSection
やcellForRowAt
で必要なデータはPresenterに準備したnumberOfItems
とitem(forRow row: Int)
を利用します。
class TodoListViewController: UIViewController {
/*
0.で書いた初期化処理
*/
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
presenter.viewDidLoad()
}
}
extension TodoListViewController: TodoListPresenterOutput {
func updateItems() {
tableView.reloadData()
}
}
extension TodoListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return presenter.numberOfItems
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: "cell")
cell.textLabel?.text = presenter.item(forRow: indexPath.row)
return cell
}
}
これでアプリ起動後にTODOを画面に表示するまでの機能が完成しました。
2. 追加機能を作る
次にTODOを追加する機能を作っていきます。
①ボタンタップ(View)
画面上で追加ボタンがタップされた時の処理をtappedAddButton()
に記述します。
newItemTextField
が空白でない場合にPresenterのaddNewItem(itemContent: String)
を呼び出しています。
TextFieldの入力内容を引数で渡すことで、ViewからPresenterへ追加するTODOのデータの受け渡しを行います。
※StoryBoard上での関連付けを忘れないようにしましょう。
class TodoListViewController: UIViewController {
/*
1.までで書いた処理
*/
@IBOutlet weak var newItemTextField: UITextField!
/*
1.までで書いた処理
*/
@IBAction func tappedAddButton() {
if !newItemTextField.text!.isEmpty {
presenter.addNewItem(itemContent: newItemTextField.text!)
newItemTextField.text = ""
}
}
}
②データの追加指示/④TableViewの表示更新指示(Presenter)
addNewItem(itemContent: String)
ではModelのaddItem(itemContent: String, completion: () -> ())
を呼び出して追加指示を行います。
Viewから受け取った入力内容の文字列をそのままModelに渡してデータの受け渡しを行います。
また、addItem()
のクロージャ内で再度ModelからTODOの配列データを取得し、Viewへの描画更新指示を行うことで、画面表示の更新を行います。
Viewで行うことはPresenterが持つデータにアクセスして画面に表示することのみであり、この処理は1.で完成しているので新しくコードを書き足す必要はありません。
protocol TodoListPresenterInput {
var numberOfItems: Int{get}
func item(forRow row: Int) -> String?
func addNewItem(itemContent: String)
func viewDidLoad()
}
protocol TodoListPresenterOutput: AnyObject {
func updateItems()
}
final class TodoListPresenter: TodoListPresenterInput {
private(set) var items: [String] = []
/*
1.までで書いた処理
*/
func addNewItem(itemContent: String) {
model.addItem(itemContent: itemContent) {
items = model.fetchItems()
view.updateItems()
}
}
③データ追加(Model)
Modelでのデータ追加の処理は以下のようになります。
追加処理の完了を呼び出し元であるPresenterに知らせて(コールバックして)、Viewの表示更新指示に処理を進めるためにクロージャを利用しています。
protocol TodoModelInput {
func fetchItems() -> [String]
func addItem(itemContent: String, completion: () -> ())
}
final class TodoModel: TodoModelInput {
private let userDefaluts = UserDefaults.standard
private let ITEM_KEY = "TodoItems"
/*
1.までで書いた処理
*/
func addItem(itemContent: String, completion: () -> ()) {
var items = userDefaluts.array(forKey: ITEM_KEY) as! [String]
items.append(itemContent)
userDefaluts.set(items, forKey: ITEM_KEY)
completion()
}
これでTextFieldの入力内容をTODOに追加する機能が完成しました。
3. 削除機能を作る
最後にセルスワイプでTODOを追加する機能を作っていきます!
これは先ほど作った追加機能とほぼ同じ流れで実現できます!
MVPでの処理の流れは以下の図のようになります。(追加が削除になっただけ)
①セルスワイプ(View)
セルスワイプ時の処理を追加するために必要なUITableViewDelegateのデリゲートメソッドの実装を行います。
canEditRowAt
でスワイプアクションを有効にし、
commit editingStyle
でPresenterのdidEditingDelete(at indexPath: IndexPath)
を呼び出します。
引数に操作されたTableViewのindexPathを渡すことで、ViewからPresenterにどの行が操作されたのかの情報を渡します。
class TodoListViewController: UIViewController {
/*
2.までで書いた処理
*/
override func viewDidLoad() {
/*
2.までで書いた処理
*/
tableView.delegate = self
}
}
extension TodoListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
presenter.didEditingDelete(at: indexPath)
}
}
}
②データの削除指示/④TableViewの表示更新指示(Presenter)
didEditingDelete(at indexPath: IndexPath)
ではModelのdeleteItem(at index: Int, completion: () -> ())
を呼び出すことでデータの削除指示を行います。
Viewから受け取ったindexPathからセルの順番を取得し(indexPath.row
)、引数として渡すことでModelに削除するデータの配列内での順番を伝えます。
また、追加処理の実装時と同様に、クロージャ内で再度ModelからTODOの配列データを取得し、Viewへの描画更新指示を行うことで、画面表示の更新を行います。
protocol TodoListPresenterInput {
var numberOfItems: Int{get}
func item(forRow row: Int) -> String?
func addNewItem(itemContent: String)
func didEditingDelete(at indexPath: IndexPath)
func viewDidLoad()
}
protocol TodoListPresenterOutput: AnyObject {
func updateItems()
}
final class TodoListPresenter: TodoListPresenterInput {
private(set) var items: [String] = []
/*
2.までで書いた処理
*/
func didEditingDelete(at indexPath: IndexPath) {
model.deleteItem(at: indexPath.row) {
items = model.fetchItems()
view.updateItems()
}
}
③データ削除(Model)
Modelでのデータ削除の処理は以下のようになります。
追加処理と同様に、削除処理の完了を呼び出し元であるPresenterに知らせて(コールバックして)、Viewの表示更新指示に処理を進めるためにクロージャを利用しています。
protocol TodoModelInput {
func fetchItems() -> [String]
func addItem(itemContent: String, completion: () -> ())
func deleteItem(at index: Int, completion: () -> ())
}
final class TodoModel: TodoModelInput {
private let userDefaluts = UserDefaults.standard
private let ITEM_KEY = "TodoItems"
/*
2.までで書いた処理
*/
func deleteItem(at index: Int, completion: () -> ()) {
var items = userDefaluts.array(forKey: ITEM_KEY) as! [String]
items.remove(at: index)
userDefaluts.set(items, forKey: ITEM_KEY)
completion()
}
これでセルスワイプでTODOを削除する機能が完成しました。
ここまでで今回示したサンプルアプリの全ての機能が完成です!
最後に
アプリ開発に慣れてきたという人にとって、設計パターンは初めはとっつきにくいですが、一度理解してしまうとパズル感覚で綺麗な実装を進めていける強力な武器になるはずです!
まずは腰を据えて自分で手を動かしてみることで理解が一気に深まるので、今回の記事がその一助になれば幸いです!