The cornerstone of user interface development for iOS since its inception has been UIKit (or AppKit for macOS). It provides a mechanism to define window and view hierarchy, navigate between scenes, render content on the screen, and has tools for handling user interaction. UIKit was designed over a decade ago in the Objective-C world and leverages principles that were common back then, like delegates and inheritance. Functional elements of modern languages like Swift naturally lead to a declarative programming paradigm which is where SwiftUI steps in.
Code written in SwiftUI is multiplatform and can be deployed to tvOS, macOS, and watchOS platforms. Interoperability with UIKit and AppKit helps with the transition even further.
Interestingly, a very similar approach also came to the Android world, known as Jetpack Compose. If you are more interested in this, it is well described in posts "Jetpack Compose: What you need to know", part 1 and part 2 , although since then some things inevitably changed.
Both of these technologies are ready for production use. Let's focus on what the arrival of SwiftUI means from the architectural point of view, especially regarding state.
In SwiftUI, a view is a function of a state, not of a sequence of events changing some internal, encapsulated state. What does that mean in practice?
SwiftUI observes UI state and triggers re-rendering of the view on every change (with some smart optimizations happening under the hood). This is done by property wrappers that annotate UI state objects and/or Combine publishers. It's only up to you to model, hold, transform, compose and distribute the state.
The state can be perceived from many different angles, for example:
- what is rendered on the screen,
- data that are persisted in the storage,
- state that's spread across multiple screens or features.
It is crucial to properly separate these kinds of states from each other, as some have a limited role in a merely encapsulated context, while others may span across multiple contexts and may serve the purpose of the source of truth.
Such a source of truth must be unambiguously defined and shared. Let's take a look at how the currently most discussed architectures approach this.
MVC was most commonly used in iOS applications back in the day. It got sidetracked with the advent of functional reactive programming and was replaced with MVVM and its derivative MVVM-C which specifies an additional layer for navigation. The place for View and Presentation logic is clear in those cases, but the Model layer has too many responsibilities, which might lead to big components that have too many concerns. In another case, the Model layer may contain only domain objects whereas the Presentation layer deals with networking, storage, business logic, and transformation of domain objects into renderable objects for the view on one side together with handling actions from the view on the other side. Huge ViewControllers with many responsibilities led to the joke saying MVC is short for Massive-View-Controller.
MVx architectures are not sufficient by their definition to design the whole system. They are merely a pattern for the presentation part of the system. Clean and robust applications may be written with MVVM as the only architecture tool in your toolbox but it requires strict discipline and proper decomposition. The architecture used often branches out into something more sophisticated than 3-layer architecture but with no clear boundaries that are defined somewhere between the lines.
Redux is a mechanism to update and distribute state around. It cannot be considered as "architecture" as it does not answer the important questions
- how to protect the app from the outside world?
- how to enable easy change of technologies and third-party dependencies?
- how to define strict boundaries to isolate features?
- where are the business rules defined?
- which parts deal with navigation, presentation, networking, storage, user input, and so on?
These discussed architectures do not address modeling and state sharing in complete detail, they are often either used in a poor way that is not very readable and sustainable, or in a way that inherently leads to a more complex architecture with a looser definition of responsibilities.
Although SwiftUI is a new player on the mobile playground, managing state is definitely not. Let's see if the old and traditional principles still have anything to offer in this new world.
There are many patterns and principles that are timeless, namely two that enable to store and distribute state in a transparent way.
A repository is a design pattern that abstracts data access logic from business access logic by exposing the collection-like interface to access business entities. The actual implementation of the interface might communicate with the database, REST API, and what your project requires as long as it doesn't leak its details to the business domain interface.
Such a repository could easily be the source of the truth for each feature. Repositories obviously hold the state, but let's see how to distribute the same state to multiple parts of the application.
Sharing state across features is as simple as sharing the same repository instance. Dependency injection frameworks help organize the dependency graph. Some use Service Locator patterns that abstracts instance resolution behind an abstraction layer. It reduces boilerplate code you would otherwise need with manual dependency injection. The resolution might cause runtime crashes if some requested instances are not registered. That's a downside that can you can handle with unit tests though.
You can also implement a shared repository using pure language without any dependency frameworks. The principle remains the same in both cases.
It's important to design your system
- to be soft and malleable, so you're able to swiftly deliver changes that are desired,
- but robust enough, so that you can be sure those changes are not going to cause unexpected behavior,
- and clear enough so it's easy to navigate and doesn't grow over your head.
Decoupling is critical to achieving that. To decouple views, view models, scenes, and what have you, you need to figure out how to share the source of truth across the app. If you mishandle it or don't think much about the state, you're going to have a hard time even with UIKit applications as it leads to coupling that results in code that's hard to test, read and reason about, for instance:
ViewModels in this case do way too much. They pass state among each other which makes them difficult to test and harder to read. The source of truth for these data is also lost, it's unclear where the state originated and who owns it.
State management is not something new that came with SwiftUI, it's been here for decades, and there are no specific new architecture requirements. If you make features independent, inject your dependencies, test, and define the source of truth with a repository pattern, you can design your system to be easier to maintain and grow, regardless of which UI framework you pick.
There's a strict boundary between features in this example. ViewModels only transform data and actions between Views and UseCases. There is one clear source of truth for each feature. Isolation leads to reusability and testability.
State management was always important in app development, unfortunately often it was neglected or done poorly. Declarative UI concepts do not bring anything new to the table as they just explicitly accent what's important to handle well because the view is a function of a state.
Breakthrough technologies such as SwiftUI don't come out that often, but that doesn't mean that your app and architecture should succumb to being completely dependent on the technology and/or derive itself from it. On the contrary, if you adhere to the separation of concerns and decouple your code, you achieve strictly defined boundaries and can start adopting those technologies without having to rewrite most of your app and even do so incrementally.
SwiftUI is a brand new technology, but that doesn't mean we can't benefit from timeless and classic principles that helped deal with many challenges over many decades. Old and new can go hand in hand together and complement each other to enable us to create brilliant apps.
Written by: Libor Huspenina