Advanced iOS App Architecture Explained on MVVM with Code Examples
Fora Soft
Posted on May 16, 2022
How to share the exact same vision with changing developes’ teams? Is there a way to make new devs onboarding faster and easier to cut costs? How will the final product be affected? In this article we want to share our experience and give a clear explanation of what iOS app architecture is for all business and tech people.
We are a custom software development company. In 17 years of work, we have developed more than 60 applications on SWIFT. 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. So we decided to create a unified architecture for mobile apps.
What benefits the architecture gave us:
- Speed up the development process. Having spent some time on creating the architecture we can now easily make changes to the code. For instance, if we need to change a new sign-up flow, just making it work would take us 8-16 hours. Now it only takes 1-2 hours.
- Eliminate bugs. Not completely, but there’s now less. We’ve already developed a lot of different kinds of flows and cases. Add the settled approach to it, and we don’t have to search for solutions anymore, we just write the code. We already know what bugs can occur so we avoid them straight away.
- Refer projects more easily. If a project developer is away (e.g. on sick days or a vacation) we find someone who could replace them until they’re back. The substitutional developer would waste time (= client’s money) on examining the code before entering a project. Now we minimized this kind of expense since we’ve unified all the solutions and the programmer can easily continue the development.
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.
How to implement it?
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
A 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)
}
}
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:
Module scheme in iOS application 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] = []
}
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)) })
}
}
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)
}
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.
November’22 Update
It’s been 6 months since we released this article and mentioned the issues and weak spots stated above. We’ve done some work and here’re the improvements we’ve made:
- Now you don’t have to update the entire provider state when altering one field.
- We implemented UIPageViewController and UITabBarController to our architecture.
State
We’ve mentioned that we had made the provider State with the custom propertyWrapper — RxPublished. It’s an alternative to Published in Combine, but in RxSwift. It “wraps up” BehaviorRelay so when we modified State we sent out an instance to the subject. And only after that the subject delivered it to its subscribers. But there was a case when we needed to update several state fields, but deliver the updated state only when the operation was completed.
We found a prompt solution using the inout parameter and closure. The function with the parameter sent via inout returns the updated parameter to the variable defined in the function, once it’s completed. The solution is literally in three lines (and saves A LOT of time):
- Copy the current state;
- Carry out the closure;
- Assign the updated state to the subject.
func commit(changes: (inout State) -> ()) {
var updatedState = stateRelay.value
changes(&updatedState)
value = updatedState
}
UIPageViewController
Implementing it in the MVVM-architecture made the process of development quite easy. Check out this step-by-step tutorial:
- Make a module for PageViewController
- In the provider, prepare the data you’ll need to configure modules inside the UIPageViewController.
- Do ViewModel as you usually do: modify the provider state into the view state.
- Add the screens DI modules in viewController using initialization.
Please note that if you want to reuse modules you should make sure that next time you address this module the new sample gets back. To do that use the Provider property (do not confuse it with the module provider). It’s responsible for getting back a new sample when addressing the variable. Tip: use the SwiftLazy library by DITranquility that is a great alternative to the native lazy and has even better functionality with the required Provider.
- Configure each screen in the render function with the required data. Here’s an example:
ViewController
….
let someDependency: SomeModuleDependency
let anptherDependency: AnotherModuleDependency
….
init(....) { }
func render(with data: InputData) {
someDependency.viewModel.setup(data.dataForSomeModule)
anotherDependency.viewModel.setup(data.dataForAnotherModule)
…
pageVC.setViewControllers([someDependency.viewController, ….])
}
UITabBarController
TabBarController now has its own coordinator so we could configure an own flow for each tab. By flow we mean a coordinator and router pair. And a thing to remember — to add two child coordinators to the storage using addDependency and call the start() method. How to do this programmatically:
TabBarCoordinator
private typealias Flow = (Coordinator, Presentable)
…
override func start() {
let flows = [someFlow(), anotherFlow()]
let coordinators = flows.map { $0.0 }
let controllers = flows.compactMap { $0.1 as? UINavigationController }
router.setViewControllers(controllers: controllers)
coordinators.forEach {
addDependency($0)
$0.start()
}
}
func someFlow() -> Flow {
let coordinator = someCoordinator()
let router = Routerlmpl(rootController: UINavigationController())
return (coordinator, router)
}
As you can see all the updates are easy and quick to implement to your mobile app architecture. We plan on adding custom popup support and more cool stuff.
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.
If you’re interested to know more about our iOS software development expertise, read WebRTC in iOS Explained. Creating an online conference app or introducing calls to your platform has never been this easy.
Thanks for reading!
Posted on May 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.