Building a dynamic modular iOS architecture
Preparing for a non-linear workflow
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:
- Increase fidelity, from interactive skeleton to fully-featured app
- Freely add, delete and modify new flows, features, and components
- Compose flows from modal, stacked and tabbed navigational building blocks
- Transpose user flow diagrams into navigation experiences
- Enable nested flows
- 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.
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.
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.
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
A dynamic framework that links all upstream frameworks.
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:
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.
In our example app’s repository, the root directory,
ModularFlowArchitecturedirectory, containing the iOS app’s Xcode project;
ModularFlowArchitecture.xcworkspace, into which the app and framework dependencies are linked;
Modulesdirectory, containing the dynamic and static framework projects and workspaces.
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.
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
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.
Now add the
-all_load flag to the
Other Linker Flags setting in app’s single dynamic framework’s —
AppCore in my example—Build settings tab. This ensures all static library symbols are included in the dynamic framework.
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:
UIViewControllersubclasses 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
FlowActionevent handling propagates down the flow controller stack until it is handled, enabling transitions across deeply nested navigation structures
- A marking protocol usually conformed to by an
enumfor easy pattern matching
- Declares actions triggered within a flow i.e.
case presentModal(from: content:)
Flow action delegate
- A delegate protocol whose
FlowActionsand determines how to mutate a navigation controllers
- Interacts with user or system events, such as gestures or the app state changes
A dynamic workflow emerges
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.
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.
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 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
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
Its extension conforms to
FlowActionDelegate to handle
FlowActions. so it is its own
flowActionDelegate — a requirement for handling
We then append a child flow controller —
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
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
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
Onboarding flow controller
Flows layer — OnboardingFlow framework
Co-ordinates flows between its child onboarding flow.
On initialisation assigns its
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
FlowActionDelegate extension, it processes
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
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.
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.