iOS Architecture

VIPER

We use VIPER iOS application architecture. VIPER is iOS version of The Clean Architecture. It allows us to write structured code which is easy to test.

Typically one application screen is one VIPER module. It consists of View, Presenter, Interactor, Router and Assembly (module builder). Layers do not depend on concrete implementations of other layers, they define input and output protocols, which they use to communicate. Since VIPER modules usually are small, we skip protocols boilerplate but still separate methods as if protocols were used.

Image from The Book of VIPER

View

View layer is implemented using UIViewControllers and UIViews. It is the only layer which can interact with UIKit components. View asks for data from the presenter and displays it. It also routes UI events to the presenter.

class ClientListViewController: UIViewController {

    var output: ClientListPresenter!

    override func viewDidLoad() {
        super.viewDidLoad()
        output.viewIsReady()
    }

    func didTapClient(client: Client) {
        output.didTapClient(client: client)
    }

}

extension ClientListViewController { // MARK: ViewInput

    func updateClientList(clients: [Client]) { }
    func showSpinner() { }
    func hideSpinner() { }

}

Presenter

Presenter is the main component of VIPER module. It has access to all components of the VIPER stack: view, router, and interactor. It handles UI events passed from view layer and calls interactor if needed. Presenter uses a router to navigate to other modules.

class ClientListPresenter {

    weak var view: ClientListViewController!
    var interactor: ClientListInteractor!
    var router: ClientListRouter!

    let agentId: Int

    init(agentId: Int) {
        self.agentId = agentId
    }

}

extension ClientListPresenter { // MARK: ViewOutput

    func viewIsReady() {
        view.showSpinner()
        interactor.fetchClients(officeId: officeId)
    }

    func didTapClient(client: Client) {
        router.openClientModule(clientId: client.id)
    }

}

extension ClientListPresenter { // MARK: InteractorOutput {

    func didFetchClients(clients: [Client]) {
        view.hideSpinner()
        view.updateClientList(clients: clients)
    }

}

Interactor

Interactor is responsible for handling business logic. It uses various services for fetching data from the API, accessing local storage, etc.

class ClientListInteractor {

    weak var output: ClientListPresenter!

    var clientApiService = ClientApiService()

    func fetchClients(agentId: Int) {
        clientApiService.fetchClients(agentId: agentId).then { clients in
            self.output.didFetchClients(clients: clients)
        }
    }

}

Router

Router is responsible for routing to other modules. It receives input from presenter to show a different module. Then router uses a module builder to instantiate a new module. After the new module is instantiated, the router uses transition handler (UIViewController) to transition to the new module.

class ClientListRouter {

    weak var transitionHandler: UIViewController!

    func openClientModule(clientId: Int) {
        let clientModule = ClientModuleBuilder(clientId: clientId).build()
        let navigationController = UINavigationController(rootViewController: clientModule)
        navigationController.modalPresentationStyle = .formSheet
        transitionHandler.present(navigationController, animated: true, completion: nil)
    }

}

Entity

Entities are simple Swift structs which represent business objects.

Assembly

Assembly layer consists of a Builder class which prepares a module by initializing and connecting all VIPER components. It is used by a router to create a new module when routing from another module.

struct ClientListModuleBuilder {

    let agentId: Int

    func build() -> ClientListViewController {
        let presenter = ClientListPresenter(agentId: agentId)
        let view = UIStoryboard(name: "ClientList", bundle: nil).instantiateInitialViewController() as! ClientListViewController
        view.output = presenter
        presenter.view = view

        let router = ClientListRouter()
        router.transitionHandler = view
        presenter.router = router

        let interactor = ClientListInteractor()
        interactor.output = presenter
        presenter.interactor = interactor

        return view
    }

}

Best practices

Folder structure

Modules/
  ClientList/
    ClientListModuleBuilder.swift
    ClientListModuleDelegate.swift
    Interactor/
      ClientListInteractor.swift
    Presenter/
      ClientListPresenter.swift
    Router/
      ClientListRouter.swift
    View/
      ClientListViewController.swift
      ClientList.storyboard
      ClientCell/
        ClientListClientCell.swift

Naming

View

Class: {Module}ViewController

class {Module}ViewController {

    var output: {Module}Presenter!

}

extension {Module}ViewControler { // MARK: ViewInput{

}
Interactor

Class: {Module}Interactor

class {Module}Interactor {

    weak var output: {Module}Presenter!

}
Presenter

Class: {Module}Presenter

class {Module}Presenter: {

    weak var view: {Module}ViewControler!
    var interactor: {Module}Interactor!
    var router: {Module}Router!

}

extension {Module}Presenter { // MARK: ViewOutput

}

extension {Module}Presenter { // MARK: InteractorOutput

}
Router

Class: {Module}Router

class {Module}Router {

    weak var transitionHandler: UIViewController!

}

Module generation

Creating all the classes required for one VIPER module by hand is tedious and time-consuming, therefore we use Generamba to generate required files with boilerplate code. We have customized the default VIPER template for our needs.

To create a new module run generamba gen [MODULE_NAME] viper.

Callbacks

When you want to be informed about something happening in a module presented by another module, you can do that using module delegates.

protocol AddClientModuleDelegate: class {

    func addClientModuleDidCreateClient(clientId: Int)

}


class AddClientPresenter {

    weak var delegate: AddClientModuleDelegate?

    func didCreateClient(clientId: Int) {
        delegate?.addClientModuleDidCreateClient(clientId: clientId)
    }

}


class ClientListRouter {

    func openAddClientModule(delegate: AddClientModuleDelegate) {
        let addClientModule = AddClientModuleBuilder().build(delegate: delegate)
        transitionHandler.present(addClientModule, animated: true, completion: nil)
    }

}

Testing

VIPER modules are easily testable. If you use the generator to create a module, test files for each component will be created automatically. Because VIPER modules are independent of each other and have a strict interface, we can easily mock other layers in tests.

Services

API services

Create a dedicated API service class for each resource type (ClientApiService, UserApiService, etc.). Because of API services asynchronous nature, we frequently use PromiseKit. ApiService classes work with entities (plain Swift structs).

import PromiseKit

class ClientApiService {

    var apiClient = ApiClient.sharedInstance

    enum ClientApiServiceError: Error {
        case NotFound
    }

    func fetchClients(agentId: Int) -> Promise<[Client]> {
        let path = "agent/\(agentId)/clients"

        return apiClient.get(path: path).then { json in
            return ClientsJsonConverter().convert(json: json)
        }.recover { error -> [Client] in
            throw ClientApiServiceError.NotFound
        }
    }

}

Local storage

Local storage classes are used to persist data on the device. It is the only layer which interacts with CoreData. We use MagicalRecord when dealing with CoreData.

class ClientStorage {

    func findClient(clientId: Int) -> Client? {
        let clientMO = ClientMO.MR_findFirstByAttribute("clientId", withValue: clientId)
        return clientMO?.entity
    }

}

Data managers

We use data managers to encapsulate complex actions with data when they are used in several places. For example when you need to save entity locally and then synchronize it with API.

class ClientDataManager {

    var storage: ClientStorage
    var apiService: ClientApiService

    func createClient(client: Client) {
        storage.createClient(client)

        apiService.createClient(client).then {
            client.synced = true
            storage.saveClient(client)
        }
    }

}