SwiftUI App Architecture
Tags: Swift, Swiftui, Combine, App Architecture, iOS

Our architecture for SwiftUI/Combine apps

Let’s start with a simplified overview of our architecture for SwiftUI and Combine Apps before digging in the role of each layer.

Simplified overview of our MVVM architecture for SwiftUI and Combine apps.

Views layer

The Views layer contains all our SwiftUI View code. We try to keep our View structs as small as possible. Our goal is to have many reusable small View , which we can use and re-use to compose our screens.

Developing the UI itself with SwiftUI View and modifiers is quite easy. Many tutorials and example apps are helping you out. But when the app becomes more complex, like real world apps do, developers discover that the real challenge is to determine where to put the states used by SwiftUI. Unfortunately, there is not a lot of guidance for this, except the principle of the single source of truth. So, we came up with the following guidelines to make our own decisions.

We use the following property wrappers for our states in the following use cases. Note that for @ObservedObject , @StateObject , @EnvironmentObject , the ObservableObject is a class in our ViewModel layer.

@State private var : when the state won’t leave the View or its sub-views. This is typically for information which is only UI relevant, not persisted and has no impact on the logic of the app. Example: the expanded state of an accordion widget.

: when the state won’t leave the or its sub-views. This is typically for information which is only UI relevant, not persisted and has no impact on the logic of the app. Example: the expanded state of an accordion widget. @Binding var : for sub-views referencing the state of the parent View . The View does not “own” this state but is updated/updates the value owned by a parent View . Example: a custom checkbox View .

: for sub-views referencing the state of the parent . The does not “own” this state but is updated/updates the value owned by a parent . Example: a custom checkbox . @ObservedObject var : the state is not only UI relevant, it is part of the logic of the app, but it is scoped to a specific part of the application. The @ObservedObject is generally not owned by the View itself, it is received from its parent. ObservableObject are recreated every time the parent View is recreated. Example: states of a specific screen in the app.

: the state is not only UI relevant, it is part of the logic of the app, but it is scoped to a specific part of the application. The is generally not owned by the itself, it is received from its parent. are recreated every time the parent is recreated. Example: states of a specific screen in the app. @StateObject private var : @StateObject were introduced with iOS 14. They are pretty similar to @ObservedObject in the way that they are intended for states that are not only UI relevant and scoped to the View and its subviews, but the ObservableObject is not recreated every time the view is displayed. So, when choosing between @ObservedObject and @StateObject , there is a tradeoff between computation time of recreating the ViewModel every time and keeping it in memory all time long. Example: states of a specific screen in the app.

: were introduced with iOS 14. They are pretty similar to in the way that they are intended for states that are not only UI relevant and scoped to the and its subviews, but the is not recreated every time the view is displayed. So, when choosing between and , there is a tradeoff between computation time of recreating the ViewModel every time and keeping it in memory all time long. Example: states of a specific screen in the app. @EnvironmentObject var : @EnvironmentObject are ObservableObject injected in a View hierarchy using .environmentObject() modifier. Then the entire hierarchy can conveniently retrieve them with @EnvironmentObject . This is intended for states which are not only UI relevant, and not scoped for a specific part of the application. We typically inject the ViewModels in the SceneDelegate on the entire View hierarchy of the App. When choosing between @EnvironmentObject and @ObservedObject / @StateObject , there is a tradeoff between having states easily available but widely exposed or having states less conveniently available (pass it through initializer in the hierarchy) but scoped to where it must be used. Note that @EnvironmentObject need to be passed again (by using .environmentObject() ) explicitly when using NavigationLink or .sheet() .

In our SBB Inclusive App, we are using (a lot of) @EnvironmentObject to retrieve our main ViewModels (like the one having timetable Data) conveniently everywhere through the App. We do not use that many @ObservedObject . We only use them for ViewModels of dedicated features like onboarding. @StateObject is not used as we do support both iOS 13 and 14. @State and @Binding are mostly used in our dedicated Mobile Design System SwiftUI framework, which helps us reusing our SwiftUI components through all our Apps.

ViewModels layer

The main goal of our ViewModels is to react to Combine events triggered by our Models layer, and to transform those events into @Published vars which are useful for our Views layer. ViewModels typically subscribe to Combine Publishers located in the Models layer. Events of the Publisher are then delivered on the main thread and assigned to a @Published var. More complex operations are sometimes required, like transforming values or assigning to multiple @Published vars with .sink() .

ViewModels are ObservableObject classes containing all the @Published vars (the states) which are needed by our Views and rely on Data of our Models layer. The ViewModel has also functions which are the actions which can be triggered by the View (with a Button for example).

The initialization allows us to provide the default implementation for dependencies (on Model in e.g. below) and to override it with fake implementation for unit testing the ViewModel .

The typical implementation of a ViewModel looks like:

Typical implementation of a ViewModel.

The ViewModel implementation is not directly an ObservableObject but it adopts a protocol which inherits of the ObservableObject protocol.

Typical ViewModel protocol.

The goal of this protocol is to be able to implement a FakeViewModel . The FakeViewModel is a very simple implementation which allows us to test the different possible states with Swift UI Previews and UI Testing.

Typical fake ViewModel implementation.

For this preview/testing purpose, we have to use the type of the ViewModel protocol ( ViewModelProtocol ) and not its implementation ( ViewModel or FakeViewModel ) type in the SwiftUI View. When doing it naively and directly like this:

Error while adopting the ViewModel protocol directly.

We end up with this error: “Protocol ‘MyViewModelProtocol’ can only be used as a generic constraint because it has Self or associated type requirements”. We just have to use generic in order to solve this:

Correctly adopting the ViewModel protocol using generics.

A specific type of events generated by Views that are received by ViewModels are navigation events. Many design pattern have a specific concept for the navigation events (Router, …). After trying a few variants for navigation, we ended up with the conclusion that with SwiftUI, navigation is just managed by states, like any other states of the app.

So, our ViewModels also contain the states on which navigation logic of the app relies on. As it is in the ViewModel layer, this also means that navigation can be unit tested.

Models layer

The Models layer consists of multiple components that contain the business logic of our application. Those components might also be responsible of providing the interface to external components (e.g. Networking, CoreLocation) and can also depend on each other. It heavily uses Combine if possible. Models can present different things to the above layer including:

Publisher : which will stream events up the layers.

: which will stream events up the layers. func : to triggers event down the layers.

: to triggers event down the layers. Publisher or Subject : to stream events down the layers.

Typical Model implementation.

Models adopt a protocol in order to enable unit testing. The pending oversimplified fake implantation also adopts the same protocol. Fake Models can then be used for unit testing components relying on it. Although it would have been nice to have the right Publisher type, like Just , Future or CurrentValueSubject , exposed in the protocol, we erase it to AnyPublisher . This allows us to use different Publisher implementations between the real Model implementation and the fake one.

Typical Model protocol with erased type Publisher.

Data

Data flowing through the Publisher are immutable structs. Ideally those immutable structs should flow from Model to View or View to Model. It could also make sense to convert them in order to have a struct which is better reflecting what is needed by a layer. As an example, a payload received by a networking Model could be converted into a more meaningful struct, mapping the business logic before being used by a ViewModel.

When persisting or transmitting those immutable Data structs, it makes sense to have them conforming to Codable protocol. It is also a good practice to adopt Equatable and Identifiable protocols in a meaningful way for our business logic.

Detailed architecture overview

Let’s wrap everything up: A View can rely on one or multiple ViewModels. Each ViewModel can possibly rely on one or multiple Models. All those layers are exchanging Data being immutable structs. All those layers adopt their own protocol in order to have one or many Fakes conforming to the same protocol. Those Fakes are used for SwiftUI Previews, UI testing and unit testing.