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

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:
- Go one by one through the ViewModels and turn them into Presenters
- 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 thelistView
mode state, whenever a gather is completed (called fromGatherViewController
). - Holds an
IBOutlet
toPlayerListView
. - 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 ofPlayerListView
.
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 |