By Alexey T

How do you save your developers’ time, especially when you have to move between several projects? Is it possible to create a template of sorts for the new devs to use?

The Fora Soft iOS department decided to create a unified architecture for apps. In 16 years of work, we have developed more than 60 applications. We regularly had to spend weeks digging into code to understand the structure and operation of another project. Some projects we created as MVP, some as MVVM, some as our own. Switching between projects and reviewing other developers’ code increased our development time by several more hours. When went on to creating an iOS app architecture, we first defined the main goals to achieve:

One of the main goals is to make developers’ lives easier. To do this, the code must be readable and the application must have a simple and clear structure.

. Outsourced development doesn’t provide much time to dive into a project. It is important that when switching to another project, it does not take the developer much time to learn the application code.

The application under development must be ready for large loads and be able to easily add new functionality. For this it is important that the architecture corresponds to modern development principles, such as SOLID, and the latest versions of the SDK

You can’t make a perfect architecture all at once, it comes with time. Every developer contributes to it — we have weekly meetings where we discuss the advantages and disadvantages of the existing architecture and things we would like to improve.

The foundation of our architecture is the MVVM pattern with coordinators

Comparing popular MV(X) patterns, we settled on MVVM. It seemed to be the best because of good speed of development and flexibility.

MVVM stands for Model, View, ViewModel:

  • Model — provides data and methods of working with it. Request to receive, check for correctness, etc.
  • View — the layer responsible for the level of graphical representation.
  • ViewModel — The mediator between the Model and View. It is responsible for changes of Model, reacting on user’s actions performed on View, and updates View, using changes from Model. The main distinctive feature from other intermediaries in MV(X) patterns is the reactive bindings of View and ViewModel, which significantly simplifies and reduces the code of working with data between these entities.

Along with the MVVM, we’ve added coordinators. These are objects that control the navigational flow of our application. They help to:

  • isolate and reuse ViewControllers
  • pass dependencies down the navigation hierarchy
  • define the uses of the application
  • implement Deep Links

We also used the DI (Dependency Enforcement) pattern in the iOS development architecture. This is a setting over objects where object dependencies are specified externally, rather than created by the object itself. We use DITranquillity, a lightweight but powerful framework with which you can configure dependencies in a declarative style.

Let’s break down our advanced iOS app architecture using a note-taking application as an example.

Let’s create the framework for the future application. Let’s implement the necessary protocols for routing.

import UIKit

protocol Presentable {

func toPresent() -> UIViewController?

}

extension UIViewController: Presentable {

func toPresent() -> UIViewController? {

return self

}

}

protocol Router: Presentable {

func present(_ module: Presentable?)

func present(_ module: Presentable?, animated: Bool)

func push(_ module: Presentable?)

func push(_ module: Presentable?, hideBottomBar: Bool)

func push(_ module: Presentable?, animated: Bool)

func push(_ module: Presentable?, animated: Bool, completion: (() -> Void)?)

func push(_ module: Presentable?, animated: Bool, hideBottomBar: Bool, completion: (() -> Void)?)

func popModule()

func popModule(animated: Bool)

func dismissModule()

func dismissModule(animated: Bool, completion: (() -> Void)?)

func setRootModule(_ module: Presentable?)

func setRootModule(_ module: Presentable?, hideBar: Bool)

func popToRootModule(animated: Bool)

}

Configuring AppDelegate and AppCoordintator

In App Delegate, we create a container for the DI. In the registerParts() method we add all our dependencies in the application. Next we initialize the AppCoordinator by passing window and container and calling the start() method, thereby giving it control.

@main

class AppDelegate: UIResponder, UIApplicationDelegate {

private let container = DIContainer()

var window: UIWindow?

private var applicationCoordinator: AppCoordinator?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

// Override point for customization after application launch.

registerParts()

let window = UIWindow()

let applicationCoordinator = AppCoordinator(window: window, container: container)

self.applicationCoordinator = applicationCoordinator

self.window = window

window.makeKeyAndVisible()

applicationCoordinator.start()

return true

}

private func registerParts() {

container.append(part: ModelPart.self)

container.append(part: NotesListPart.self)

container.append(part: CreateNotePart.self)

container.append(part: NoteDetailsPart.self)

}

}

The App Coordinator determines on which script the application should run. For example, if the user isn’t authorized, authorization is shown for him, otherwise the main application script is started. In the case of the notes application, we have 1 scenario — displaying a list of notes.

We do the same as with App Coordinator, only instead of window, we send router.

final class AppCoordinator: BaseCoordinator {

private let window: UIWindow

private let container: DIContainer

init(window: UIWindow, container: DIContainer) {

self.window = window

self.container = container

}

override func start() {

openNotesList()

}

override func start(with option: DeepLinkOption?) {

}

func openNotesList() {

let navigationController = UINavigationController()

navigationController.navigationBar.prefersLargeTitles = true

let router = RouterImp(rootController: navigationController)

let notesListCoordinator = NotesListCoordinator(router: router, container: container)

notesListCoordinator.start()

addDependency(notesListCoordinator)

window.rootViewController = navigationController

}

}

In NoteListCoordinator, we take the dependency of the note list screen, using the method container.resolve(). Be sure to specify the type of our dependency, so the library knows which dependency to fetch. Also set up jump handlers for the following screens. The dependencies setup will be presented later.

class NotesListCoordinator: BaseCoordinator {

private let container: DIContainer

private let router: Router

init(router: Router, container: DIContainer) {

self.router = router

self.container = container

}

override func start() {

setNotesListRoot()

}

func setNotesListRoot() {

let notesListDependency: NotesListDependency = container.resolve()

router.setRootModule(notesListDependency.viewController)

notesListDependency.viewModel.onNoteSelected = { [weak self] note in

self?.pushNoteDetailsScreen(note: note)

}

notesListDependency.viewModel.onCreateNote = { [weak self] in

self?.pushCreateNoteScreen(mode: .create)

}

Creating a module

Each module in an application can be represented like this:

The Model layer in our application is represented by the Provider entity. Its layout is

The Provider is an entity in iOS app architecture, which is responsible for communicating with services and managers in order to receive, send, and process data for the screen, e.g. to contact services to retrieve data from the network or from the database.

Let’s create a protocol for communicating with our provider by mentioning the necessary fields and methods. Let’s create a structure ProviderState, where we declare the data on which our screen will depend. In the protocol, we will mention fields such as Current State with type ProviderState and its observer State with type Observable<ProviderState> and methods to change our Current State.

Then we’ll create an implementation of our protocol, calling as the name of the protocol + “Impl”. CurrentState we mark as @Published, this property wrapper, allows us to create an observable object which automatically reports changes. BehaviorRelay could do the same thing, having both observable and observer properties, but it had a rather complicated data update flow that took 3 lines, while using @Published only took 1. Also set the access level to private(set), because the provider’s state should not change outside of the provider. The State will be an observer of CurrentState and will broadcast changes to its subscribers, namely to our future View Model. Do not forget to implement the methods that we will need when working on this screen.

struct Note {

let id: Identifier<Self>

let dateCreated: Date

var text: String

var dateChanged: Date?

}

protocol NotesListProvider {

var state: Observable<NotesListProviderState> { get }

var currentState: NotesListProviderState { get }

}

class NotesListProviderImpl: NotesListProvider {

let disposeBag = DisposeBag()

lazy var state = $currentState

@Published private(set) var currentState = NotesListProviderState()

init(sharedStore: SharedStore<[Note], Never>) {

sharedStore.state.subscribe(onNext: { [weak self] notes in

self?.currentState.notes = notes

}).disposed(by: disposeBag)

}

}

struct NotesListProviderState {

var notes: [Note] = []

}

Here we’ll create a protocol, just like for the provider. Mention fields such as ViewInputData, and Events. ViewInputData is the data that will be passed directly to our viewController. Let’s create the implementation of our ViewModel, let’s subscribe the viewInputData to the state provider and change it to the necessary format for the view using the mapToViewInputData function. Create an enum Events, where we define all the events that should be processed on the screen, like view loading, button pressing, cell selection, etc. Make Events a PublishSubject type, to be able to subscribe and add new elements, subscribe and handle each event.

protocol NotesListViewModel: AnyObject {

var viewInputData: Observable<NotesListViewInputData> { get }

var events: PublishSubject<NotesListViewEvent> { get }

var onNoteSelected: ((Note) -> ())? { get set }

var onCreateNote: (() -> ())? { get set }

}

class NotesListViewModelImpl: NotesListViewModel {

let disposeBag = DisposeBag()

let viewInputData: Observable<NotesListViewInputData>

let events = PublishSubject<NotesListViewEvent>()

let notesProvider: NotesListProvider

var onNoteSelected: ((Note) -> ())?

var onCreateNote: (() -> ())?

init(notesProvider: NotesListProvider) {

self.notesProvider = notesProvider

self.viewInputData = notesProvider.state.map { $0.mapToNotesListViewInputData() }

events.subscribe(onNext: { [weak self] event in

switch event {

case .viewDidAppear, .viewWillDisappear:

break

case let .selectedNote(id):

self?.noteSelected(id: id)

case .createNote:

self?.onCreateNote?()

}

}).disposed(by: disposeBag)

}

private func noteSelected(id: Identifier<Note>) {

if let note = notesProvider.currentState.notes.first(where: { $0.id == id }) {

onNoteSelected?(note)

}

}

}

private extension NotesListProviderState {

func mapToNotesListViewInputData() -> NotesListViewInputData {

return NotesListViewInputData(notes: self.notes.map { ($0.id, NoteCollectionViewCell.State(text: $0.text)) })

}

}

In this layer, we configure the screen UI and bindings with the view model. The View layer represents the UIViewController. In viewWillAppear(), we subscribe to ViewInputData and give the data to render, which distributes it to the desired UI elements

override func viewWillAppear(_ animated: Bool) {

super.viewDidAppear(animated)

let disposeBag = DisposeBag()

viewModel.viewInputData.subscribe(onNext: { [weak self] viewInputData in

self?.render(data: viewInputData)

}).disposed(by: disposeBag)

self.disposeBag = disposeBag

}

private func render(data: NotesListViewInputData) {

var snapshot = DiffableDataSourceSnapshot<NotesListSection, NotesListSectionItem>()

snapshot.appendSections([.list])

snapshot.appendItems(data.notes.map { NotesListSectionItem.note($0.0, $0.1) })

dataSource.apply(snapshot)

}

We also add event bindings, either with RxSwift or the basic way through selectors.

@objc private func createNoteBtnPressed() {

viewModel.events.onNext(.createNote)

}

Now, that all the components of the module are ready, let’s proceed to link objects between themselves. The module is a class subscribed to the DIPart protocol, which primarily serves to maintain the code hierarchy by combining some parts of the system into a single common class, and in the future includes some, but not all, of the components in the list. Let’s implement the obligatory load(container:) method, where we will register our components.

final class NotesListPart: DIPart {

static func load(container: DIContainer) {

container.register(SharedStore.notesListScoped)

.as(SharedStore<[Note], Never>.self, tag: NotesListScope.self)

.lifetime(.objectGraph)

container.register { NotesListProviderImpl(sharedStore: by(tag: NotesListScope.self, on: $0)) }

.as(NotesListProvider.self)

.lifetime(.objectGraph)

container.register(NotesListViewModelImpl.init(notesProvider:)).as(NotesListViewModel.self).lifetime(.objectGraph)

container.register(NotesListViewController.init(viewModel:)).lifetime(.objectGraph)

container.register(NotesListDependency.init(viewModel:viewController:)).lifetime(.prototype)

}

}

struct NotesListDependency {

let viewModel: NotesListViewModel

let viewController: NotesListViewController

}

We’ll register components with the method container.register(), sendingthere our object, and specifying the protocol by which it will communicate, as well as the lifetime of the object. We do the same with all the other components

Our module is ready, do not forget to add the module to the container in the AppDelegate. Let’s go to the NoteListCoordinator in the list opening function. Let’s take the required dependency through the container.resolve function, be sure to explicitly declare the type of variable. Then we create event handlers onNoteSelected and onCreateNote, and pass the viewController to the router.

func setNotesListRoot() {

let notesListDependency: NotesListDependency = container.resolve()

router.setRootModule(notesListDependency.viewController)

notesListDependency.viewModel.onNoteSelected = { [weak self] note in

self?.pushNoteDetailsScreen(note: note)

}

notesListDependency.viewModel.onCreateNote = { [weak self] in

self?.pushCreateNoteScreen(mode: .create)

}

}

Other modules and navigation are created following these steps. In conclusion, we can say that the architecture isn’t without flaws. We could mention a couple problems, such as changing one field in viewInputData forces to update the whole UI but not certain elements of it; underdeveloped common flow of work with UITabBarController and UIPageViewController.

Conclusion

With the creation of the iOS app architecture, it became much easier for us to work. It’s not so scary anymore to replace a colleague on vacation and take on a new project. Solutions for this or that implementation can be viewed by colleagues without puzzling over how to implement it so that it would work properly with our architecture.

During the year, we have already managed to add the shared storage, error handling for coordinators, improved routing logic, and we aren’t gonna stop there.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Fora Soft

Fora Soft

Video and Multimedia Software Developmet company