Radu Dan
MVP Banner

Architecture Series - Model View Presenter (MVP)

Motivation

Before starting to develop an iOS app, we have to think of the structure of the project. We need to consider how we add those pieces of code together so they make sense later on - when we come back and revisit a part of the app - and how to form a known ā€œlanguageā€ with the other developers.

In this third article of the series, we will transform the MVVM app in MVP.
As usual, we will see how we apply the pattern to each screen, seeing the actual implementation and the source code. At the end, we will show the build times and detail some key observations about MVP, compared to the other architecture patterns.

If you just want to see the code, feel free to skip this post. The code is available open source here.

Why an architecture pattern for your iOS app

The most important thing to consider is to have an app that can be maintainable. You know the View goes there, this View Controller should do X and not Y. And more important the others know that too.

Here are some advantages of choosing a good architecture pattern:

  • Easier to maintain
  • Easier to test the business logic
  • Develop a common language with the other teammates
  • Separate the responsibility of your entities
  • Less bugs

Defining the requirements

Given an iOS application with 6-7 screens, we are going to develop it using the most popular architecture patterns from the iOS world: MVC, MVVM, MVP, VIPER, VIP and Coordinators.

The demo app is called Football Gather and is a simple way of friends to track score of their amateur football matches.

Main features

  • Ability to add players in the app
  • You can assign teams to the players
  • Edit players
  • Set countdown timer for matches

Screen Mockups

Football Gather Mockups

Backend

The app is powered by a web app developed in Vapor web framework. You can check the app here (Vapor 3 initial article) and here (Migrating to Vapor 4).

What is MVP

MVP is slightly the same as MVVM, but with some key notes:

  • You now have a presenter layer.
  • You can control the view from the presenter layer.

Model

  • The Model layer is exactly as in the others, it is used to encapsulate business data.
  • Is an interface responsible for domain data.

Communication

  • When something happens in the view layer, for example when the user initiates an action, it is communicated to the model through the Presenter.
  • When the model is changed, for example when new data is made available and we need to update the UI, the Presenter updates the View.

View

  • View layer is the same as in MVVM, but the View now lacks responsibility for updating its state. The presenter owns the View..

Communication

  • Views canā€™t communicate directly with the Model, everything is done through the Presenter.

Presenter

  • Responsible for handling the events coming from the view and triggering the appropriate events with the Model.
  • Connects the View with the Model, but without any logic added to the View.
  • Has a 1:1 mapping to a View.

Communication

  • Can communicate with both layers, Model and View/View Controller.
  • The view updates will be done from the Presenter.
  • When data changes, it makes sure those changes are communicated to the user interface, updating the View.

When to use MVP

Use it when you feel MVC and MVVM doesn't work quite well for your use cases. You want to make your app more modularised and increase your code coverage.
Donā€™t use it when you are a beginner or you donā€™t have that much experience into iOS development.
Be prepared to write more code.

For our app, we separated the View layer into two components: ViewController and the actual View.
The ViewController acts as a Coordinator / Router and holds a reference to the view, usually set as an IBOutlet

Advantages

  • The separation of layers is better than in the other patterns.
  • We can test most of the business logic.

Disadvantages

  • The assembly problem is revealed more prominently in MVP. Most likely you will have to introduce a Router or a Coordinator to take care of navigation and module assembly.
  • There is a risk that your Presenter will turn into a massive class, because he has more responsibilities.

Applying to our code

There are two big steps we need to do:

  1. Go one by one through the ViewModels and turn them into Presenters
  2. Separate the View from the ViewController

The applied MVP pattern is detailed below:


    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

Letā€™s see how the LoginPresenter looks like:


    // 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?)
    }

All parameters will be injected through the initialiser.


    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
    }

The Keychain interactions are defined below:


    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
    }

And we have the two services:


    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()
               }
           }
       }
    }

The register function is basically the same as the login one:


    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()
               }
           }
       }
    }

The LoginView has the following protocols:


    // 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()
    }

Most of the ViewController logic is now inside the 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()
        }
    }

Finally, the 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)
        }
    }

We take screen by screen, and turn the existing MVVM architecture into an MVP.

PlayerListPresenter

Next screen is PlayerList, and we start with the web API calls:


    func performPlayerDeleteRequest() {
       guard let indexPath = indexPathForDeletion else { return }

       view?.showLoadingView()

       requestDeletePlayer(at: indexPath) { [weak self] result in
           if result {
               self?.view?.handlePlayerDeletion(forRowAt: indexPath)
           }
       }
    }

Now, the check for player deletion is made inside the Presenter and not in the View/ViewController.


    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)
               }
           }
       }
    }

If we look in the PlayerListView, at table view's data source methods, we observe that the Presenter is behaving exactly as a 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
    }

The PlayerListViewController acts now more as a router between Edit, Confirm and Add screens.


    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
       }
    }

Breaking into responsibilities, PlayerList module has the following components.

PlayerListViewController responsibilities:

  • Is implementing the PlayerListTogglable protocol to go back to the listView mode state, whenever a gather is completed (called from GatherViewController).
  • Holds an IBOutlet to PlayerListView.
  • It sets the presenter, view delegate and tells the view to setup.
  • Handles the navigation logic and constructs that models for Edit, Add and Confirm screens.
  • Implements the PlayerListViewDelegate and does the following operations:
    • Changes the title when the view requested to change it (func didRequestToChangeTitle(_ title: String).
    • Adds the right navigation bar button item (Select or Cancel selection of players)
    • Performs the appropriate segue with the identifier constructed in the Presenter.
    • Presents a simple alert with a title and a message (when the service failed, for example)
    • Presents the delete confirmation alert.
  • By implementing the PlayerDetailViewControllerDelegate, when a player is edited, it tells the View to refresh.
  • Same case for AddPlayerDelegate, and here, it tells the View to load again the list of players.

PlayerListView responsibilities:

  • Exposes the public API, PlayerListViewProtocol. This layer should be as dumb as possible and not do complex things.

PlayerListPresenter responsibilities:

  • At a first glimpse at PlayerListPresenterProtocol, we notice it does a lot of things.
  • Exposes the needed methods for the View, such as barButtonItemTitle, barButtonItemIsEnabled, and so on.

PlayerListViewState responsibilities:

  • We decided to extract the ViewState into a new file, keeping the same functionality that we had in MVVM, using the Factory Method pattern to allocate the different states of PlayerListView.

PlayerDetail screen

Continuing with PlayerDetail screen, we separate the View from the ViewController.


      // MARK: - PlayerDetailViewController
      final class PlayerDetailViewController: UIViewController {

          // MARK: - Properties
          @IBOutlet weak var playerDetailView: PlayerDetailView!

          weak var delegate: PlayerDetailViewControllerDelegate?
          var player: PlayerResponseModel?

          // .. other methods
      }

Following the same pattern, the navigation to edit screen is done through delegation:

  • user taps one of the row that corresponds with a player property. The View tells the ViewController that we want to edit that field, the ViewController performs the correct segue. In prepare:for:segue method, we allocate the properties required to edit the player.


      extension PlayerDetailViewController: PlayerDetailViewDelegate {
          func didRequestEditView() {
              performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
          }
      }

Inside 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 is presented below:


        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()
            }
        }

And the table view delegate and data source implementation:


    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()
        }
    }

The 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

We follow the same approach for the remaining screens of the app.
Exemplifying below the PlayerEdit functionality. The PlayerEditView class is basically the new 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
    }

The selectors are pretty straightforward:


        // 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)
        }

And the 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()
          }
      }

Finally, the UITableViewDataSource and UITableViewDelegate methods:


      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 handles the business logic and exposes the properties for updating the UI elements.


      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

      }

An API call is detailed below:


      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)
             }
         }
      }

PlayerAdd, Confirm and Gather screens follow the same approach.

Testing our business logic

The testing approach is 90% the same as we did for MVVM.

In addition, we need to mock the view and check if the appropriate methods were called. For example, when a service API call is made, check if the view reloaded its state or handled the error in case of failures.

Unit Testing below 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()
          }
      }

Testing the 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")
      }
      

Toggle timer becomes more interesting:


    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()
         }
      }

And below is the 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() {}
          }
      }

Iā€™d say that testing the presenter is very cool. You donā€™t need to do magic stuff, and the methods are very small in size which is helping.
The complex thing comes with the fact that you will need to mock the View layer and check if some parameters are changing accordingly.

Key Metrics

Lines of code - View Controllers

File Number of lines of code MVVM - Lines of code MVC - Lines of code
PlayerAddViewController 59 77 (-18) 79 (-20)
PlayerListViewController 115 296 (-115) 387 (-272)
PlayerDetailViewController 85 96 (-11) 204 (-119)
LoginViewController 43 96 (-53) 126 (-83)
PlayerEditViewController 68 124 (-56) 212 (-144)
GatherViewController 73 227 (-154) 359 (-286)
ConfirmPlayersViewController 51 104 (-53) 260 (-209)
TOTAL 494 1020 (-526) 1627 (-1133)

Lines of code - Views

File Number of lines of code
PlayerAddView 75
PlayerListView 238
PlayerDetailView 77
LoginView 88
PlayerEditView 127
GatherView 198
ConfirmPlayersView 103
TOTAL 906

Lines of code - Presenters

File Number of lines of code MVVM - View Model LOC
LoginPresenter 111 75 (+36)
PlayerListPresenter 259 258 (+1)
PlayerEditPresenter 187 155 (+32)
PlayerAddPresenter 52 37 (+15)
PlayerDetailPresenter 195 178 (+17)
GatherPresenter 237 204 (+33)
ConfirmPlayersPresenter 195 206 (-11)
TOTAL 1236 1113 (+123)

Lines of code - Local Models

File Number of lines of code
PlayerListViewState 69
TeamSection 50
GatherTimeHandler 100
TOTAL 219

Unit Tests

Topic Data MVVM Data MVC Data
Number of key classes (ViewControllers, Views, Presenters, Local Models) 24 14 +10 7 +17
Key Class GatherPresenter GatherViewModel GatherViewController
Number of Unit Tests 34 34 30 +4
Code Coverage of Gathers feature 97.2% 97.3% -0.1 95.7% +1.5
How hard to write unit tests 3/5 3/5 5/5 -2

Build Times

Build Time (sec)* MVVM Time (sec)* MVC Time (sec)*
Average Build Time (after clean Derived Data & Clean Build) 10.18 9.65 +0.53 9.78 +0.4
Average Build Time 0.1 0.1 0.1
Average Unit Test Execution Time (after clean Derived Data & Clean Build) 16.52 17.88 -1.36 12.78 +3.74

* tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019

Conclusion

The application has now been rewritten from MVVM to MVP. The approach was straightforward, we took ViewModel by ViewModel and replaced it with a Presenter layer.

Additionally, we created a new layer - View - out of the ViewController to better separate the concerns. The code looks cleaner, view controllers are thinner, the classes and functions are small and concentrate doing one thing, embracing the Single Responsibility Principle.

I personally prefer this pattern rather than MVVM when it comes to apps developed with UIKit in mind. It feels more natural than MVVM.

Taking a look at the key metrics we can make the following notes:

  • the View Controllers are much thinner, overall we reduced their size with more than 1,000 lines of code
  • however, we introduced a new layer for the UI updates - called the View
  • Presenters are bigger than View Models, because they hold an additional responsibility, to manage views
  • writing unit tests was similar as per MVVM, obtaining almost the same code coverage, 97.2%
  • having more files and classes, we had a small impact on the build time, being increased with 530 ms compared with MVVM and 400 ms from MVC
  • surprisingly, the average unit test execution time has been quicker with 1,36 seconds compared with MVVM
  • comparing to the MVC pattern, the unit tests covering the business logic were much easier to write

Really cool we saw together how to transform an app written in MVVM into a different pattern, such as MVP. From my point of view, MVP with the separation of View from the ViewController, is much nicer than MVVM. It brings more power to your layers, making them decoupled from each other and is much easier to use dependency injection.

Thanks for staying until the end! As usual, here are some useful links below.

Useful Links

Item Series Links
The iOS App - Football Gather GitHub Repo Link
The web server application made in Vapor GitHub Repo Link
Vapor 3 - Backend APIs article link
Migrating to Vapor 4 article link
Model View Controller (MVC) GitHub Repo Link
Article Link
Model View ViewModel (MVVM) GitHub Repo Link
Article Link
Model View Presenter (MVP) GitHub Repo Link
Article Link
Coordinator Pattern - MVP with Coordinators (MVP-C) GitHub Repo Link
Article Link
View Interactor Presenter Entity Router (VIPER) GitHub Repo Link
Article Link
View Interactor Presenter (VIP) GitHub Repo Link
Article Link