iOS App Architecture — Part 1: Building Screens
History and motivation
Over the years, we’ve investigated and experimented with lots of different architecture patterns at Nubank on both the iOS and Android team. We created our projects and, as many beginner developers do, started writing simple MVC code that fit our needs and implemented what was needed at the time.
Apple’s MVC is a simple architecture for beginners but falls short when your project starts to scale. While we do want simplicity, we also want to be able to share the work across our team while developing a new feature, and that is hard to do when all of your screen’s behavior is in one single class, the ViewController.
As we continued to expand and with our projects growing more complex, it became clear to us that we needed a different tool for the job, and as such, we took on the task of creating our own application architecture. When developing a pattern to try and solve our problems, our main driving forces and concerns were:
- We were transitioning from a one-man codebase to a team
- We needed to go modular and favor reusability
- We wanted to have smaller classes with fewer responsibilities
- We needed to make tests easier to write and more useful
We have done a lot of research and experimentation when building our architecture pattern, studying how existing structures solve different problems and how their strengths and weaknesses present themselves in our day-to-day activities. One such architecture that inspired us a lot was The Clean Architecture, a proposal from Uncle Bob that suggests a way to build applications with a few goals in mind:
There is great overlap between what the Clean Architecture proposes and what we want to achieve. However, we felt like following Uncle Bob’s proposal exactly would create too much overhead for our particular work environment.
For example, separating the work into too many classes may create an intimidating look for newcomers because it can be hard to understand what each part does and what are the immediate benefits of such separation. We also don’t want that separation to get in the way of the developer and cause drops in productivity because of the need to create several files and switch between them all the time.
Because of this, we’ve settled in an intermediate application of the Clean Architecture, which gives us the decoupling, testability and single responsibility of the original proposal, but does not add as much overhead and allows us to build our features faster.
To achieve the desired separation and distribution of responsibilities, we have decided to create additional layers incrementally abstracting away the
Let’s explain our pattern using a fictitious screen designed to lead to a sign-up process, using as example our credit card’s Rewards program, which users may opt into at any time.
This screen should either present a message inviting the user to sign up if their card is currently
active, but should instruct them to unblock their card first if it is
Our Controller is an abstraction of a unit of control. It provides an interface to the outside world about a specific user interaction and performs work related to that interaction that may affect the state of the application.
The Controller produces the view state specification (known as a ViewModel) based on the input it received from the context and coordinates any changes to that view state based on actions or input changes.
Let’s assume for example that we are building a simple screen that should react to the state of our customer’s card. We may, for instance, have the following
enum to represent such state:
There are two main concerns for our controller: creating the state for the view based on
CardStatus and reacting to the actions performed by the user.
At Nubank we decided to use reactive programming to accomplish the first goal. By using RxSwift’s Observable sequences we are able to react to changes in our underlying state in a fluid way with very predictable side-effects. To accomplish the latter we can once again use Observable sequences, only this time we are creating them, not consuming.
Notice how our Controller is given access to the account state via dependency injection, where the callee is the one responsible for inserting the state into it. This reduces the coupling of the object to the source of the state and makes the component easier to reuse and test. The Controller also receives its
RewardsManager instance in the same fashion.
In our pattern, the
UIViewController manages a
UIView by binding a view state specification (ViewModel) to its internal components. We see the binding process as the fundamental job of the
The ViewController also exposes the actions that the user can take in the View in an abstract way for the Controller to use in its logic process.
Notice how the ViewController exposes the available actions of the View in a layout-agnostic way (i.e.: that the confirm action was taken, not that the confirm button was tapped). This serves as a decoupling layer between the View and the Controller, which makes it easier to change the two objects independently.
For example, if the layout was completely changed and the button was replaced by some other control with well-defined actions, the Controller could remain the same while only the View and ViewController changed, since the ViewController is “shielding” the Controller from the specific implementation of the
UIButton inside the View.
Likewise, a fundamental change in the screen behavior could be implemented simply by replacing the side-effects applied by the Controller in response to the action, without the View ever knowing about it.
The ViewModel is meant to be a pure data object, an ephemeral and instantaneous representation of the view’s state that can be re-created at will by the Controller and that changes whenever the external state or internal actions cause the state to change. It is an immutable entity, is implemented in Swift by a Struct with
let properties, and is not retained by either the Controller or the ViewController.
In our architecture, the
UIView is simply a layout component. It does not contain any logic and its purpose is to expose user interaction events and present content in its internal views.
At Nubank we write our views in code and do not rely on Interface Builder. We use a third-party library called SnapKit to make it easier to write AutoLayout constraints in our views.
We have also created a subclass of
UIView with a few methods that allow us to compose our layouts in a more readable manner, separating the initial setup in the
initialize method, and creating a method specific for adding constraints (
Testing is very important at Nubank. It is common sense among engineers never to consider a feature done if it has no tests to back it up. The fundamental idea is that our tests ensure our code behaves the way we expect it to and that we will notice if any changes we are making to the code will break existing, required behavior.
With that said, tests are supposed to help you develop and be certain that your code does what you expect it to do, and not to slow you down or create a heavy burden. We wanted to build a design principle that would address this, encouraging developers to write code that is easy to test because it is shorter and decoupled.
Controller objects manage (create) ViewControllers and ViewModels based on external content, and may also perform actions that will cause the application’s state to change.
This type of logic is easy to test, as it allows you to provide predictable external dependencies, fake interactions, and watch for the side-effects produced by the Controller.
To start this test, we should first mock the required pieces that we are injecting into the Controller:
By creating a
StubViewController, we are aiming to accomplish two things:
- Fake user interactions to ensure proper side-effects are being triggered
- Verify that the Controller is properly binding the ViewController by checking the arguments of the invocations to the
To fake user interactions, a common practice is to stub the
ControlEvent property that would originally reference the
.rx.tap property on the View and replace it with a
PublishSubject, which allows you to inject elements into the observable stream.
To verify the invocations of
bind, we are storing the arguments of each invocation of the method so we can later run assertions on them.
With those stubs in place, we can create unit tests that assert the desired behaviors one by one:
View behavior testing
As we described before,
UIViewControllers in our pattern are responsible for binding the internal state of the view based on the ViewModel and passing through any actions that happen in the view with observable sequences (i.e.:
We may test the forwarding of actions to
TestObserver, a utility from the
RxTest module that allows you to inspect the elements emitted by an observable sequence:
Apart from testing the forwarding of actions, one should also test the
bind method for completeness. One approach to do that is to write a unit test that asserts the method sets the appropriate properties on the view one by one. However, we don't usually test this function in isolation as it would create a considerable amount of overhead, and would not guarantee the behavior of the screen as a whole. We believe this type of test does not add great value, and as such, is coalesced with the Layout test.
In our pattern, Views are meant to be simple representations of a state, possibly related to a Model through a ViewModel
struct. Views should not have any logic nor should they be able to bind their own controls to a ViewModel, as this is the job of the ViewController.
Views can be tested by creating all valid permutations of their internal state and asserting that the layout is correct. This enables a form of generative testing for views where one can easily iterate over all possible states and check the corresponding layout.
The layout assertion is usually done through screenshot tests, where the view is assigned an arbitrary size and drawn into a
UIImage, which is then saved to disk or compared to an existing reference when the test runs.
A complete View test usually includes:
- Content size category variations
- Screen size permutations (iPhone 4s, 5, 6, 6 Plus…)
- Model permutations
- Internal state permutations (i.e: collapsed, expanded, selected…)
For our example screen, the permutations test could be implemented by creating a list of all valid ViewModels and iterating through them, taking a screenshot for every permutation and comparing it to a reference image that has been added to source control:
Notice here that
NUScreenshotService is the class responsible for taking the screenshot of the View and comparing it to the reference, if available. The method
assertView(_ view:, identifier: screenshotService:) will take the incoming
view, size it, perform a layout pass and then use the provided
NUScreenshotService instance to either save a new reference or compare to the existing reference image.
We have created helper methods on
XCTestCase to make it easier to repeat this process of iterating through several permutations and taking screenshots on each step of the way. In fact, that's how we implement
UIContentSizeCategory and screen size tests.
In our workload, reference screenshots are created by the test runs and manually validated by the developer, and then added to source control using Git LFS. As the layout evolves, these reference images are re-validated and updated. Using this approach, layout history is available if required.
One optimization we’ve adopted on this is to combine the View and the ViewController tests into a single one. We can do that by creating the ViewController-View pair at test time, and then simply calling bind on the ViewController providing a known ViewModel object. If the binding is correct, the view will appear as intended in all tests, and both behaviors (layout and binding) are tested at once.
In summary, the architecture we built attempts to create different layers to abstract away the layout-specific Views as they approach the business logic and to keep classes small and with few responsibilities.
The View is the element with the least control over what happens, and serves merely as a vessel for the content, displaying it in the proper layout.
The ViewController has a bit more control and manages the View by populating content coming from the intermediate state representation and by exposing the possible actions in a layout-agnostic way. That intermediate representation is our ViewModel, an immutable pure data structure.
Finally, the Controller owns the logic and knows the effects of the actions. It takes in an abstracted version of events and uses that to create the ViewModel. It does not know how the ViewModel is applied nor how the information should be displayed to the user.