iOS Architecture Patterns 뿌시기: Model View Presenter (MVP)

18 분 소요

Thanks to Radu Dan for allowing the translation.

Reference: Battle of the iOS Architecture Patterns: Model View Presenter (MVP)

본 글은 위의 내용에 대한 번역본입니다.

순서는 다음과 같습니다.

  1. MVC
  2. MVVM
  3. MVP
  4. MVP-C
  5. VIPER
  6. VIP

iOS Architecture Patterns 뿌시기: Model View Presenter (MVP)

아키텍처 시리즈 — Model View Presenter (MVP)

아키텍처 시리즈 — Model View Presenter (MVP)

동기 (Motivation)

iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.

시리즈의 세번째 글에서 MVVM 앱을 MVP 로 변환할 것입니다. 평소와 같이 각 화면에 패턴을 어떻게 적용하는지 보고 실제 구현과 소스코드를 볼 것입니다. 마지막에는 다른 아키텍처 패턴과 비교해서 MVP 에 대한 빌드시간과 주요 관찰사항을 살펴볼 것입니다.

코드를 보기 원한다면 이 글을 건너뛰어도 됩니다. 코드는 GitHub 에 오픈 소스로 공개되어 있습니다.

iOS 앱에 아키텍처 패턴이 필요한 이유? (Why an Architecture Pattern for Your iOS App?)

고려해야 할 가장 중요한 것은 유지될 수 있는 앱을 가지는 것입니다. View 가 있다는 것을 알고 View Controller 가 Y 가 아닌 X 를 수행해야 한다는 것도 알고 있습니다. 더 중요한 것은 다른 사람들도 알고 있다는 것입니다.

좋은 아키텍처 패턴을 고르는 것에 대한 이점은 다음과 같습니다:

  • 유지보수가 쉬움
  • 비지니스 로직을 테스트하기 쉬움
  • 다른 팀원들과 공통 언어로 개발
  • 역할 분리 (각 기능별 분리)
  • 더 적은 버그

요구사항 정의 (Defining the Requirements)

6개 또는 7개의 화면을 가진 iOS 애플리케이션이 주어지고 iOS 세계에서 가장 유명한 아키텍처 패턴을 사용하여 개발할 것입니다: MVC, MVVM, MVP, VIPER, VIP, 그리고 Coordinators.

데모앱은 Football Gather 라고 불리며 아마추어 축구 경기의 점수를 추적하는 간단한 앱입니다.

주요 요소 (Main features)

가능한 것:

  • 앱에 선수 추가
  • 선수에게 팀 할당
  • 선수 수정
  • 경기에 대한 카운트다운 타이머 설정

화면 목업 (Screen mockups)

iOS 앱인 "Football Gather" 에 화면 목업

iOS 앱인 “Football Gather” 에 화면 목업

백엔드 (Backend)

이 앱은 Vapor web framework 로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial articlearticle about Migrating to Vapor 4 에서 앱을 확인할 수 있습니다.

MVP 란? (What is MVP)

MVP 는 MVVM 과 약간 비슷하지만 몇가지 주요사항이 있습니다:

  • 이제 presenter 레이어가 있습니다.
  • presenter 레이어로 view 를 제어할 수 있습니다.

Model

  • Model 레이어는 다른 아키텍처와 동일하고 비지니스 데이터를 캡슐화 하는데 사용됩니다.
  • 도메인 데이터를 담당하는 인터페이스 입니다.

통신 (Communication)

  • 예를 들어 사용자가 액션을 시작하는 것과 같이 view 레이어에서 어떠한 것이 발생하면 Presenter 를 통해 model 로 전달됩니다.
  • 예를 들어 새로운 데이터를 사용할 수 있고 UI 를 업데이트 해야하는 경우와 같이 model 이 변경되면 Presenter 는 View 를 업데이트 합니다.

View

  • View 레이어는 MVVM 에서와 같지만 View 는 이제 상태 업데이트에 대해 책임이 없습니다. presenter 는 View 를 소유합니다.

통신 (Communication)

  • View 는 Model 과 직접적으로 통신할 수 없고 모든 것은 Presenter 를 통해서 수행됩니다.

Presenter

  • view 에서 오는 이벤트를 처리하고 Model 과 같이 적절한 이벤트를 트리거하는 책임이 있습니다.
  • View 와 Model 을 연결하지만 View 에 어떠한 로직을 추가하지 않습니다.
  • View 와 1:1 매핑을 가집니다.

통신 (Communication)

  • Model 과 View/View Controller 의 레이어 모두와 통신이 가능합니다.
  • view 업데이트는 Presenter 를 통해 수행됩니다.
  • 데이터가 변경되면 해당 변경사항이 사용자 인터페이스로 전달되어 View 를 업데이트 되는지 확인합니다.

언제 MVP 를 사용할까 (When to use MVP)

MVC 와 MVVM 이 use case 에 대해 잘 동작하지 않는다고 느낄 때 MVP 를 사용합니다. 앱을 더 모듈화 하고 코드 커버리지를 늘리기 원할 것입니다. 초보자 이거나 iOS 개발에 많은 경험이 없으면 사용하지 마시기 바랍니다. 더 많은 코드를 작성할 준비를 해야 합니다.

예제 앱에서 View 레이어를 두개의 컴포넌트로 분리합니다: ViewController 와 실제 View. ViewController 는 Coordinator / Router 로 역할을 하고 일반적으로 IBOutlet 으로 설정된 View 에 대한 참조를 보유합니다.

장점 (Advantages)

  • 다른 패턴에 비해 레이어 분리가 더 좋습니다.
  • 비지니스 로직에 대해 더 많은 테스트를 할 수 있습니다.

단점 (Disadvantages)

  • 조립 문제 (assembly problem) 은 MVP 에서 더 두드러지게 나타납니다. 대부분 네비게이션과 모듈 조립 (module assembly) 를 다루기 위해 Router 또는 Coordinator 를 도입해야 합니다.
  • 이것은 Presenter 에 더 많은 책임을 가지기 때문에 비대한 클래스가 될 수 있는 리스크가 있습니다.

코드에 적용 (Applying to our code)

두가지 중요한 단계가 있습니다:

  1. ViewModel 을 하나씩 Presenter 로 변환합니다.
  2. ViewController 로 부터 View 를 분리합니다.

MVP 패턴 적용은 아래 자세히 나와있습니다:

final class FooViewController: UIViewController {
    
    @IBOutlet weak var fooView: FooView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    private func setupView() {
        let presenter = FooPresenter(view: fooView)
        fooView.delegate = self
        fooView.presenter = presenter
        fooView.setupView()
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    }
    
}

extension FooViewController: FooViewDelegate {
    func didRequestToNavigateToFooDetail() {
        // perform segue
    }
}

protocol FooViewDelegate: AnyObject {
    func didRequestToNavigateToFooDetail()
}

protocol FooViewProtocol: AnyObject {
    func setupView()
}

final class FooView: UIView, FooViewProtocol {
    
    var presenter: FooPresenterProtocol = FooPresenter()
    weak var delegate: FooViewDelegate?
    
    func setupView() {
        
    }
    
    func loadData() {
        
    }
    
}

protocol FooPresenterProtocol: AnyObject {
    func loadData()
}

final class FooPresenter: FooPresenterProtocol {
    
    private(set) weak var view: FooViewProtocol?
    // model
    init(view: FooViewProtocol? = nil) {
        self.view = view
    }
    
    func loadData() {
        
    }
    
}

LoginPresenter

LoginPresenter 가 어떻게 보이는지 확인해 봅시다:

// Defines the public API
protocol LoginPresenterProtocol: AnyObject {
    var rememberUsername: Bool { get }
    var username: String? { get }
    
    func setRememberUsername(_ value: Bool)
    func setUsername(_ username: String?)
    func performLogin(withUsername username: String?, andPassword password: String?)
    func performRegister(withUsername username: String?, andPassword password: String?)
}

모든 파라미터는 초기화 구문을 통해 주입됩니다.

final class LoginPresenter: LoginPresenterProtocol {
    private weak var view: LoginViewProtocol?
    private let loginService: LoginService
    private let usersService: StandardNetworkService
    private let userDefaults: FootballGatherUserDefaults
    private let keychain: FootbalGatherKeychain
    
    init(view: LoginViewProtocol? = nil,
         loginService: LoginService = LoginService(),
         usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
         userDefaults: FootballGatherUserDefaults = .shared,
         keychain: FootbalGatherKeychain = .shared) {
        self.view = view
        self.loginService = loginService
        self.usersService = usersService
        self.userDefaults = userDefaults
        self.keychain = keychain
    }
}

Keychain 상호작용은 아래와 같이 정의됩니다:

var rememberUsername: Bool {
    return userDefaults.rememberUsername ?? true
}

var username: String? {
    return keychain.username
}

func setRememberUsername(_ value: Bool) {
    userDefaults.rememberUsername = value
}

func setUsername(_ username: String?) {
    keychain.username = username
}

그리고 두개의 서비스를 가집니다:

func performLogin(withUsername username: String?, andPassword password: String?) {
    guard let userText = username, userText.isEmpty == false,
          let passwordText = password, passwordText.isEmpty == false else {
              // Key difference between MVVM and MVP, the presenter now tells the view what should do.
              view?.handleError(title: "Error", message: "Both fields are mandatory.")
              return
          }
    
    // Presenter tells the view to present a loading indicator.
    view?.showLoadingView()
    
    let requestModel = UserRequestModel(username: userText, password: passwordText)
    loginService.login(user: requestModel) { [weak self] result in
        DispatchQueue.main.async {
            self?.view?.hideLoadingView()
            
            switch result {
            case .failure(let error):
                self?.view?.handleError(title: "Error", message: String(describing: error))
                
            case .success(_):
                // Go to next screen
                self?.view?.handleLoginSuccessful()
            }
        }
    }
}

등록 함수는 기본적으로 로그인 함수와 동일합니다:

func performRegister(withUsername username: String?, andPassword password: String?) {
    guard let userText = username, userText.isEmpty == false,
          let passwordText = password, passwordText.isEmpty == false else {
              view?.handleError(title: "Error", message: "Both fields are mandatory.")
              return
          }
    
    guard let hashedPasssword = Crypto.hash(message: passwordText) else {
        fatalError("Unable to hash password")
    }
    
    view?.showLoadingView()
    
    let requestModel = UserRequestModel(username: userText, password: hashedPasssword)
    usersService.create(requestModel) { [weak self] result in
        DispatchQueue.main.async {
            self?.view?.hideLoadingView()
            
            switch result {
            case .failure(let error):
                self?.view?.handleError(title: "Error", message: String(describing: error))
                
            case .success(let resourceId):
                print("Created user: \(resourceId)")
                self?.view?.handleRegisterSuccessful()
            }
        }
    }
}

LoginView 는 다음의 프로토콜을 가집니다:

// MARK: - LoginViewDelegate
protocol LoginViewDelegate: AnyObject { // This is how it communicates with the ViewController
    func presentAlert(title: String, message: String)
    func didLogin()
    func didRegister()
}

// MARK: - LoginViewProtocol
protocol LoginViewProtocol: AnyObject { // The Public API
    func setupView()
    func showLoadingView()
    func hideLoadingView()
    func handleError(title: String, message: String)
    func handleLoginSuccessful()
    func handleRegisterSuccessful()
}

ViewController 로직의 대부분은 이제 View 안에 있습니다.

// MARK: - LoginView
final class LoginView: UIView, Loadable {
    
    // MARK: - Properties
    @IBOutlet weak var usernameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var rememberMeSwitch: UISwitch!
    
    lazy var loadingView = LoadingView.initToView(self)
    
    weak var delegate: LoginViewDelegate?
    var presenter: LoginPresenterProtocol = LoginPresenter()
    
    private func configureRememberMe() {
        rememberMeSwitch.isOn = presenter.rememberUsername
        
        if presenter.rememberUsername {
            usernameTextField.text = presenter.username
        }
    }
    
    // [1] Convenient function to store the username and remember name values.
    private func storeUsernameAndRememberMe() {
        presenter.setRememberUsername(rememberMeSwitch.isOn)
        
        if rememberMeSwitch.isOn {
            presenter.setUsername(usernameTextField.text)
        } else {
            presenter.setUsername(nil)
        }
    }
    
    // [2] The service call and the show/hide loading indicator has now moved into the responsibility of the Presenter.
    @IBAction private func login(_ sender: Any) {
        presenter.performLogin(withUsername: usernameTextField.text, andPassword: passwordTextField.text)
    }
    
    // [3] Same for registration.
    @IBAction private func register(_ sender: Any) {
        presenter.performRegister(withUsername: usernameTextField.text, andPassword: passwordTextField.text)
    }
    
}

extension LoginView: LoginViewProtocol {
    func setupView() {
        configureRememberMe()
    }
    
    func handleError(title: String, message: String) {
        delegate?.presentAlert(title: title, message: message)
    }
    
    // [4] A more MVP way would have been to just leave delegate?.didLogin in this function.
    // The store username and remember me logic should have been done in the Presenter and expose
    // whatever needed from the View in the LoginViewProtocol.
    func handleLoginSuccessful() {
        storeUsernameAndRememberMe()
        delegate?.didLogin()
    }
    
    func handleRegisterSuccessful() {
        storeUsernameAndRememberMe()
        delegate?.didRegister()
    }
}

마지막으로 ViewController:

// MARK: - LoginViewController
final class LoginViewController: UIViewController {
    
    // [1] Another way would have been to cast self.view to a LoginViewProtocol and extract it to a variable.
    @IBOutlet weak var loginView: LoginView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    private func setupView() {
        let presenter = LoginPresenter(view: loginView)
        loginView.delegate = self
        loginView.presenter = presenter
        loginView.setupView()
    }
    
}

// MARK: - LoginViewDelegate
extension LoginViewController: LoginViewDelegate {
    func presentAlert(title: String, message: String) {
        // [2] Show the alert.
        AlertHelper.present(in: self, title: title, message: message)
    }
    
    // [3] Navigate to player list screen.
    func didLogin() {
        performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
    }
    
    func didRegister() {
        performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
    }
}

화면을 하나씩 가져와 MVVM 아키텍처를 MVP 로 변환합니다.

PlayerListPresenter

다음 화면은 PlayerList 이고 웹 API 호출부터 시작합니다: **

func performPlayerDeleteRequest() {
    guard let indexPath = indexPathForDeletion else { return }
    
    view?.showLoadingView()
    
    requestDeletePlayer(at: indexPath) { [weak self] result in
        if result {
            self?.view?.handlePlayerDeletion(forRowAt: indexPath)
        }
    }
}

선수 삭제에 대한 확인은 View/ViewController 가 아니라 Presenter 에서 이루어집니다.

private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
    let player = players[indexPath.row]
    var service = playersService
    
    service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
        DispatchQueue.main.async {
            // [1] We tell the view to hide the spinner view.
            self?.view?.hideLoadingView()
            
            switch result {
            case .failure(let error):
                self?.view?.handleError(title: "Error", message: String(describing: error))
                completion(false)
                
            case .success(_):
                // [2] Player was deleted.
                completion(true)
            }
        }
    }
}

table view 의 data source 메서드에서 PlayerListView 를 보면 Presenter 가 정확하게 ViewModel 과 일치하게 동작한다는 것을 알 수 있습니다:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return presenter.numberOfRows
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
        return UITableViewCell()
    }
    
    if presenter.isInListViewMode {
        presenter.clearSelectedPlayerIfNeeded(at: indexPath)
        cell.setupDefaultView()
    } else {
        cell.setupSelectionView()
    }
    
    cell.nameLabel.text = presenter.playerNameDescription(at: indexPath)
    cell.positionLabel.text = presenter.playerPositionDescription(at: indexPath)
    cell.skillLabel.text = presenter.playerSkillDescription(at: indexPath)
    cell.playerIsSelected = presenter.playerIsSelected(at: indexPath)
    
    return cell
}

PlayerListViewController 는 이제 수정 (Edit), 확인 (Confirm) 그리고 추가 (Add) 화면 사이에서 router 역할을 합니다.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segue.identifier {
    case SegueIdentifier.confirmPlayers.rawValue:
        // [1] Compose the selected players that will be added in the ConfirmPlayersPresenter.
        if let confirmPlayersViewController = segue.destination as? ConfirmPlayersViewController {
            confirmPlayersViewController.playersDictionary = playerListView.presenter.playersDictionary
        }
        
        // [2] Set the player that we want to show the details.
    case SegueIdentifier.playerDetails.rawValue:
        if let playerDetailsViewController = segue.destination as? PlayerDetailViewController,
           let player = playerListView.presenter.selectedPlayerForDetails {
            // [3] From Details screen we can edit a player. Using delegation, we listen for
            // such modifications and refresh this view presenting the player with the updated details.
            playerDetailsViewController.delegate = self
            playerDetailsViewController.player = player
        }
        
    case SegueIdentifier.addPlayer.rawValue:
        (segue.destination as? PlayerAddViewController)?.delegate = self
        
    default:
        break
    }
}

책임을 나누어 PlayerList 모듈은 다음의 컴포넌트가 있습니다.

PlayerListViewController 책임:

  • 수집이 완료될 때마다 (GatherViewController 로 부터 호출됨) listView 모드 상태로 돌아가기 위해 PlayerListTogglable 프로토콜을 구현합니다.
  • PlayerListViewIBOutlet 를 가집니다.
  • presenter, view delegate 를 설정하고 view 에 설정하도록 요청합니다.
  • 네비게이션 로직을 처리하고 수정 (Edit), 추가 (Add) 그리고 확인 (Confirm) 화면에 대한 model 을 구성합니다.
  • PlayerListViewDelegate 구현하고 다음의 동작을 수행합니다:
  • view 는 타이틀을 변경하기 위해 호출하면 (func didRequestToChangeTitle(_ title: String)) 타이틀을 변경합니다.
  • 네비게이션 오른편에 bar 버튼 아이템을 추가합니다 (선수 선택을 Select 또는 Cancel)
  • Presenter 에서 구성된 identifier 를 사용하여 적절한 segue 를 수행합니다.
  • 예를 들어 서비스 실패 시 타이틀과 메세지를 가진 alert 를 나타냅니다.
  • 삭제 확인 alert 을 나타냅니다.
  • PlayerDetailViewControllerDelegate 을 구현하면 선수가 수정될 때 View 를 새로고침 하기위해 요청합니다.
  • AddPlayerDelegate 의 경우와 비슷하며 여기에서는 View 가 선수의 리스트를 다시 로드하도록 요청합니다.

PlayerListView 책임:

  • public API, PlayerListViewProtocol 을 노출합니다. 이 레이어는 가능한 단순하고 복잡하지 않아야 합니다.

PlayerListPresenter 책임:

  • PlayerListPresenterProtocol 을 보면 많은 일을 한다는 것을 알 수 있습니다.
  • barButtonItemTitle, barButtonItemIsEnabled 등과 같이 View 에 대한 필요한 메서드를 노출합니다.

PlayerListViewState 책임:

  • PlayerListView 의 다른 상태를 할당하기 위해 Factory Method 패턴을 사용하여 MVVM 에서와 동일한 기능을 유지하며 ViewState 를 새로운 파일로 추출하기로 결정했습니다.

PlayerDetail screen

계속해서 PlayerDetail 화면에서 ViewViewController 를 분리합니다.

// MARK: - PlayerDetailViewController
final class PlayerDetailViewController: UIViewController {
    
    // MARK: - Properties
    @IBOutlet weak var playerDetailView: PlayerDetailView!
    
    weak var delegate: PlayerDetailViewControllerDelegate?
    var player: PlayerResponseModel?
    
    // .. other methods
}

동일한 패턴에 따라 수정 화면에 대한 네비게이션은 delegation 을 통해 수행됩니다:

  • 사용자가 선수 프로퍼티에 해당하는 행 중 하나를 탭합니다. View 는 수정하기 위해 해당 필드에 요청하고 ViewController 는 올바른 segue 를 수행합니다. prepare:for:segue 메서드에서 선수를 수정하는데 필요한 프로퍼티를 할당합니다.
extension PlayerDetailViewController: PlayerDetailViewDelegate {
    func didRequestEditView() {
        performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
    }
}

PlayerDetailViewController 내부:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard segue.identifier == SegueIdentifier.editPlayer.rawValue,
          let destinationViewController = segue.destination as? PlayerEditViewController else {
              return
          }
    
    let presenter = playerDetailView.presenter
    // [1] Show the textfield or the picker for editing a player field
    destinationViewController.viewType = presenter?.destinationViewType ?? .text
    // [2] The edit model
    destinationViewController.playerEditModel = presenter?.playerEditModel
    // [3] In case we are in picker mode, we need to specify the data source.
    destinationViewController.playerItemsEditModel = presenter?.playerItemsEditModel
    destinationViewController.delegate = self
}

PlayerDetailView 는 아래와 같이 나타냅니다:

final class PlayerDetailView: UIView, PlayerDetailViewProtocol {
    
    // MARK: - Properties
    @IBOutlet weak var playerDetailTableView: UITableView!
    
    weak var delegate: PlayerDetailViewDelegate?
    var presenter: PlayerDetailPresenterProtocol!
    
    // MARK: - Public API
    var title: String {
        return presenter.title
    }
    
    func reloadData() {
        playerDetailTableView.reloadData()
    }
    
    func updateData(player: PlayerResponseModel) {
        presenter.updatePlayer(player)
        presenter.reloadSections()
    }
}

그리고 table view delegate 와 data source 를 구현합니다:

extension PlayerDetailView: UITableViewDelegate, UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return presenter.numberOfSections
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presenter.numberOfRowsInSection(section)
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerDetailTableViewCell") as? PlayerDetailTableViewCell else {
            return UITableViewCell()
        }
        
        cell.leftLabel.text = presenter.rowTitleDescription(for: indexPath)
        cell.rightLabel.text = presenter.rowValueDescription(for: indexPath)
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return presenter.titleForHeaderInSection(section)
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.selectPlayerRow(at: indexPath)
        delegate?.didRequestEditView()
    }
}

PlayerDetailPresenter:

final class PlayerDetailPresenter: PlayerDetailPresenterProtocol {
    
    // MARK: - Properties
    private(set) var player: PlayerResponseModel
    private lazy var sections = makeSections()
    private(set) var selectedPlayerRow: PlayerRow?
    
    // MARK: - Public API
    init(player: PlayerResponseModel) {
        self.player = player
    }
    
    // other methods
}

수정 화면 (Edit Screen)

앱의 나머지 화면에 대해 동일한 접근방식을 따릅니다. PlayerEdit 기능이 아래 예제에 있습니다. PlayerEditView 클래스는 기본적으로 새로운 ViewController 입니다.

final class PlayerEditView: UIView, Loadable {
    
    // MARK: - Properties
    @IBOutlet weak var playerEditTextField: UITextField!
    @IBOutlet weak var playerTableView: UITableView!
    
    private lazy var doneButton = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(doneAction))
    lazy var loadingView = LoadingView.initToView(self)
    
    weak var delegate: PlayerEditViewDelegate?
    var presenter: PlayerEditPresenterProtocol!
    
    // other methods
}

selector 는 간단합니다:

// MARK: - Selectors
@objc private func textFieldDidChange(textField: UITextField) {
    doneButton.isEnabled = presenter.doneButtonIsEnabled(newValue: playerEditTextField.text)
}

@objc private func doneAction(sender: UIBarButtonItem) {
    presenter.updatePlayerBasedOnViewType(inputFieldValue: playerEditTextField.text)
}

그리고 Public API:

extension PlayerEditView: PlayerEditViewProtocol {
    var title: String {
        return presenter.title
    }
    
    func setupView() {
        setupNavigationItems()
        setupPlayerEditTextField()
        setupTableView()
    }
    
    func handleError(title: String, message: String) {
        delegate?.presentAlert(title: title, message: message)
    }
    
    func handleSuccessfulPlayerUpdate() {
        delegate?.didFinishEditingPlayer()
    }
}

마지막으로, UITableViewDataSourceUITableViewDelegate 메서드:

extension PlayerEditView: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presenter.numberOfRows
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "ItemSelectionCellIdentifier") else {
            return UITableViewCell()
        }
        
        cell.textLabel?.text = presenter.itemRowTextDescription(indexPath: indexPath)
        cell.accessoryType = presenter.isSelectedIndexPath(indexPath) ? .checkmark : .none
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let selectedItemIndex = presenter.selectedItemIndex {
            clearAccessoryType(forSelectedIndex: selectedItemIndex)
        }
        
        presenter.updateSelectedItemIndex(indexPath.row)
        tableView.cellForRow(at: indexPath)?.accessoryType = .checkmark
        
        doneButton.isEnabled = presenter.doneButtonIsEnabled(selectedIndexPath: indexPath)
    }
    
    private func clearAccessoryType(forSelectedIndex selectedItemIndex: Int) {
        let indexPath = IndexPath(row: selectedItemIndex, section: 0)
        playerTableView.cellForRow(at: indexPath)?.accessoryType = .none
    }
    
    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        tableView.cellForRow(at: indexPath)?.accessoryType = .none
    }
}

PlayerEditPresenter 는 비지니스 로직을 처리하고 UI 요소 업데이트를 위한 프로퍼티를 노출합니다.

final class PlayerEditPresenter: PlayerEditPresenterProtocol {
    
    // MARK: - Properties
    private weak var view: PlayerEditViewProtocol?
    private var playerEditModel: PlayerEditModel
    private var viewType: PlayerEditViewType
    private var playerItemsEditModel: PlayerItemsEditModel?
    private var service: StandardNetworkService
    
    // MARK: - Public API
    init(view: PlayerEditViewProtocol? = nil,
         viewType: PlayerEditViewType = .text,
         playerEditModel: PlayerEditModel,
         playerItemsEditModel: PlayerItemsEditModel? = nil,
         service: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
        self.view = view
        self.viewType = viewType
        self.playerEditModel = playerEditModel
        self.playerItemsEditModel = playerItemsEditModel
        self.service = service
    }
    
    // other methods
}

API 호출은 아래에 자세히 나와있습니다:

func updatePlayerBasedOnViewType(inputFieldValue: String?) {
    // [1] Check if we updated something.
    guard shouldUpdatePlayer(inputFieldValue: inputFieldValue) else { return }
    
    // [2] Present a loading indicator.
    view?.showLoadingView()
    
    let fieldValue = isSelectionViewType ? selectedItemValue : inputFieldValue
    
    // [3] Make the Network call.
    updatePlayer(newFieldValue: fieldValue) { [weak self] updated in
        DispatchQueue.main.async {
            self?.view?.hideLoadingView()
            self?.handleUpdatedPlayerResult(updated)
        }
    }
}

PlayerAddConfirm 그리고 Gather 화면은 동일한 접근방식을 따릅니다.

비지니스 로직 테스트 (Testing our business logic)

테스트 접근방식은 MVVM 에 대해 수행한 것과 90% 동일합니다.

또한 view 를 mock 하고 적절한 메서드가 호출되었는지 확인해야 합니다. 예를 들어 서비스 API 가 호출 시 view 가 상태를 다시 로드하거나 실패 케이스에서 에러를 처리하는지 확인합니다.

GatherPresenter 의 유닛 테스트:

// [1] Basic setup
final class GatherPresenterTests: XCTestCase {
    
    // [2] Define the Mocked network classes.
    private let session = URLSessionMockFactory.makeSession()
    private let resourcePath = "/api/gathers"
    private let appKeychain = AppKeychainMockFactory.makeKeychain()
    
    // [3] Setup and clear the Keychain variables.
    override func setUp() {
        super.setUp()
        appKeychain.token = ModelsMock.token
    }
    
    override func tearDown() {
        appKeychain.storage.removeAll()
        super.tearDown()
    }
}

countdownTimerLabelText 테스트:

func testFormattedCountdownTimerLabelText_whenViewModelIsAllocated_returnsDefaultTime() {
    // given
    let gatherTime = GatherTime.defaultTime
    let expectedFormattedMinutes = gatherTime.minutes < 10 ? "0\(gatherTime.minutes)" : "\(gatherTime.minutes)"
    let expectedFormattedSeconds = gatherTime.seconds < 10 ? "0\(gatherTime.seconds)" : "\(gatherTime.seconds)"
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherPresenter(gatherModel: mockGatherModel)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "\(expectedFormattedMinutes):\(expectedFormattedSeconds)")
}

func testFormattedCountdownTimerLabelText_whenPresenterIsAllocated_returnsDefaultTime() {
    // given
    let gatherTime = GatherTime.defaultTime
    let expectedFormattedMinutes = gatherTime.minutes < 10 ? "0\(gatherTime.minutes)" : "\(gatherTime.minutes)"
    let expectedFormattedSeconds = gatherTime.seconds < 10 ? "0\(gatherTime.seconds)" : "\(gatherTime.seconds)"
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherPresenter(gatherModel: mockGatherModel)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "\(expectedFormattedMinutes):\(expectedFormattedSeconds)")
}

func testFormattedCountdownTimerLabelText_whenTimeIsZero_returnsZeroSecondsZeroMinutes() {
    // given
    let mockGatherTime = GatherTime(minutes: 0, seconds: 0)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherPresenter(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "00:00")
}

func testFormattedCountdownTimerLabelText_whenTimeHasMinutesAndZeroSeconds_returnsMinutesAndZeroSeconds() {
    // given
    let mockGatherTime = GatherTime(minutes: 10, seconds: 0)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherPresenter(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "10:00")
}

func testFormattedCountdownTimerLabelText_whenTimeHasSecondsAndZeroMinutes_returnsSecondsAndZeroMinutes() {
    // given
    let mockGatherTime = GatherTime(minutes: 0, seconds: 10)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherPresenter(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "00:10")
}

토클 타이머는 더 흥미롭습니다:

func testToggleTimer_whenSelectedTimeIsNotValid_returns() {
    // given
    let mockGatherTime = GatherTime(minutes: -1, seconds: -1)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    // [1] Allocate the mock view.
    let mockView = MockView()
    let sut = GatherPresenter(view: mockView, gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    sut.toggleTimer()
    
    // then
    // [2] configureSelectedTime() was not called.
    XCTAssertFalse(mockView.selectedTimeWasConfigured)
}

func testToggleTimer_whenSelectedTimeIsValid_updatesTime() {
    // given
    let numberOfUpdateCalls = 2
    let mockGatherTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    // [1] Configure the mock view parameters
    let exp = expectation(description: "Waiting timer expectation")
    let mockView = MockView()
    mockView.numberOfUpdateCalls = numberOfUpdateCalls
    mockView.expectation = exp
    
    let sut = GatherPresenter(view: mockView, gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    sut.toggleTimer()
    
    // [2] Selector should be called two times.
    // then
    waitForExpectations(timeout: 5) { _ in
        XCTAssertTrue(mockView.selectedTimeWasConfigured)
        XCTAssertEqual(mockView.actualUpdateCalls, numberOfUpdateCalls)
        sut.stopTimer()
    }
}

그리고 아래는 mock view 입니다:

private extension GatherPresenterTests {
    final class MockView: GatherViewProtocol {
        private(set) var selectedTimeWasConfigured = false
        
        weak var expectation: XCTestExpectation? = nil
        var numberOfUpdateCalls = 1
        private(set) var actualUpdateCalls = 0
        
        func configureSelectedTime() {
            selectedTimeWasConfigured = true
            
            actualUpdateCalls += 1
            
            if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
                expectation?.fulfill()
            }
        }
        
        func handleSuccessfulEndGather() {
            expectation?.fulfill()
        }
        
        func setupView() {}
        func showLoadingView() {}
        func hideLoadingView() {}
        func handleError(title: String, message: String) {}
        func confirmEndGather() {}
    }
}

presenter 를 테스트하는 것은 매우 좋습니다. 마법같은 걸 할 필요도 없고 메서드는 크기가 작아 도움이 됩니다. 복잡한 것은 View 레이어를 mock 하고 일부 파라미터가 적절하게 변경되는지 확인해야 합니다.

주요지표 (Key Metrics)

코드 라인 수 (Lines of code) — View Controllers

Untitled

코드 라인 수 (Lines of code) — Views

Untitled

코드 라인 수 (Lines of code) — Presenters

Untitled

코드 라인 수 (Lines of code) — Local Models

Untitled

유닛 테스트 (Unit Tests)

Untitled

빌드 시간 (Build Times)

Untitled

테스트는 iOS 14.3, Xcode 12.4 그리고 i9 MacBook Pro 2019 사양에 iPhone 8 시뮬레이터에서 실행했습니다.

결론 (Conclusion)

이제 애플리케이션은 MVVM 에서 MVP 로 재작성 되었습니다. 이 접근방식은 간단하고 ViewModel 마다 Presenter 레이어로 변경했습니다.

추가적으로 관계를 더 구분하기 위해 ViewController 에서 View 라는 새로운 레이어를 생성합니다. Single Responsibility Principle 을 수용하여 코드는 깔끔해 보이고 view controller 는 더 다이어트 되고 클래스와 함수가 작고 한가지에 집중합니다.

개인적으로 UIKit 을 염두에 두고 앱을 개발할 때 MVVM 보다 이 패턴을 더 선호합니다. MVVM 보다 더 자연스럽습니다.

주요지표를 보면 다음을 알 수 있습니다:

  • View Controller 는 더 다이어트 되고 전체적으로 코드의 라인 수를 1,000 라인 이상 줄였습니다.
  • 그러나 UI 업데이트를 위해 View 라는 새로운 레이어를 도입합니다.
  • view 를 관리하기 위해 추가 책임이 늘었으므로 Presenter 는 View Model 보다 큽니다.
  • 유닛 테스트 작성은 97.2% 코드 커버리지와 동일함을 포함하여 MVVM 과 유사합니다.
  • 더 많은 파일과 클래스를 가지므로 빌드 시간에 약간의 영향을 끼치며 MVVM 보다 530 ms 그리고 MVC 보다 400 ms 증가됩니다.
  • 놀랍게도 평균 유닛 테스트 실행 시간은 MVVM 보다 1.36 초 빨라졌습니다.
  • MVC 패턴과 비교하여 비지니스 로직을 커버하는 유닛 테스트는 작성하기 더 쉽습니다.

MVVM 으로 작성한 앱을 MVP 와 같은 다른 패턴으로 변환하는 방법을 보았습니다. 내 관점에서 보면 ViewController 로 부터 View 를 분리하는 MVP 는 MVVM 보다 좋습니다. 레이어에 더 큰 힘을 가져다 주고 서로 분리하고 의존성 주입 (dependency injection) 사용이 더 쉽습니다.

끝까지 읽어 주셔서 감사합니다! 아래 유용한 링크도 참고 바랍니다.

유용한 링크 (Useful Links)