見出し画像

SwiftとUIKitでMVCからMVPのアーキテクチャ変更

こんにちは。ママさんエンジニアのトモヨです。
お仕事でゴリゴリMVC(Model-View-ViewController)のコードを書いていましたが、どうしてもテストがしづらい。ということでMVVM(Model-View-ViewModel)のコードを目指したいと思います。
ただ一旦MVP(Model-View-Presenter)に置き換えるとスムーズに移行しやすいとお話を聞いたので、
MVPに置き換えるコードを書いてみました。

アプリの内容としてはSwiftUIチュートリアルの一部をUIKitに置き換えたものです。TableViewを作成し、jsonから読み込んだデータを表示します。
またfavoriteのみの表示切り替えもできます。

以下がMVCのコードです

一般的な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 ContentViewPresenterOutputAnyObject {
    func didFetch(_ landmarks: [Landmark])
    func didPushViewController(of landmark: Landmark)
}

class ContentViewPresenterContentViewPresenterInput {
    private let modelData = ModelData()
    private weak var vc: ContentViewPresenterOutput?
    
    var showFavoritesOnly = false
    
    let numberOfSections = 1
    
    enum AdditionalCellStringCaseIterableCodable {
        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 ContentViewControllerUIViewController {
    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 ContentViewControllerContentViewPresenterOutput {
    func didFetch(_ Landmark: [Landmark]) {
        DispatchQueue.main.async { [weak selfin
            guard let self = self else { return }
            self.tableView.reloadData()
        }
    }
    
    func didPushViewController(of landmark: Landmark) {
        // TODO: 詳細画面へ遷移
    }
}

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

extension ContentViewControllerUITableViewDataSource {
    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 ContentViewControllerFavoriteOnlyCellDelegate {
    func favoriteOnlyCellSwitch(toggle: UISwitch) {
        presenter.showFavoritesOnly = toggle.isOn
        tableView.reloadData()
    }
}

考えたこと

ViewControllerコード行数は変化はさほどないですが、処理の重さとしてはViewControllerの負担が減ったので良かったと思います。
これからMVVMにしてみようと思います。
最終的にMVVMのアーキテクチャを採用したいのですが、学習コストやプロジェクト導入の負荷がどれくらいかかるのか判断したいです。

細かいことですが、FavoriteOnlyCellDelegateはPresenterが処理してもいいと思いました。

変数宣言時にContentViewPresenter!のアンラップは迷いましたが作成に失敗することはないので、使用するときに都度guard letするよりはスマートかなと思い、やっています。

いいなと思ったら応援しよう!