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