CodeNewbie Community 🌱

Cover image for Advanced iOS App Architecture Explained on MVVM with Code Examples
Fora Soft
Fora Soft

Posted on • Originally published at forasoft.com

Advanced iOS App Architecture Explained on MVVM with Code Examples

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:

Simplicity and speed. 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.

Quick immersion in the project. 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.

Scalability and extensibility. 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.

Constant development. 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)
}
Enter fullscreen mode Exit fullscreen mode

Configuring AppDelegate and AppCoordintator

A diagram of the interaction between the delegate and the coordinators is below.

diagram of the interaction between the delegate and the coordinators

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
Enter fullscreen mode Exit fullscreen mode

Creating a module

Each module in an application can be represented like this:

Each module in iOS app architecture

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

Provider scheme in apple app architecture

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 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] = []
}
Enter fullscreen mode Exit fullscreen mode

View-Model scheme in iOS development architecture

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)) })
    }
}
Enter fullscreen mode Exit fullscreen mode

View scheme in iOS mobile architecture

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)
    }
Enter fullscreen mode Exit fullscreen mode

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

    @objc private func createNoteBtnPressed() {
        viewModel.events.onNext(.createNote)
    }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)