Building a dynamic modular iOS architecture

Preparing for a non-linear workflow


Introduction

Previously, I extolled the virtues of a modular architecture, and how a flow controller aids in sequencing navigation between isolated feature modules.

The implementation provided, was illustrative — a taste of how to handle the problem of monolithic architectures — fine for small applications with a single flow and a limited number of modules, but not a solution for building more complex real-world iOS apps in teams.

In this article, I present a more scalable solution. Something that will handle the most intricate and variable of navigational structures, and suit a dynamic workflow that doesn’t fight the non-linear nature of app development.


How we work

App production is imperfect from inception — assumptions are often incorrect, technology choices are regularly inappropriate and over-arching business requirements are ever-changing. The ability to rapidly move from idea, to prototype, to iteration, to continually-delivered product is critical. We strive to move our vision from low to high fidelity at break-neck speed, within an environment that is chaotic.

We work in teams of varying sizes and domains of expertise. Ideally, we want to be working together. Each, dividing our efforts and contributing to the speedy production of the whole. In reality, a project’s dependencies are often not ready when needed, or might change when new information — user test analyses, style guides, executive approval, legal requirements, business goals, technologies, etc. — becomes available.

One thing that can be done to help ease the pain of moving forward in an unstable environment — begin with a foundation that is designed for change. When new designs, specifications, flows, features and technologies are ideated, they can be promptly prototyped, built, integrated, tested and released.

Designing for change

If the EU legislature decide that a verified facial scan is now a legal requirement to use a mobile application, it is necessary to incorporate it, quickly.

The workflow has the design and UX team create an appropriate user interface, and an updated app flow. The engineering team works on building or integrating 3rd party frameworks and — as dependencies become available — promptly link their work to the new UI components, feature framework and login flow.

The prototype is isolated — behind a feature controller — and does not impact the remainder of the codebase. When the functioning prototype is complete, it is distributed in a canary release for testing and analysis. Once verified, it can be removed from behind the feature controller, and integrated into the main release.

To achieve such a workflow, the architecture should be:

Scalable

  • Increase fidelity, from interactive skeleton to fully-featured app
  • Freely add, delete and modify new flows, features, and components

Composable

  • Compose flows from modal, stacked and tabbed navigational building blocks
  • Transpose user flow diagrams into navigation experiences
  • Enable nested flows

Loosely coupled

  • Components, features and flows can be prototyped, built, modified and tested in isolation with few dependencies

Interactive when needed

  • Transitions between features can optionally use built-in UIKit, custom or interactive animations

Compliant with Apple’s framework limitations

  • A maximum of 6 dynamic frameworks
  • Discouraged use of umbrella frameworks
  • A target pre-main cold start time of 400 ms

Not re-inventing the wheel

  • Re-use as many UIKit components as possible
  • Plug into the UI lifecycle out of the box

Layering your frameworks

In iOS, the unit of modularity is the framework. They are dynamic or static.

Dynamic frameworks bundle dynamic libraries dylibs and their resources. These dynamic libraries are loaded — as needed — at run time. Because of the nature of how this loading works, Apple recommends a maximum of 6 non-system dylibs shipping in your application.

Static frameworks bundle static libraries and their resources. These static libraries are attached at run time. This has an impact on start times and memory footprint, but generally not as great as having more than 6 dylibs.

My proposed architecture is composed of:

  • an app, that depends on;
  • a single dynamic framework, that depends on many;
  • static frameworks, that are logically grouped into layers of responsibility.

These layers are not enforced, but aid in organising dependency relationships between frameworks.

Upstream frameworks have no knowledge of downstream frameworks dependent upon them — with the exception of common layer frameworks, which may have dependencies within the layer.

Logical architecture structure

Common layer

Static frameworks responsible for common services. Including databases, networking, UI components, and data.

Layers can be further subdivided further into shared and common layers to further decompose the dependencies of shared and common frameworks.

Features layer

Static frameworks responsible for design pattern agnostic features. MVC, MVVM, VIPER, etc. can be used as desired.

Frameworks can link to upstream frameworks in the common layer.

Flows layer

Static frameworks responsible for feature flows. Defines navigation method between features.

Frameworks can link to upstream frameworks in the common & features layers.

App flow layer

Static framework responsible for application flows. Defines navigation method between flows — the flow of flows.

Framework can link to upstream frameworks in the common, features & flows layers

App core

A dynamic framework that links all upstream frameworks.

App

The app embeds and links to the app core dynamic framework.

A modular structure in practice

The example app illustrates the proposed architecture with the following modular structure:

Example app module structure

Composing your modules

I will assume that you know your way around Xcode, are familiar with building workspaces, and adding single-view app projects and CocoaTouch frameworks to them. There are plenty of excellent tutorials and articles on the matter, please refer to them if in doubt. And remember, this example only illustrates an implementation. There are many variations on themes presented here, mixing different dependency management methods and directory, workspace and project structures.

Root directory of example app

In our example app’s repository, the root directory, ModularFlowArchitecture, contains:

  • ModularFlowArchitecture directory, containing the iOS app’s Xcode project;
  • ModularFlowArchitecture.xcworkspace, into which the app and framework dependencies are linked;
  • Modules directory, containing the dynamic and static framework projects and workspaces.

Open the ModularFlowArchitecture.xcworkspace, or any framework’s workspace in the Modules directory, and you observe a common pattern in their project navigators— the project located at the top, and a Dependencies group beneath, containing linked project dependencies.

Workspace project navigators

The products of those projects — namely the static and dynamic frameworks found in each project’s Products group — is linked to the dependent project by dragging the framework onto its Linked Frameworks and Libraries section, under the project target’s General tab.

Xcode will then automatically add the dependency to the dependent project’s Frameworks group.

Project navigator showing AppCore’s Linked Frameworks and Frameworks group

This gives the compiler a common location — the built products directory of the built project target — for finding the dependency when building. The side-effect of this setup: each workspace is functional and testable in isolation.

The large number of frameworks means managing dependencies is a must. Updating a layer’s dependencies and its dependent downstream products, would be overwhelming to do manually. I generally use Git submodules or subtrees because of the minimal impact they have on projects and workspaces. However, this subject is way beyond the scope of this article and example.

Setup your static frameworks

This architecture is reliant on using static frameworks for most functional upstream dependencies. Making a framework static, is achieved by selecting the framework target’s Build Settings tab and choosing the Static Library option on the Linking section’s Mach-O Type setting selector.

Static Library setting

Now add the -all_load flag to the Other Linker Flags setting in app’s single dynamic framework’s — AppCore in my exampleBuild settings tab. This ensures all static library symbols are included in the dynamic framework.

Dynamic framework’s Other Linker Flag

The corollary of using all these static frameworks linked into a single dynamic framework, is faster app start times. To measure app start times, add the DYLD_PRINT_STATISTICS environmental variable to the app’s scheme. A brief guide on how to do this can be found here.

Of note, merging static libraries into a single library, does not reduce the cold start time. So don’t look there for load time optimisations.


Flow control with FLXFlow

Now that the architecture is modular, it’s time to actually put it to use and get something a screen.

Controlling which screen appears next and how it appears is the responsibility of a flow controller. When multiple flows need co-ordination, a flow controller stack is required — with a parent flow controller sequencing the transitions between child flows.

There are existing frameworks, such as RxFlow, that largely do this. I personally prefer solutions with fewer 3rd-party dependencies, and that are less tightly coupled, better lending themselves to modularity.

For this reason, I’ve included a little flow controller framework called FLXFlow, in the example app. Its components being:

Flow controllers

  • FlowController, StackedFlowController, TabbedFlowController classes
  • Are UIViewController subclasses that own a navigation controller
  • Depending on the type, the navigation controller enables modal, stacked or tabbed feature navigation
  • Are navigational building blocks that can be composed into stacks to allow complex navigational structures
  • FlowAction event handling propagates down the flow controller stack until it is handled, enabling transitions across deeply nested navigation structures

Flow action

  • FlowAction protocol
  • A marking protocol usually conformed to by an enum for easy pattern matching
  • Declares actions triggered within a flow i.e. case presentModal(from: content:)

Flow action delegate

  • FlowActionDelegate protocol
  • A delegate protocol whose process(action:) method handles FlowActions and determines how to mutate a navigation controllers

Flow interactors

  • FlowInteractor, TapFlowInteractor, InteractiveFlowInteractor classes
  • Interacts with user or system events, such as gestures or the app state changes
  • Trigger FlowActions

A dynamic workflow emerges

Using FLXFlow, in conjunction with the layered modular foundation, a flow’s features, sequencing, transitions and dependencies are defined in an isolated framework. It knows nothing of the app it is part of, and whether it leads to another flow. It knows only the frameworks it has inherited.

This results in the emergence of a dynamic workflow — entire flows with features can be prototyped, built and tested, independently from your main app and from other flows.

Swapping out components, features, sequences, transitions, and interactors becomes relatively painless. Even constructing a test app to validate assumptions is done by simply linking dependencies into a new target and running it.

Everything is recomposable, reusable and testable all of the time.

The example app

The example app is relatively simple, aiming to demonstrate the key concepts presented. This user flow diagram gives an overview.

Example app user flow

On launch it checks if the user has onboarded, directing them to the onboarding flow or home flow.

The onboarding flow sits atop a stacked navigational structure. It loads with the onboarding first feature. They can then tap to transition to the onboarding second feature. This feature allows the user to either left edge swipe and interactively transition back to onboarding first feature, or tap to transition to the home flow, ending the onboarding flow and marking onboarding as completed.

The home flow sits atop a tabbed navigational structure. It loads with the home first feature. The user can tap on the tab bar items to transition between selected features — home first feature & home second feature. Tapping elsewhere triggers presenting the module flow.

The modal flow only contains the modal feature. Tapping anywhere will start a dismissal transition, removing it from the stack.

The flow controller and feature stack

Transposing the user flow into our modular components results in the following UIWindow, flow controller and feature stack.

Example app’s expanded flow controller and feature stack

Implementation

Let’s go through some of the important parts of the codebase. The linked repository contains further examples, including stacked, tabbed and modal flow controllers, and built-in, custom and interactive animated transitions, so please take a look.

App delegate

App layer — ModularFlowArchitecture app

Initialising the app requires a single import of the AppFlow framework and the initialisation of an AppFlowController. Because it is also an UIViewController it is UIWindow’s rootViewController.

The app project contains little more than the app delegate and linking and embedding the AppCore dynamic framework.

App flow controller

App flow layer — AppFlow framework

Let’s define the main AppFlowController. It co-ordinates flows between its child flows — onboarding, home and modal flows.

It is a StackedFlowController subclass and therefore controls a UINavigationController as its navigationController.

Its extension conforms to FlowActionDelegate to handle FlowActions. so it is its ownflowActionDelegate — a requirement for handling FlowActions.

We then append a child flow controller — HomeFlowController or OnboardingFlowController — depending on whether the user has onboarded or not. This makes the flow controller part of the FlowAction processing chain.

Finally, it adds the child flow controller to the rootNavigationController by calling show().

App flow controller — flow delegate extension

App flow layer — AppFlow framework

The extension overrides the required process(action:) -> Bool method. The action enum is matched to a case, which calls a handler method.

These actions are defined in their respective Flow frameworks— OnboardingFlow, etc. It has access to all child FlowActions, this is why it can co-ordinate between flows.

The handler methods perform mutations to the flow controller’s navigationController stack or the presentedViewController of the originating feature’s UIViewController.

It also assigns a transitioningDelegate to the destination feature’s UIViewController, to control animated transitions.

Onboarding flow action

Flows layer — OnboardingFlow framework

Declares what actions a flow has. It often sends the originating (triggering) feature as a parameter. This feature owns a flow interactor, and is used to control transition animations through the interactor’s conformance to UINavigationControllerDelegate, etc.

Onboarding flow controller

Flows layer — OnboardingFlow framework

Co-ordinates flows between its child onboarding flow.

On initialisation assigns its navigationController and flowActionDelegate.

It initialises and shows the first feature. Assigning that feature’s tapFlowInteractor which injects the method that triggers a FlowAction event on tap.

The file also includes the FlowActionDelegate extension. Like AppFlowController’s FlowActionDelegate extension, it processes FlowActions. Unlike AppFlowController, it can only process OnboardingFlowAction, as it has no knowledge of actions in other flows.

Onboarding interactive flow interactor

Flows layer — OnboardingFlow framework

This interactor encapsulates a UIScreenEdgePanGestureRecognizer. It drives interactive transitions by conforming to UIPercentDrivenInteractiveTransition. When a transition completes, it triggers a FlowAction, which is processed by the flow controller stack.

Onboarding first feature

Features layer — OnboardingFirstFeature framework

Features are simply UIViewControllers that can own flow interactors that conform to the FlowInteractorProtocol.

Conclusion

So, there you have it — a clearly-organised modular architecture, designed to be scalable, composable, loosely coupled and interactive, with the added benefit of giving you faster build and launch times.

Changes in your designs and user flows can be painlessly incorporated into components, features and flows. Deploying new modules alongside old ones for A/B testing is also easy.

Take some time to play with the example app and see if it might benefit your workflow.


Download

The example app that this article refers to, can be found at https://github.com/markjarecki/ModularFlowArchitecture

Sample code in this article has been stripped from a lot of comments, line spaces and public access control declarations — for readability. More accurate implementation can be found in the linked source code.

This code is provided as-is.