A Simple Architecture for Mobile Apps
Whether on-boarding a user, signing in to a service, or simply using a tab-based app with navigation stacks of different screens, almost any app process can be described as a flow of screens and information. It all begins with a start.
Before joining BigCommerce as a Senior Mobile Software Engineer, I was fortunate to have worked at Mutual Mobile for over five years holding positions from iOS engineer to architect. For those unfamiliar with MM, they are a global digital innovation and emerging technology agency, headquartered in Austin, Texas (same as BC).
One of the great benefits of being a mobile developer at an agency is the diverse range of projects. One day you could be working on a banking app. Six months later it might be a social app built around a sports community, a fitness app, a mesh networking system, a smart home manager, or a dozen more. As diverse as the clients were, they always had things in common, and at Mutual Mobile we developed and open sourced tools and architectures that many other developers use to this day.
One such architecture called VIPER, developed and championed at Mutual Mobile by Jeff Gilbert and Conrad Stoll, gained a lot of traction in the iOS community and beyond. When starting a new mobile project recently at BigCommerce, I considered returning to that familiar architecture.
VIPER is an application of Clean Architecture for mobile apps, with a focus on testable code that separates logic from view controllers. In the mobile world most apps use MVC architecture, which often means view controllers that contain application logic, aka Massive View Controllers. By separating views from business logic, VIPER makes classes that are easier to understand, test, and maintain.
But ultimately I decided that VIPER was more than I needed. While it is a great, testable, modern architecture, it tends to require a lot of files to do it right. Several open source projects are available to generate the files needed to do VIPER (although if you do it TDD-style like Gilbert, you only create those files as you need them).
I decided to give my own personal architecture idea a shot instead. It is very minimal, not boilerplate-y, and highly portable. I’ve written apps in Objective-C, Swift and JavaScript using it, and there’s no reason why it couldn’t work in a dozen others. It is definitely influenced by VIPER and the thinking behind it.
Its central thesis is this: a mobile app is a construction of screens managed by data-passing flows and sub-flows, that start, end, move forward and back, and sometimes execute actions. Because of this, I call it the Flow Architecture.
Introducing Flow Architecture
Whether on-boarding a user (which was the genesis for this architecture), signing in to a service, or simply using a tab-based app with navigation stacks of different screens, almost any app process can be described as a flow of screens and information. It all begins with a start.
/** Start a flow, providing optional initial data */
func start(with data: Any?)
And of course any flow that can be started can also be ended.
/** Complete a flow, providing optional data */
func end(with data: Any?)
Some flows, like on-boarding or logins, may need to step forward and back.
/** Step backward in a flow, providing optional data */
func back(with data: Any?)
/** Step forward in a flow, providing optional data */
func next(with data: Any?)
Occasionally a flow may need to execute some action that does not necessarily cause a step forward or back. An action needs an id to identify it, and may provide some data to give it context.
I also may log an action instead, such as for analytics. No actual change to the flow takes place.
protocol FlowAction {
var id: String { get set }
var data: Any? { get set }
}
...
/** Perform an action on a flow, providing optional data */
func action(_ action: FlowAction)
/** Log an action on a flow, providing optional data. No actual action is taken. */
func log(_ action: FlowAction)
Because any flow could both be a sub-flow or parent to a sub-flow, it needs to signal to the parent. These delegate-like functions are automatically invoked on a parent when a flow starts or ends (remember to call your flow subclasses' super functions).
/** Notifies a parent flow, if any, that a sub-flow is starting */
func flowStarted(_ flow: Flow)
/** Notifies a parent flow, if any, that a flow has completed, providing optional final data */
func flowEnded(_ flow: Flow, with data: Any?)
You may also want to check the current state of the flow.
public enum FlowState {
case notStarted
case started
case ended
}
/** Current state of the flow */
var state: FlowState { get }
In practice, I like to define this flow capability as a private protocol and then provide a concrete Flow base class for the implementation. I then subclass that to create any flow objects I need to construct the app, as well as create structs that implement the FlowAction protocol for any custom actions I want to handle.
I also add a bit of logging to each function so I can follow the flow in the debug console. A custom logging function or framework makes it easy to disable this in production builds, to have useful flow logging without leaking implementation details in public.
Flow Architecture in practice
One of the great benefits of thinking in flows is that there are opportunities to make decisions while information is being collected.
The genesis of this architecture was on-boarding a user. Instead of a static, linear progression of screens, we needed to customize steps based on initial responses. VIPER is great at handling this scenario, and is highly tailored to views and interactions. As the presenter collects inputs from the user, it uses the wireframe to navigate to the next screen, whatever that may be. It could be the next in a static set of screens, or it could change based on a decision tree.
But what if there is no user input, or even a screen? Another benefit of Flow Architecture is it has very few requirements, implying only a start, end, and some number of steps or actions between.
Like VIPER, flows are easy to test. However, unlike VIPER where you always know the data types being passed around, there is some uncertainty with the Any? nature of the data. That tradeoff is made for simplicity and flexibility.
This particular iteration is written in Swift, so it comes with the benefits of that strongly-typed language. When a Swift flow receives data it can check its type, whether a primitive or a struct of other objects. In Javascript, the data could be any object.
In my apps, I use flows to handle all the decision-making, sub-flow creation, screen creation, network requests, OS API calls, validations, and so on. They're initialized with only whatever they need to do their work. My view controllers do little more than setup and update their views. The flows do all the work, and when they start to look like they’re doing too much, that probably means you can extract a sub-flow instead.
In part 2, we'll look at a typical scenario in modern mobile apps: on-boarding a new user.