Architecture Series - Model View ViewModel (MVVM)
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.
This is the second article in the series and it is all about MVVM.
We will check build times, pros and cons of each pattern, but most important we will see the actual implementation and the source code.
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 MVVM
MVVM stands for Model View ViewModel, an architecture pattern that is used naturally with RxSwift where you can bind your UI elements to the Model classes through the ViewModel.
It is a newer pattern, proposed in 2005 by John Gossman and has the role of extracting the Model from the ViewController. The interaction between the ViewController and the Model is done through a new layer, called ViewModel.
Model
- The same layer we had in MVC, and is used to encapsulate data and the business logic.
Communication
- When something happens in the view layer, for example when the user initiates an action, it is communicated to the model through the ViewModel.
- When the model is changed, for example when new data is made available and we need to update the UI, the model notifies the ViewModel.
View
- View and ViewController are the layers where the visual elements reside.
- The View contains the UI elements, such as buttons, labels, table views and the ViewController is the owner of the View.
- This layer is the same as in MVC, but the ViewController is now part of it and will be changed to reference the ViewModel.
Communication
- Views can’t communicate directly with the Model, everything is done through the ViewModel.
ViewModel
- A new layer that sits between the View/View Controller and the Model.
- Through binding, it updates the UI elements when something has changed in the Model.
- Is a canonical representation of the View.
- Provides interfaces to the View.
Communication
- Can communicate with both layers, Model and View/View Controller.
- Via binding, ViewModels trigger changes to the data of the Model layer
- When data changes, it makes sure those changes are communicated to the user interface, updating the View (again through a binding).
Different flavours of MVVM
The way you apply MVVM depends on how you choose to implement the binding:
- Using a 3rd party, such as RxSwift.
- KVO - Key Value Observing.
- Manually.
In our demo app we will explore the manual approach.
How and when to use MVVM
When you see the ViewController
does a lot of stuff and might turn to be massive, you can start looking at different patterns, such as MVVM.
Advantages:
- Slims down the ViewController.
- Easier to test the business logic, because you now have a dedicated layer that handles data.
- Provides a better separation of concerns
Disadvantages:
- Same as in MVC, if is not applied correctly and you are not careful of SRP (Single Responsibility Principle), it can turn out into a Massive ViewModel.
- Can be overkill and too complex for small projects (for example, in a Hackathon app/prototype).
- Adopting a 3rd party increases the app size and can impact the performance.
- Doesn’t feel natural to iOS app development with UIKit. On the other hand, for apps developed with SwiftUI makes perfect sense.
Below you can find a collection of links that tell you more about this code architecture pattern:
- Book about MVVM on raywenderlich.
- Article about MVVM in iOS
- Article "How to not get desperate with MVVM implementation"
- Introduction to Model/View/ViewModel pattern for building WPF apps
- MVVM vs MVC
- Using MVV in iOS
- Practical MVVM + RxSwift
- MVVM with RxSwift
- How to integrate RxSwift in your MVVM architecture
- What Are the Benefits of Model-View-ViewModel
- MVVM Pattern Advantages – Benefits of Using MVVM Model
- Advantages and disadvantages of M-V-VM
- MVVM-1: A General Discussion
Applying to our code
This is pretty straightforward. We go into each ViewController
and extract the business logic into a new layer (ViewModel
).
Decoupling LoginViewController
from business logic
Transformations:
viewModel
- A new layer that handles the view state and the model updates.- The services are now part of the ViewModel layer.
In viewDidLoad
method, we call configureRememberMe()
function. Here, we can observe how the View asks the ViewModel for the values of the "Remember Me" UISwitch
and the username:
private func configureRememberMe() {
rememberMeSwitch.isOn = viewModel.rememberUsername // [1] set switch on / off based on the preferred mode of the user
if viewModel.rememberUsername { // [2] set the stored username to the textfield
usernameTextField.text = viewModel.username
}
}
For the Login and Register actions, we tell the ViewModel to handle the service requests. We use closures for updating the UI once the server API call finished.
@IBAction func login(_ sender: Any) {
guard let userText = usernameTextField.text, userText.isEmpty == false,
let passwordText = passwordTextField.text, passwordText.isEmpty == false else {
AlertHelper.present(in: self, title: "Error", message: "Both fields are mandatory.")
return
} // [1] Extract the username and the password from the fields
showLoadingView() // [2] Display a loading spinner
viewModel.performLogin(withUsername: userText, andPassword: passwordText) { [weak self] error in // [3] Tell the view model to Login
DispatchQueue.main.async {
self?.hideLoadingView() // [4] Service finished, update UI
self?.handleServiceResponse(error: error)
}
}
}
private func handleServiceResponse(error: Error?) {
if let error = error {
AlertHelper.present(in: self, title: "Error", message: String(describing: error)) // [5] Handle the error
} else {
handleSuccessResponse()
}
}
// [6] We navigate to the next screen, PlayerList
private func handleSuccessResponse() {
storeUsernameAndRememberMe()
performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
}
// [7] Storing the details is done in the ViewModel
private func storeUsernameAndRememberMe() {
viewModel.setRememberUsername(rememberMeSwitch.isOn)
if rememberMeSwitch.isOn {
viewModel.setUsername(usernameTextField.text)
} else {
viewModel.setUsername(nil)
}
}
The LoginViewModel
is defined by the following properties:
struct LoginViewModel {
private let loginService: LoginService
private let usersService: StandardNetworkService
private let userDefaults: FootballGatherUserDefaults
private let keychain: FootbalGatherKeychain
}
We have the services that were passed from LoginViewController
(LoginService
, StandardNetworkService
used for registering the user and the Storage facilitators - UserDefaults
and Keychain
wrappers).
All of them are injected through the initializer:
init(loginService: LoginService = LoginService(),
usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
userDefaults: FootballGatherUserDefaults = .shared,
keychain: FootbalGatherKeychain = .shared) {
self.loginService = loginService
self.usersService = usersService
self.userDefaults = userDefaults
self.keychain = keychain
}
This comes in handy for unit testing if we want to use our own Mocked services or storages.
The Public API is clean and simple:
// [1] Checks in the UserDefaults storage if we have set Remember Me option
var rememberUsername: Bool {
return userDefaults.rememberUsername ?? true
}
// [2] The username that was stored in case rememberUsername is true
var username: String? {
return keychain.username
}
// [3] Stores the RememberMe boolen property
func setRememberUsername(_ value: Bool) {
userDefaults.rememberUsername = value
}
// [4] Store the username in the Keychain
func setUsername(_ username: String?) {
keychain.username = username
}
And the two server API calls:
func performLogin(withUsername username: String, andPassword password: String, completion: @escaping (Error?) -> ()) {
let requestModel = UserRequestModel(username: username, password: password) // [1] Create the request model
loginService.login(user: requestModel) { result in
switch result {
case .failure(let error):
completion(error)
case .success(_):
completion(nil)
}
}
}
func performRegister(withUsername username: String, andPassword password: String, completion: @escaping (Error?) -> ()) {
guard let hashedPasssword = Crypto.hash(message: password) else { // [1] Make sure we crash in case there are invalid passwords that could not be hashed
fatalError("Unable to hash password")
}
let requestModel = UserRequestModel(username: username, password: hashedPasssword) // [2] Create the request model (same model as we have for Login)
usersService.create(requestModel) { result in
switch result {
case .failure(let error):
completion(error)
case .success(let resourceId):
print("Created user: \(resourceId)")
completion(nil)
}
}
}
As you can see, the code looks much cleaner by separating the Model from the ViewController. Now, the View / ViewController asks the ViewModel for what it needs.
PlayerListViewController
is much bigger, harder to refactor and to extract the business logic than the LoginViewController
.
First, we want to leave just the outlets and all UIView
objects we require for this class.
In viewDidLoad
, we will do the setup and configuration of the initial state of the views, setting the view model delegate and trigger the player load through the view model.
Loading players:
private func loadPlayers() {
view.isUserInteractionEnabled = false
viewModel.fetchPlayers { [weak self] error in // [1] Pass the responsibility tot the ViewModel
DispatchQueue.main.async {
self?.view.isUserInteractionEnabled = true
if let error = error { // [2] Handle the response
self?.handleServiceFailures(withError: error)
} else {
self?.handleLoadPlayersSuccessfulResponse()
}
}
}
}
The response handling is similar as we have in LoginViewController
:
private func handleServiceFailures(withError error: Error) {
AlertHelper.present(in: self, title: "Error", message: String(describing: error)) // [1] Present an alert to the user
}
private func handleLoadPlayersSuccessfulResponse() {
if viewModel.playersCollectionIsEmpty {
showEmptyView() // [2] The players array is empty
} else {
hideEmptyView() // [3] No need to display the emptyView
}
playerTableView.reloadData() // [4] Reload the players table view
}
To display the model properties in the table view’s cell and configure it, we ask the ViewModel to give us the primitives and then we set them to the cell’s properties:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.numberOfRows // [1] The number of players in the array
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
return UITableViewCell()
}
if viewModel.isInListViewMode { // [2] When we are in the default view mode, showing the players
viewModel.clearSelectedPlayerIfNeeded(at: indexPath)
cell.setupDefaultView()
} else {
cell.setupSelectionView() // [3] When we are in the view mode for selecting the players for the gather
}
// [4] Display the model properties in the cell’s properties
cell.nameLabel.text = viewModel.playerNameDescription(at: indexPath)
cell.positionLabel.text = viewModel.playerPositionDescription(at: indexPath)
cell.skillLabel.text = viewModel.playerSkillDescription(at: indexPath)
cell.playerIsSelected = viewModel.playerIsSelected(at: indexPath)
return cell
}
To delete a player, we do the following:
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return viewModel.isInListViewMode // [1] Only in list view mode we can edit rows
}
// [2] Present a confirmation alert
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
let alertController = UIAlertController(title: "Delete player", message: "Are you sure you want to delete the selected player?", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
self?.handleDeletePlayerConfirmation(forRowAt: indexPath)
}
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
}
private func handleDeletePlayerConfirmation(forRowAt indexPath: IndexPath) {
requestDeletePlayer(at: indexPath) { [weak self] result in // [3] Perform the server call
guard result, let self = self else { return }
self.playerTableView.beginUpdates() // [4] In case the service succeeded, delete locally the player
self.viewModel.deleteLocallyPlayer(at: indexPath)
self.playerTableView.deleteRows(at: [indexPath], with: .fade)
self.playerTableView.endUpdates()
if self.viewModel.playersCollectionIsEmpty {
self.showEmptyView() // [5] Check if we need to display the empty view in case we haven’t any players left
}
}
}
private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
viewModel.requestDeletePlayer(at: indexPath) { [weak self] error in // [6] Tells the ViewModel to perform the API call for deleting the player
DispatchQueue.main.async {
self?.hideLoadingView()
if let error = error {
self?.handleServiceFailures(withError: error)
completion(false)
} else {
completion(true)
}
}
}
}
The navigation to Confirm / Detail and Add screens is done through performSegue
. We choose PlayerListViewModel
to be responsible to create the next screens view models and inject them in prepareForSegue
.
This is not the best approach, because we violate the SRP principle, but we will see in the Coordinator article how we can solve this problem.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segue.identifier {
case SegueIdentifier.confirmPlayers.rawValue:
if let confirmPlayersViewController = segue.destination as? ConfirmPlayersViewController {
confirmPlayersViewController.viewModel = viewModel.makeConfirmPlayersViewModel()
}
case SegueIdentifier.playerDetails.rawValue:
if let playerDetailsViewController = segue.destination as? PlayerDetailViewController, let player = viewModel.selectedPlayerForDetails {
playerDetailsViewController.delegate = self
playerDetailsViewController.viewModel = PlayerDetailViewModel(player: player)
}
case SegueIdentifier.addPlayer.rawValue:
(segue.destination as? PlayerAddViewController)?.delegate = self
default:
break
}
}
PlayerListViewModel
is rather big and contains a lot of properties and methods that are exposed to the View, all of them mandatory.
For the sake of the demo, we will leave it like it is and let the desired refactoring as an exercise to the readers. You could:
- separate
PlayerListViewController
in multipleViewControllers
/ViewModels
, all handled by a parent or container view controller. - split
PlayerListViewModel
in different components: by edit / list functions, service component, player selection.
The ViewState
(player selection and list modes) is implemented through Factory pattern:
final class PlayerListViewModel {
private var viewState: ViewState
private var viewStateDetails: LoginViewStateDetails {
return ViewStateDetailsFactory.makeViewStateDetails(from: viewState)
}
}
extension PlayerListViewModel {
enum ViewState {
case list
case selection
mutating func toggle() {
self = self == .list ? .selection : .list
}
}
}
And the concrete classes for list and selection:
// [1] Abstractization
protocol LoginViewStateDetails {
var barButtonItemTitle: String { get }
var actionButtonIsEnabled: Bool { get }
var actionButtonTitle: String { get }
var segueIdentifier: String { get }
}
fileprivate extension PlayerListViewModel {
struct ListViewStateDetails: LoginViewStateDetails {
var barButtonItemTitle: String {
return "Select"
}
var actionButtonIsEnabled: Bool {
return false
}
var segueIdentifier: String {
return SegueIdentifier.addPlayer.rawValue // [2] Binded to the bottom button action
}
var actionButtonTitle: String {
return "Add player"
}
}
struct SelectionViewStateDetails: LoginViewStateDetails {
var barButtonItemTitle: String {
return "Cancel"
}
var actionButtonIsEnabled: Bool {
return true
}
var segueIdentifier: String {
return SegueIdentifier.confirmPlayers.rawValue
}
var actionButtonTitle: String {
return "Confirm players"
}
}
enum ViewStateDetailsFactory {
static func makeViewStateDetails(from viewState: ViewState) -> LoginViewStateDetails {
switch viewState {
case .list:
return ListViewStateDetails()
case .selection:
return SelectionViewStateDetails()
}
}
}
}
The service methods are easy to read:
func fetchPlayers(completion: @escaping (Error?) -> ()) {
playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
switch result {
case .failure(let error):
completion(error)
case .success(let players):
self?.players = players
completion(nil)
}
}
}
func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Error?) -> Void) {
let player = players[indexPath.row]
var service = playersService
service.delete(withID: ResourceID.integer(player.id)) { result in
switch result {
case .failure(let error):
completion(error)
case .success(_):
completion(nil)
}
}
}
PlayerAddViewController
- defines the Add players screen.
Once a player was created, we use delegation pattern to notify Player Add screen and pop the view controller. The service call resides in the view model.
@objc private func doneAction(sender: UIBarButtonItem) {
guard let playerName = playerNameTextField.text else { return }
showLoadingView() // [1] Present the loading indicator
viewModel.requestCreatePlayer(name: playerName) { [weak self] playerWasCreated in // [2] Check if the service finished sucessfully
DispatchQueue.main.async {
self?.hideLoadingView()
if !playerWasCreated {
self?.handleServiceFailure()
} else {
self?.handleServiceSuccess()
}
}
}
}
private func handleServiceFailure() {
AlertHelper.present(in: self, title: "Error update", message: "Unable to create player. Please try again.")
}
private func handleServiceSuccess() {
delegate?.playerWasAdded()
navigationController?.popViewController(animated: true)
}
The ViewModel entity is presented below:
struct PlayerAddViewModel {
private let service: StandardNetworkService // [1] Used to request a player creation
init(service: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
self.service = service
}
// [2] The title of the view controller
var title: String {
return "Add Player"
}
// [3] Service API call
func requestCreatePlayer(name: String, completion: @escaping (Bool) -> Void) {
let player = PlayerCreateModel(name: name)
service.create(player) { result in
if case .success(_) = result {
completion(true)
} else {
completion(false)
}
}
}
// [4] Defines the state of the done buttokn
func doneButtonIsEnabled(forText text: String?) -> Bool {
return text?.isEmpty == false
}
}
PlayerDetailViewController
defines the Details screen
The view model is created and passed in the PlayerListViewController
's method, prepareForSegue
.
We use the same approach when navigating to PlayerEditViewController
:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard segue.identifier == SegueIdentifier.editPlayer.rawValue,
let destinationViewController = segue.destination as? PlayerEditViewController else {
return
}
destinationViewController.viewModel = viewModel.makeEditViewModel()
destinationViewController.delegate = self
}
Displaying the player’s details is done similar as we have in PlayerList screen: the View asks the ViewModel for the properties and sets the labels’ text.
extension PlayerDetailViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.numberOfSections
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.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 = viewModel.rowTitleDescription(for: indexPath)
cell.rightLabel.text = viewModel.rowValueDescription(for: indexPath)
return cell
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return viewModel.titleForHeaderInSection(section)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
viewModel.selectPlayerRow(at: indexPath)
performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
}
}
When the user finished editing a player in the presented screen, didFinishEditing
is called:
extension PlayerDetailViewController: PlayerEditViewControllerDelegate {
func didFinishEditing(player: PlayerResponseModel) {
setupTitle() // [1] If the player’s name changed, reload the title
viewModel.updatePlayer(player) // [2] Update the player local model
viewModel.reloadSections() // [3] Reconstruct the sections model
reloadData() // [4] Reload data from table view
delegate?.didEdit(player: player) // [5] Tell PlayerList that the player was updated
}
}
PlayerDetailViewModel
has the following properties:
final class PlayerDetailViewModel {
// MARK: - Properties
private(set) var player: PlayerResponseModel // [1] player that is viewable in the screen
private lazy var sections = makeSections() // [2] all data is displayed in multiple sections
private(set) var selectedPlayerRow: PlayerRow? // [3] used for holding the tapped player row information
// MARK: - Public API
init(player: PlayerResponseModel) {
self.player = player
}
}
PlayerEditViewController
The segue to display the Edit screen is triggered from PlayerDetails screen. This is the place where you can edit the players details.
The ViewModel is passed from PlayerDetailsViewController
.
Following the same approach, we moved all server API interaction, plus the model handling, in the ViewModel.
The edit text field is configured based on the ViewModel's properties:
private func setupPlayerEditTextField() {
playerEditTextField.placeholder = viewModel.playerRowValue
playerEditTextField.text = viewModel.playerRowValue
playerEditTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
playerEditTextField.isHidden = viewModel.isSelectionViewType
}
When we are done with editing the player’s information, we ask the view model to perform the server updates and after it’s done, we handle the success or failure responses.
In case we have a failure, we inform the user, and in case the server call was successful, we notify the delegate and pop this view controller from the view controllers stack.
@objc private func doneAction(sender: UIBarButtonItem) {
guard viewModel.shouldUpdatePlayer(inputFieldValue: playerEditTextField.text) else { return }
showLoadingView()
viewModel.updatePlayerBasedOnViewType(inputFieldValue: playerEditTextField.text) { [weak self] updated in
DispatchQueue.main.async {
self?.hideLoadingView()
if updated {
self?.handleSuccessfulPlayerUpdate()
} else {
self?.handleServiceError()
}
}
}
}
private func handleSuccessfulPlayerUpdate() {
delegate?.didFinishEditing(player: viewModel.editablePlayer)
navigationController?.popViewController(animated: true)
}
private func handleServiceError() {
AlertHelper.present(in: self, title: "Error update", message: "Unable to update player. Please try again.")
}
PlayerEditViewModel
is similar with the rest, most important methods would be the player update ones:
// [1] Checks if the entered value in the field is different from the old value
func shouldUpdatePlayer(inputFieldValue: String?) -> Bool {
if isSelectionViewType {
return newValueIsDifferentFromOldValue(newFieldValue: selectedItemValue)
}
return newValueIsDifferentFromOldValue(newFieldValue: inputFieldValue)
}
private func newValueIsDifferentFromOldValue(newFieldValue: String?) -> Bool {
guard let newFieldValue = newFieldValue else { return false }
return playerEditModel.playerRow.value.lowercased() != newFieldValue.lowercased()
}
// [2] There are two different ways to update player information.
// One is through the input / textField where you can type, for example the name or age of the player
// and the other one is through selection where you can choose a different option (applied to player’s position or skill).
private var selectedItemValue: String? {
guard let playerItemsEditModel = playerItemsEditModel else { return nil}
return playerItemsEditModel.items[playerItemsEditModel.selectedItemIndex]
}
// [3] Decides what needs to be updated (if inputFieldValue is nil, than it will update the player through selection mode).
func updatePlayerBasedOnViewType(inputFieldValue: String?, completion: @escaping (Bool) -> ()) {
if isSelectionViewType {
updatePlayer(newFieldValue: selectedItemValue, completion: completion)
} else {
updatePlayer(newFieldValue: inputFieldValue, completion: completion)
}
}
private func updatePlayer(newFieldValue: String?, completion: @escaping (Bool) -> ()) {
guard let newFieldValue = newFieldValue else {
completion(false)
return
}
playerEditModel.player.update(usingField: playerEditModel.playerRow.editableField, value: newFieldValue)
requestUpdatePlayer(completion: completion)
}
// [4] Perfoms the player service update call
private func requestUpdatePlayer(completion: @escaping (Bool) -> ()) {
let player = playerEditModel.player
service.update(PlayerCreateModel(player), resourceID: ResourceID.integer(player.id)) { [weak self] result in
if case .success(let updated) = result {
self?.playerEditModel.player = player
completion(updated)
} else {
completion(false)
}
}
}
ConfirmPlayersViewController
Before reaching Gather screen, we have to confirm the selected players. This screen is defined by ConfirmPlayersViewController
.
In viewDidLoad
we setup the UI elements, such as the table view and configure the start gather button:
func setupViews() {
playerTableView.isEditing = viewModel.playerTableViewIsEditing
configureStartGatherButton()
}
The server API call is presented below:
@IBAction private func startGather(_ sender: Any) {
showLoadingView()
viewModel.startGather { [weak self] result in
DispatchQueue.main.async {
self?.hideLoadingView()
if !result {
self?.handleServiceFailure()
} else {
self?.performSegue(withIdentifier: SegueIdentifier.gather.rawValue, sender: nil)
}
}
}
}
private func handleServiceFailure() {
AlertHelper.present(in: self, title: "Error", message: "Unable to create gather.")
}
And the TableView Delegate and DataSource:
extension ConfirmPlayersViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.numberOfSections
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return viewModel.titleForHeaderInSection(section)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.numberOfRowsInSection(section)
}
func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
return .none
}
func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
return false
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerChooseTableViewCellId") else {
return UITableViewCell()
}
cell.textLabel?.text = viewModel.rowTitle(at: indexPath)
cell.detailTextLabel?.text = viewModel.rowDescription(at: indexPath)
return cell
}
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
viewModel.moveRowAt(sourceIndexPath: sourceIndexPath, to: destinationIndexPath)
configureStartGatherButton()
}
}
ConfirmPlayersViewModel
contains the playersDictionary
with the selected players and their teams, the services needed to add players to a gather and to start the gather, the gatherUUID
which is defined after a gather is created on the server and a dispatchGroup
to orchestrate the multiple server calls.
final class ConfirmPlayersViewModel {
// MARK: - Properties
private var playersDictionary: [TeamSection: [PlayerResponseModel]]
private var addPlayerService: AddPlayerToGatherService
private let gatherService: StandardNetworkService
private let dispatchGroup = DispatchGroup()
private var gatherUUID: UUID?
// MARK: - Public API
init(playersDictionary: [TeamSection: [PlayerResponseModel]] = [:],
addPlayerService: AddPlayerToGatherService = AddPlayerToGatherService(),
gatherService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/gathers", authenticated: true)) {
self.playersDictionary = playersDictionary
self.addPlayerService = addPlayerService
self.gatherService = gatherService
}
}
The most complex thing from this class is the server API interaction when starting a gather:
// [1] Main function for starting a taher
func startGather(completion: @escaping (Bool) -> ()) {
createGather { [weak self] uuid in
guard let gatherUUID = uuid else {
completion(false)
return
}
// [2] The gather was created, now is time to put the selected players in it.
self?.gatherUUID = gatherUUID
self?.addPlayersToGather(havingUUID: gatherUUID, completion: completion)
}
}
private func createGather(completion: @escaping (UUID?) -> Void) {
gatherService.create(GatherCreateModel()) { result in
if case let .success(ResourceID.uuid(gatherUUID)) = result {
completion(gatherUUID)
} else {
completion(nil)
}
}
}
// [3] Use the dispatch group to add the players to the gather
private func addPlayersToGather(havingUUID gatherUUID: UUID, completion: @escaping (Bool) -> ()) {
var serviceFailed = false
playerTeamArray.forEach { playerTeam in
dispatchGroup.enter()
self.addPlayer(playerTeam.player, toGatherHavingUUID: gatherUUID, team: playerTeam.team) { [weak self] playerWasAdded in
if !playerWasAdded {
serviceFailed = true
}
self?.dispatchGroup.leave()
}
}
// [4] The for loop finished, it’s time to call the completion closure.
dispatchGroup.notify(queue: DispatchQueue.main) {
completion(serviceFailed)
}
}
// [5] Maps the players to the PlayerTeamModel
private var playerTeamArray: [PlayerTeamModel] {
var players: [PlayerTeamModel] = []
players += self.playersDictionary
.filter { $0.key == .teamA }
.flatMap { $0.value }
.map { PlayerTeamModel(team: .teamA, player: $0) }
players += self.playersDictionary
.filter { $0.key == .teamB }
.flatMap { $0.value }
.map { PlayerTeamModel(team: .teamB, player: $0) }
return players
}
// [6] This is the service API call to add a player to a gather.
private func addPlayer(_ player: PlayerResponseModel,
toGatherHavingUUID gatherUUID: UUID,
team: TeamSection,
completion: @escaping (Bool) -> Void) {
addPlayerService.addPlayer(
havingServerId: player.id,
toGatherWithId: gatherUUID,
team: PlayerGatherTeam(team: team.headerTitle)) { result in
if case let .success(resultValue) = result {
completion(resultValue)
} else {
completion(false)
}
}
}
GatherViewController
Finally, we have GatherViewController
, belonging to the most important screen from FootballGather.
We manage to clean the properties and left the IBOutlets, plus the loading view and the view model:
final class GatherViewController: UIViewController, Loadable {
// MARK: - Properties
@IBOutlet weak var playerTableView: UITableView!
@IBOutlet weak var scoreLabelView: ScoreLabelView!
@IBOutlet weak var scoreStepper: ScoreStepper!
@IBOutlet weak var timerLabel: UILabel!
@IBOutlet weak var timerView: UIView!
@IBOutlet weak var timePickerView: UIPickerView!
@IBOutlet weak var actionTimerButton: UIButton!
lazy var loadingView = LoadingView.initToView(self.view)
var viewModel: GatherViewModel!
}
In viewDidLoad
, we setup and configure the views:
override func viewDidLoad() {
super.viewDidLoad()
setupViewModel()
setupTitle()
configureSelectedTime()
hideTimerView()
configureTimePickerView()
configureActionTimerButton()
setupScoreStepper()
reloadData()
}
private func setupTitle() {
title = viewModel.title
}
private func setupViewModel() {
viewModel.delegate = self
}
private func configureSelectedTime() {
timerLabel?.text = viewModel.formattedCountdownTimerLabelText
}
private func configureActionTimerButton() {
actionTimerButton.setTitle(viewModel.formattedActionTitleText, for: .normal)
}
private func hideTimerView() {
timerView.isHidden = true
}
private func showTimerView() {
timerView.isHidden = false
}
private func setupScoreStepper() {
scoreStepper.delegate = self
}
private func reloadData() {
timePickerView.reloadAllComponents()
playerTableView.reloadData()
}
The timer related functions are looking neat:
@IBAction private func setTimer(_ sender: Any) {
configureTimePickerView()
showTimerView()
}
@IBAction private func cancelTimer(_ sender: Any) {
viewModel.stopTimer()
viewModel.resetTimer()
configureSelectedTime()
configureActionTimerButton()
hideTimerView()
}
@IBAction private func actionTimer(_ sender: Any) {
viewModel.toggleTimer()
configureActionTimerButton()
}
@IBAction private func timerCancel(_ sender: Any) {
hideTimerView()
}
@IBAction private func timerDone(_ sender: Any) {
viewModel.stopTimer()
viewModel.setTimerMinutes(selectedMinutesRow)
viewModel.setTimerSeconds(selectedSecondsRow)
configureSelectedTime()
configureActionTimerButton()
hideTimerView()
}
private var selectedMinutesRow: Int { timePickerView.selectedRow(inComponent: viewModel.minutesComponent) }
private var selectedSecondsRow: Int { timePickerView.selectedRow(inComponent: viewModel.secondsComponent) }
And the endGather
API interaction:
@IBAction private func endGather(_ sender: Any) {
let alertController = UIAlertController(title: "End Gather", message: "Are you sure you want to end the gather?", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "Yes", style: .default) { [weak self] _ in
self?.endGather()
}
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
}
private func endGather() {
guard let scoreTeamAString = scoreLabelView.teamAScoreLabel.text,
let scoreTeamBString = scoreLabelView.teamBScoreLabel.text else {
return
}
showLoadingView()
viewModel.endGather(teamAScoreLabelText: scoreTeamAString, teamBScoreLabelText: scoreTeamBString) { [weak self] updated in
DispatchQueue.main.async {
self?.hideLoadingView()
if !updated {
self?.handleServiceFailure()
} else {
self?.handleServiceSuccess()
}
}
}
}
private func handleServiceFailure() {
AlertHelper.present(in: self, title: "Error update", message: "Unable to update gather. Please try again.")
}
private func handleServiceSuccess() {
guard let playerListTogglable = navigationController?.viewControllers.first(where: { $0 is PlayerListTogglable }) as? PlayerListTogglable else {
return
}
playerListTogglable.toggleViewState()
if let playerListViewController = playerListTogglable as? UIViewController {
navigationController?.popToViewController(playerListViewController, animated: true)
}
}
The table view's DataSource and Delegate are looking great as well, clean and simple:
// MARK: - UITableViewDelegate | UITableViewDataSource
extension GatherViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
viewModel.numberOfSections
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
viewModel.titleForHeaderInSection(section)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.numberOfRowsInSection(section)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "GatherCellId") else {
return UITableViewCell()
}
let rowDescription = viewModel.rowDescription(at: indexPath)
cell.textLabel?.text = rowDescription.title
cell.detailTextLabel?.text = rowDescription.details
return cell
}
}
And the rest of the methods:
// MARK: - ScoreStepperDelegate
extension GatherViewController: ScoreStepperDelegate {
func stepper(_ stepper: UIStepper, didChangeValueForTeam team: TeamSection, newValue: Double) {
if viewModel.shouldUpdateTeamALabel(section: team) {
scoreLabelView.teamAScoreLabel.text = viewModel.formatStepperValue(newValue)
} else if viewModel.shouldUpdateTeamBLabel(section: team) {
scoreLabelView.teamBScoreLabel.text = viewModel.formatStepperValue(newValue)
}
}
}
// MARK: - UIPickerViewDataSource
extension GatherViewController: UIPickerViewDataSource, UIPickerViewDelegate {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
viewModel.numberOfPickerComponents
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
viewModel.numberOfRowsInPickerComponent(component)
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
viewModel.titleForPickerRow(row, forComponent: component)
}
}
// MARK: - GatherViewModelDelegate
extension GatherViewController: GatherViewModelDelegate {
func didUpdateGatherTime() {
configureSelectedTime()
}
}
Cleaning the ViewController came with some downsides in the ViewModel class. It has a lot of methods and the class is big (around 200 lines of code).
We decided to move out the Timer interaction into a new struct, called GatherTimeHandler
.
In this struct, we expose selectedTime
which is set from outside of the class, and has two more variables: the timer and a state variable (can be stopped, running or paused).
The public API has methods such as stop, reset and toggle timer, as well as decrementTime
:
mutating func decrementTime() {
if selectedTime.seconds == 0 {
decrementMinutes()
} else {
decrementSeconds()
}
if selectedTimeIsZero {
stopTimer()
}
}
private mutating func decrementMinutes() {
selectedTime.minutes -= 1
selectedTime.seconds = 59
}
private mutating func decrementSeconds() {
selectedTime.seconds -= 1
}
private var selectedTimeIsZero: Bool {
return selectedTime.seconds == 0 && selectedTime.minutes == 0
}
Overall, this is looking much better from the first iteration where we implemented the app via MVC.
Testing our business logic
The most important part is the ViewModel. Here, we have implemented the business logic.
Testing the title:
func testTitle_whenViewModelIsAllocated_isNotEmpty() {
// given
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let sut = GatherViewModel(gatherModel: mockGatherModel)
// when
let title = sut.title
// then
XCTAssertFalse(title.isEmpty)
}
Testing the formatted countdown timer label text:
func testFormattedCountdownTimerLabelText_whenViewModelIsAllocated_returnsDefaultTime() {
// given
let gatherTime = GatherTime.defaultTime
// Define the expected values, format should be 00:00.
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 = GatherViewModel(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 = GatherViewModel(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 = GatherViewModel(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 = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
// then
XCTAssertEqual(formattedCountdownTimerLabelText, "00:10")
}
Testing the action title text, that should be Start, Resume or Pause.
// We set the state to be initially .paused
func testFormattedActionTitleText_whenStateIsPaused_returnsResume() {
// given
let mockGatherTimeHandler = GatherTimeHandler(state: .paused)
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
let formattedActionTitleText = sut.formattedActionTitleText
// then
XCTAssertEqual(formattedActionTitleText, "Resume")
}
We follow the same approach for Pause and Start:
func testFormattedActionTitleText_whenStateIsRunning_returnsPause() {
// given
let mockGatherTimeHandler = GatherTimeHandler(state: .running)
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
let formattedActionTitleText = sut.formattedActionTitleText
// then
XCTAssertEqual(formattedActionTitleText, "Pause")
}
func testFormattedActionTitleText_whenStateIsStopped_returnsStart() {
// given
let mockGatherTimeHandler = GatherTimeHandler(state: .stopped)
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
let formattedActionTitleText = sut.formattedActionTitleText
// then
XCTAssertEqual(formattedActionTitleText, "Start")
}
For testing the stopTimer
function, we mock the system to be in a running state
func testStopTimer_whenStateIsRunning_updatesStateToStopped() {
// given
let mockGatherTimeHandler = GatherTimeHandler(state: .running)
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
sut.stopTimer()
// then
let formattedActionTitleText = sut.formattedActionTitleText
XCTAssertEqual(formattedActionTitleText, "Start")
}
Same for resetTimer
:
func testResetTimer_whenTimeIsSet_returnsDefaultTime() {
// given
let mockMinutes = 12
let mockSeconds = 13
let mockGatherTime = GatherTime(minutes: mockMinutes, seconds: mockSeconds)
let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
sut.resetTimer()
// then
XCTAssertNotEqual(sut.selectedMinutes, mockMinutes)
XCTAssertNotEqual(sut.selectedSeconds, mockSeconds)
XCTAssertEqual(sut.selectedMinutes, GatherTime.defaultTime.minutes)
XCTAssertEqual(sut.selectedSeconds, GatherTime.defaultTime.seconds)
}
The delegates of the pickerView
and tableView
are very easy to test. We exemplify some unit tests below:
func testNumberOfRowsInSection_whenViewModelHasPlayers_returnsCorrectNumberOfPlayers() {
// given
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 5)
let teamAPlayersCount = mockGatherModel.players.filter { $0.team == .teamA}.count
let teamBPlayersCount = mockGatherModel.players.filter { $0.team == .teamB}.count
let sut = GatherViewModel(gatherModel: mockGatherModel)
// when
let numberOfRowsInSection0 = sut.numberOfRowsInSection(0)
let numberOfRowsInSection1 = sut.numberOfRowsInSection(1)
// then
XCTAssertEqual(numberOfRowsInSection0, teamAPlayersCount)
XCTAssertEqual(numberOfRowsInSection1, teamBPlayersCount)
}
func testNumberOfRowsInPickerComponent_whenComponentIsMinutes_returns60() {
// given
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let sut = GatherViewModel(gatherModel: mockGatherModel)
// when
let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(GatherTimeHandler.Component.minutes.rawValue)
// then
XCTAssertEqual(numberOfRowsInPickerComponent, 60)
}
For ending a gather we use the mocked endpoint and models. We verify if the received response is true:
func testEndGather_whenScoreIsSet_updatesGather() {
// given
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
let mockService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint, keychain: appKeychain))
let sut = GatherViewModel(gatherModel: mockGatherModel, updateGatherService: mockService)
let exp = expectation(description: "Update gather expectation")
// when
sut.endGather(teamAScoreLabelText: "1", teamBScoreLabelText: "1") { gatherUpdated in
XCTAssertTrue(gatherUpdated)
exp.fulfill()
}
// then
waitForExpectations(timeout: 5, handler: nil)
}
To check if the timer is toggled, we use a MockViewModelDelegate
:
private extension GatherViewModelTests {
final class MockViewModelDelegate: GatherViewModelDelegate {
// [1] Used to check if the delegate was called (didUpdateGatherTime())
private(set) var gatherTimeWasUpdated = false
// [2] Is fulfilled when the numberOfUpdateCalls is equal to actualUpdateCalls.
// This means that the selector for the timer was called as many times as we wanted.
weak var expectation: XCTestExpectation? = nil
var numberOfUpdateCalls = 1
private(set) var actualUpdateCalls = 0
func didUpdateGatherTime() {
gatherTimeWasUpdated = true
actualUpdateCalls += 1 // [3] Increment the number of calls to this method
if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
expectation?.fulfill()
}
}
}
}
And the unit test:
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)
let exp = expectation(description: "Waiting timer expectation")
let mockDelegate = MockViewModelDelegate()
mockDelegate.numberOfUpdateCalls = numberOfUpdateCalls
mockDelegate.expectation = exp
let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
sut.delegate = mockDelegate
sut.toggleTimer()
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockDelegate.gatherTimeWasUpdated)
XCTAssertEqual(mockDelegate.actualUpdateCalls, numberOfUpdateCalls)
sut.stopTimer()
}
}
Compared with testing the ViewController in the MVC architecture, life becomes easier when testing the ViewModel layer. The unit tests are easy to write, easier to understand and much simpler.
Key Metrics
Lines of code - View Controllers
File | Number of lines of code | MVC - Lines of code |
---|---|---|
PlayerAddViewController | 77 | 79 (-2) |
PlayerListViewController | 296 | 387 (-91) |
PlayerDetailViewController | 96 | 204 (-108) |
LoginViewController | 96 | 126 (-30) |
PlayerEditViewController | 124 | 212 (-88) |
GatherViewController | 227 | 359 (-132) |
ConfirmPlayersViewController | 104 | 260 (-156) |
TOTAL | 1020 | 1627 (-607) |
Lines of code - View Models
File | Number of lines of code |
---|---|
LoginViewModel | 75 |
PlayerListViewModel | 258 |
PlayerEditViewModel | 155 |
PlayerAddViewModel | 37 |
PlayerDetailViewModel | 178 |
GatherViewModel | 204 |
ConfirmPlayersViewModel | 206 |
TOTAL | 1113 |
Unit Tests
Topic | Data | MVC Data |
---|---|---|
Number of key classes (ViewControllers and ViewModels) | 14 | 7 +7 |
Key Class | GatherViewModel | GatherViewController |
Number of Unit Tests | 34 | 30 +4 |
Code Coverage of Gathers feature | 97.3% | 95.7% +1.6 |
How hard to write unit tests | 3/5 | 5/5 -2 |
Build Times
Build | Time (sec)* | MVC Time (sec)* |
---|---|---|
Average Build Time (after clean Derived Data & Clean Build) | 9.65 | 9.78 -0.13 |
Average Build Time | 0.1 | 0.1 |
Average Unit Test Execution Time (after clean Derived Data & Clean Build) | 17.88 | 12.78 +5.1 |
* tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019
Conclusion
Our application has now been transformed from MVC to MVVM. We added a new layer to handle the business logic and decouple it from the View Controller, separating better the responsibilities.
MVVM is a good pattern and worked great to reduce the complexity of our View Controllers, sliming the implementation down. The unit tests covering the business logic, were also easier to write.
However, when working with UIKit
in your projects, MVVM is unnatural and hard to apply.
Looking at the key metrics, we can note down the following observations:
- we reduced considerably the number of lines of codes in the view controllers by 607 lines of code
- on the other hand, the view models took us 1113 lines of code to write
- in total, we added 506 lines of code and 7 files to our app
- a slightly negative impact was on the average unit test execution time, being increased with 5.1 seconds
- the code coverage applied to the Gathers feature, increased with 1.6%, solidifying a total of 97.3%, giving more confidence when adopting changes and refactoring parts of the app, without breaking the existing logic
- comparing to the MVC, the unit tests covering the business logic were much easier to write
In conclusion, MVVM was a fun exercise; we are now having a much cleaner application and we can even say it's less error prone.
Thanks for staying until the end! We have 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 |