SwiftとUIKitでMVCからMVPのアーキテクチャ変更
こんにちは。ママさんエンジニアのトモヨです。
お仕事でゴリゴリMVC(Model-View-ViewController)のコードを書いていましたが、どうしてもテストがしづらい。ということでMVVM(Model-View-ViewModel)のコードを目指したいと思います。
ただ一旦MVP(Model-View-Presenter)に置き換えるとスムーズに移行しやすいとお話を聞いたので、
MVPに置き換えるコードを書いてみました。
アプリの内容としてはSwiftUIチュートリアルの一部をUIKitに置き換えたものです。TableViewを作成し、jsonから読み込んだデータを表示します。
またfavoriteのみの表示切り替えもできます。
一般的なMVCのコードです。ViewControllerがモリモリしてます。
Presenterの実装
このC部分を一部切り離しPresenterとして実装します。
PresenterはUIKitをインポートしない
Input、Outputのプロトコルを作成する
PresenterはInputをViewControllerはOutputのプロトコルを実装する
import Foundation
protocol ContentViewPresenterInput {
var showFavoritesOnly: Bool { get }
var numberOfRowsInSection: Int { get }
var filteredLandmarks: [Landmark] { get }
func landmark(forRow row: Int) -> Landmark?
func viewDidLoad()
func didSelectRowAt(_ indexPath: IndexPath)
}
protocol ContentViewPresenterOutput: AnyObject {
func didFetch(_ landmarks: [Landmark])
func didPushViewController(of landmark: Landmark)
}
class ContentViewPresenter: ContentViewPresenterInput {
private let modelData = ModelData()
private weak var vc: ContentViewPresenterOutput?
var showFavoritesOnly = false
let numberOfSections = 1
enum AdditionalCell: String, CaseIterable, Codable {
case favorite = "Favorites only"
}
init(_ view: ContentViewPresenterOutput) {
self.vc = view
}
var numberOfRowsInSection: Int {
return AdditionalCell.allCases.count + filteredLandmarks.count
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter({ landmark in
(!showFavoritesOnly || landmark.isFavorite)
})
}
func additionalCell(forRow row: Int) -> AdditionalCell? {
guard row < AdditionalCell.allCases.count else { return nil }
return AdditionalCell.allCases[row]
}
func landmark(forRow row: Int) -> Landmark? {
// 一番上はAdditionalCellが付与されているのでその分は引く
let index = row - AdditionalCell.allCases.count
guard index < filteredLandmarks.count else { return nil }
return filteredLandmarks[index]
}
func viewDidLoad() {
vc?.didFetch(filteredLandmarks)
}
func didSelectRowAt(_ indexPath: IndexPath) {
guard let landmark = landmark(forRow: indexPath.row) else { return }
vc?.didPushViewController(of: landmark)
}
}
データ周りは一通り移植できました。
ViewControllerはUITableViewDataSourceあたりがスッキリしました。
import UIKit
final class ContentViewController: UIViewController {
private let tableView = UITableView()
private var presenter: ContentViewPresenter!
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
view.addSubview(tableView)
presenter = ContentViewPresenter(self)
presenter.viewDidLoad()
}
override func viewWillLayoutSubviews() {
tableView.frame.size = UIScreen.main.bounds.size
super.viewWillLayoutSubviews()
}
}
extension ContentViewController: ContentViewPresenterOutput {
func didFetch(_ Landmark: [Landmark]) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.tableView.reloadData()
}
}
func didPushViewController(of landmark: Landmark) {
// TODO: 詳細画面へ遷移
}
}
extension ContentViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 50
}
}
extension ContentViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return presenter.numberOfRowsInSection
}
func numberOfSections(in tableView: UITableView) -> Int {
return presenter.numberOfSections
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard indexPath.section == 0 else {
return UITableViewCell()
}
if let additionalCell = presenter.additionalCell(forRow: indexPath.row) {
let cell = FavoriteOnlyCell(showFavoritesOnly: presenter.showFavoritesOnly)
cell.delegate = self
cell.textLabel?.text = additionalCell.rawValue
return cell
}
guard let landmark = presenter.landmark(forRow: indexPath.row) else { return UITableViewCell() }
let cell = LandmarkCell(landmark: landmark)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
}
}
extension ContentViewController: FavoriteOnlyCellDelegate {
func favoriteOnlyCellSwitch(toggle: UISwitch) {
presenter.showFavoritesOnly = toggle.isOn
tableView.reloadData()
}
}
考えたこと
ViewControllerコード行数は変化はさほどないですが、処理の重さとしてはViewControllerの負担が減ったので良かったと思います。
これからMVVMにしてみようと思います。
最終的にMVVMのアーキテクチャを採用したいのですが、学習コストやプロジェクト導入の負荷がどれくらいかかるのか判断したいです。
細かいことですが、FavoriteOnlyCellDelegateはPresenterが処理してもいいと思いました。
変数宣言時にContentViewPresenter!のアンラップは迷いましたが作成に失敗することはないので、使用するときに都度guard letするよりはスマートかなと思い、やっています。