Geek Culture

A new tech publication by Start it up (https://medium.com/swlh).

Battle of the iOS Architecture Patterns: View Interactor Presenter (VIP)

--

Architecture Series — View Interactor Presenter (VIP)

Motivation

Before diving into iOS app development, it’s crucial to carefully consider the project’s architecture. We need to thoughtfully plan how different pieces of code will fit together, ensuring they remain comprehensible not just today, but months or years later when we need to revisit and modify the codebase. Moreover, a well-structured project helps establish a shared technical vocabulary among team members, making collaboration more efficient.

This article kicks off an exciting series where we’ll explore different architectural approaches by building the same application using various patterns. Throughout the series, we’ll analyze practical aspects like build times and implementation complexity, weigh the pros and cons of each pattern, and most importantly, examine real, production-ready code implementations. This hands-on approach will help you make informed decisions about which architecture best suits your project needs.

Architecture Series Articles

If you’re eager to explore the implementation details directly, you can find the complete source code in our open-source repository here.

Why Your iOS App Needs a Solid Architecture Pattern

The cornerstone of any successful iOS application is maintainability. A well-architected app clearly defines boundaries — you know exactly where view logic belongs, what responsibilities each view controller has, and which components handle business logic. This clarity isn’t just for you; it’s essential for your entire development team to understand and maintain these boundaries consistently.

Here are the key benefits of implementing a robust architecture pattern:

  • Maintainability: Makes code easier to update and modify over time
  • Testability: Facilitates comprehensive testing of business logic through clear separation of concerns
  • Team Collaboration: Creates a shared technical vocabulary and understanding among team members
  • Clean Separation: Ensures each component has clear, single responsibilities
  • Bug Reduction: Minimizes errors through better organization and clearer interfaces between components

Project Requirements Overview

Given a medium-sized iOS application consisting of 6–7 screens, we’ll demonstrate how to implement it using the most popular architectural patterns in the iOS ecosystem: MVC (Model-View-Controller), MVVM (Model-View-ViewModel), MVP (Model-View-Presenter), VIPER (View-Interactor-Presenter-Entity-Router), VIP (Clean Swift), and the Coordinator pattern. Each implementation will showcase the pattern’s strengths and potential challenges.

Our demo application, Football Gather, is designed to help friends organize and track their casual football matches. It’s complex enough to demonstrate real-world architectural challenges while remaining simple enough to clearly illustrate different patterns.

Core Features and Functionality

  • Player Management: Add and maintain a roster of players in the application
  • Team Assignment: Flexibly organize players into different teams for each match
  • Player Customization: Edit player details and preferences
  • Match Management: Set and control countdown timers for match duration

Screen Mockups

Backend

The application is supported by a web backend developed using the Vapor framework, a popular choice for building modern REST APIs in Swift. You can explore the details of this implementation in our article here, which covers the fundamentals of building REST APIs with Vapor and Fluent in Swift. Additionally, we have documented the transition from Vapor 3 to Vapor 4, highlighting the enhancements and new features in our article here.

Cleaning Your Code Like a VIP

VIP is not a widely adopted architecture pattern, but it offers unique advantages for clean and scalable iOS development. Invented by Raymond Law, VIP is an adaptation of Uncle Bob’s Clean Architecture principles tailored for iOS projects. For more details, visit Clean Swift.

The primary goal of VIP is to address the “Massive View Controller” problem often associated with MVC. VIP also aims to resolve challenges seen in other architecture patterns. For example, while VIPER places the Presenter at the center of the application, VIP simplifies this process by using a unidirectional flow of control, making it easier to manage method invocations across layers.

VIP organizes your app into distinct control cycles, ensuring a clear and consistent flow of data and actions.

Example Scenario of Applying VIP:

  1. A user taps a button to fetch a list of players, starting in the ViewController.
  2. The associated IBAction triggers a method in the Interactor.
  3. The Interactor processes the request, executes business logic (e.g., fetching players from a server), and sends the response to the Presenter to format it for display.
  4. The Presenter passes the formatted data to the ViewController, which displays it to the user.

VIP Architecture Components

View/ViewController

The View/ViewController layer has two primary responsibilities: sending user actions to the Interactor and displaying data received from the Presenter.

Interactor

Known as the “new Presenter,” the Interactor serves as the core of the VIP architecture. It handles tasks like network calls, error handling, and business logic computation.

Worker

In some cases (e.g., in Football Gather), we refer to Workers as “Services.” A Worker offloads specific tasks from the Interactor, such as managing network requests or database interactions.

Presenter

The Presenter processes data from the Interactor and formats it into a ViewModel suitable for display in the View.

Router

The Router manages scene transitions, similar to its role in VIPER.

Models

The Model layer encapsulates data, much like in other architectural patterns.

Communication

Communication flows in a unidirectional manner:

  • The ViewController interacts with both the Router and the Interactor.
  • The Interactor processes data and communicates with the Presenter. It may also collaborate with Workers for specific tasks.
  • The Presenter formats the response from the Interactor into a ViewModel and sends it to the View/ViewController.

Advantages of VIP

  • Eliminates the “Massive View Controller” issue found in MVC.
  • Avoids the “Massive View Model” problem that can occur with incorrect MVVM implementation.
  • Solves control issues seen in VIPER by introducing the VIP cycle.
  • Prevents “Massive Presenters” often encountered with improper VIPER use.
  • Aligns with Clean Architecture principles, ensuring separation of concerns.
  • Facilitates handling of complex business logic by delegating to Workers.
  • Highly testable and compatible with TDD practices.
  • Offers good modularity and easier debugging.

Disadvantages of VIP

  • Introduces numerous layers, which can become tedious without code generation tools.
  • Involves writing significant amounts of code, even for simple actions.
  • Not well-suited for small applications due to its complexity.
  • Certain components may feel redundant, depending on the app’s use case.
  • Slightly increases app startup time.

VIP vs. VIPER

  • In VIP, the Interactor directly interacts with the ViewController.
  • The ViewController holds a reference to the Router in VIP.
  • VIPER’s flexibility can lead to “Massive Presenters” if not implemented correctly.
  • VIP maintains a unidirectional flow of control.
  • Services in VIPER are referred to as Workers in VIP.

Applying VIP to Our Code

Transitioning an app from VIPER to VIP is not a straightforward process. The first step is to transform the Presenter into an Interactor. Afterward, extract the Router from the Presenter and integrate it into the ViewController.

The module assembly logic from VIPER can remain intact, simplifying the process.

Login Scene

Let’s start by applying these changes to the Login scene and iterating from there.

final class LoginViewController: UIViewController, LoginViewable {

// MARK: - Properties
@IBOutlet private weak var usernameTextField: UITextField!
@IBOutlet private weak var passwordTextField: UITextField!
@IBOutlet private weak var rememberMeSwitch: UISwitch!

lazy var loadingView = LoadingView.initToView(view)

var interactor: LoginInteractorProtocol = LoginInteractor()
var router: LoginRouterProtocol = LoginRouter()

// MARK: - View life cycle
override func viewDidLoad() {
super.viewDidLoad()
loadCredentials()
}

private func loadCredentials() {
let request = Login.LoadCredentials.Request()
interactor.loadCredentials(request: request)
}

// ...
}

As you can see we no longer tell the Presenter that the view has been loaded. We now make a request to the Interactor to load the credentials.

The IBActions have been modified as below:

final class LoginViewController: UIViewController, LoginViewable {

// ...
@IBAction private func login(_ sender: Any) {
showLoadingView()
let request = Login.Authenticate.Request(username: usernameTextField.text,
password: passwordTextField.text,
storeCredentials: rememberMeSwitch.isOn)
interactor.login(request: request)
}

@IBAction private func register(_ sender: Any) {
showLoadingView()
let request = Login.Authenticate.Request(username: usernameTextField.text,
password: passwordTextField.text,
storeCredentials: rememberMeSwitch.isOn)
interactor.register(request: request)
}
// ...
}

We start the loading view, construct the request to the Interactor containing the username, password contents of the text fields and the state of the UISwitch for remembering the username.

Next, handling the viewDidLoad UI updates are made through the LoginViewConfigurable protocol:

extension LoginViewController: LoginViewConfigurable {
func displayStoredCredentials(viewModel: Login.LoadCredentials.ViewModel) {
rememberMeSwitch.isOn = viewModel.rememberMeIsOn
usernameTextField.text = viewModel.usernameText
}
}

Finally, when the logic service call has been completed we call from the Presenter the following method:

func loginCompleted(viewModel: Login.Authenticate.ViewModel) {
hideLoadingView()

if viewModel.isSuccessful {
router.showPlayerList()
} else {
handleError(title: viewModel.errorTitle!, message: viewModel.errorDescription!)
}
}

The Interactor looks the same as the one from the VIPER architecture. It has the same dependencies:

final class LoginInteractor: LoginInteractable {

var presenter: LoginPresenterProtocol

private let loginService: LoginService
private let usersService: StandardNetworkService
private let userDefaults: FootballGatherUserDefaults
private let keychain: FootbalGatherKeychain

init(presenter: LoginPresenterProtocol = LoginPresenter(),
loginService: LoginService = LoginService(),
usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
userDefaults: FootballGatherUserDefaults = .shared,
keychain: FootbalGatherKeychain = .shared) {
self.presenter = presenter
self.loginService = loginService
self.usersService = usersService
self.userDefaults = userDefaults
self.keychain = keychain
}
}

The key thing here is that we now inject the Presenter through the initializer and it is no longer a weak variable.

Loading credentials is presented below. We first take the incoming request from the ViewController. We create a response for the presenter and call the function presentCredentials(response: response).

func loadCredentials(request: Login.LoadCredentials.Request) {
let rememberUsername = userDefaults.rememberUsername ?? true
let username = keychain.username
let response = Login.LoadCredentials.Response(rememberUsername: rememberUsername, username: username)
presenter.presentCredentials(response: response)
}

The login and register methods are the same, the exception being the Network service (Worker).

func login(request: Login.Authenticate.Request) {
guard let username = request.username, let password = request.password else {
let response = Login.Authenticate.Response(error: .missingCredentials)
presenter.authenticationCompleted(response: response)
return
}

let requestModel = UserRequestModel(username: username, password: password)

loginService.login(user: requestModel) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .failure(let error):
let response = Login.Authenticate.Response(error: .loginFailed(error.localizedDescription))
self?.presenter.authenticationCompleted(response: response)
case .success(_):
guard let self = self else { return }
self.updateCredentials(username: username, shouldStore: request.storeCredentials)
let response = Login.Authenticate.Response(error: nil)
self.presenter.authenticationCompleted(response: response)
}
}
}
}

private func updateCredentials(username: String, shouldStore: Bool) {
keychain.username = shouldStore ? username : nil
userDefaults.rememberUsername = shouldStore
}

The Presenter doesn’t hold references to the Router or Interactor. We just keep the dependency of the View, which has to be weak to complete the VIP cycle and not have retain cycles.

The Presenter has been greatly simplified, exposing two methods of the public API:

func presentCredentials(response: Login.LoadCredentials.Response) {
let viewModel = Login.LoadCredentials.ViewModel(rememberMeIsOn: response.rememberUsername,
usernameText: response.username)
view?.displayStoredCredentials(viewModel: viewModel)
}

func authenticationCompleted(response: Login.Authenticate.Response) {
guard response.error == nil else {
handleServiceError(response.error)
return
}

let viewModel = Login.Authenticate.ViewModel(isSuccessful: true, errorTitle: nil, errorDescription: nil)
view?.loginCompleted(viewModel: viewModel)
}

private func handleServiceError(_ error: LoginError?) {
switch error {
case .missingCredentials:
let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
errorTitle: "Error",
errorDescription: "Both fields are mandatory.")
view?.loginCompleted(viewModel: viewModel)
case .loginFailed(let message), .registerFailed(let message):
let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
errorTitle: "Error",
errorDescription: String(describing: message))
view?.loginCompleted(viewModel: viewModel)
default:
break
}
}

The Router layer remains the same.

We apply some minor updates to the Module assembly:

extension LoginModule: AppModule {
func assemble() -> UIViewController? {
presenter.view = view

interactor.presenter = presenter
view.interactor = interactor
view.router = router
return view as? UIViewController
}
}

PlayerList scene

Next, we move to PlayerList scene.

The ViewController will be transformed in a similar way - the Presenter will be replaced by Interactor and we now hold a reference to the Router.

An interesting aspect in VIP is the fact we can have an array of view models inside the ViewController:

var interactor: PlayerListInteractorProtocol = PlayerListInteractor()
var router: PlayerListRouterProtocol = PlayerListRouter()

private var displayedPlayers: [PlayerList.FetchPlayers.ViewModel.DisplayedPlayer] = []

We no longer tell the Presenter that the View has been loaded. The ViewController will configure its UI elements in the initial state.

override func viewDidLoad() {
super.viewDidLoad()

setupView()
fetchPlayers()
}

private func setupView() {
configureTitle("Players")
setupBarButtonItem(title: "Select")
setBarButtonState(isEnabled: false)
setupTableView()
}

Similar to Login, the IBActions will construct a request and will call a method within the Interactor.

// MARK: - Selectors
@objc private func selectPlayers() {
let request = PlayerList.SelectPlayers.Request()
interactor.selectPlayers(request: request)
}

@IBAction private func confirmOrAddPlayers(_ sender: Any) {
let request = PlayerList.ConfirmOrAddPlayers.Request()
interactor.confirmOrAddPlayers(request: request)
}

When the data will be fetched and ready to be displayable, the Presenter will call the method from the ViewController displayFetchedPlayers.

The rest of the code is available in the open-source repository.

Key Metrics

Lines of code — Protocols

Lines of code — View Controllers and Views

Lines of code — Modules

Lines of code — Local Models

Lines of code — Routers

Lines of code — Presenters

Lines of code — Interactors

Unit Tests

Build Times

Tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019. Xcode version: 12.5.1. macOS Big Sur.

Conclusion

We implemented VIP architecture in an application originally built with VIPER. The first noticeable improvement is the significant simplification and cleanliness of the Presenters. If the transition were made from an MVC application, the ViewControllers would have been reduced drastically due to better separation of concerns.

VIP introduces a unidirectional flow of control, making the invocation of methods across layers more straightforward and predictable. This results in a cleaner and more maintainable structure.

The average build times for VIP are comparable to those of VIPER and MVP, remaining around 10 seconds. While the addition of more unit tests increases the overall test execution time, we found it to be slightly faster than VIPER during our testing process.

One key observation is the reduction in the size of Presenters. With VIP, Presenters were streamlined to only 514 lines of code, a substantial improvement over VIPER. However, this reduction is offset by an increase in the size of Interactors, which grew by 508 lines. Essentially, what was removed from the Presenters was redistributed to the Interactors, resulting in a similar overall code footprint.

Personally, I still lean towards VIPER. While VIP offers several advantages, there are aspects of the architecture that, in my view, deviate from the principles it claims to follow, including Uncle Bob’s Clean Architecture guidelines.

For instance, the necessity of constructing a Request object, even when there is no data to attach to it, seems redundant. While this step can technically be skipped, the example repository demonstrates numerous instances of empty Request objects, which add unnecessary boilerplate code.

Another challenge is the added complexity of maintaining an array of view models within the ViewController. This approach can lead to synchronization issues with the corresponding Worker models, making the architecture harder to manage.

That said, there’s room to customize and adapt VIP to better suit specific project needs. A personalized variation of VIP could mitigate many of these concerns and improve its practicality in certain contexts.

On a positive note, the concept of VIP cycles is compelling, and the architecture is well-suited for Test-Driven Development (TDD). However, adhering strictly to the prescribed layering rules can make even minor changes more cumbersome than necessary. After all, software development should aim to be adaptable — true to the spirit of “SOFTware.”

Useful Links

--

--

Radu Dan
Radu Dan

Written by Radu Dan

iOS Developer / Doing magic things in Swift

No responses yet