SwiftUI App Architecture
Have you ever wondered how to structure your SwiftUI app and allow it to grow in a sustainable way? We did and still do. Meanwhile after having developed several (award-winning (1)(2)) SwiftUI apps, we think that we have found a good design pattern which fits our needs. We’d like to share it with you (example project included).
Since Apple introduced SwiftUI as a declarative UI framework and Combine as a functional reactive programming framework with iOS 13, developers were taught how to use them with tutorials and sample apps, but left in the dark on which app architecture to use when it comes to larger apps. Available tutorials from Apple and 3rd parties aren’t really helping either because they’re only focusing on a small use case and often lack emphasis on testing, which is a crucial point for us.
However there is no doubt, that the iOS future belongs to SwiftUI and Combine. This motivated us to write the consumer app SBB Inclusive (amongst others intern apps) entirely with SwiftUI. However, this lead us to big architectural discussions. Today and after many refactorings, we think that we found a design pattern (or architecture), that has proven to fit all our needs:
- Separation of concerns, so multiple developers know and understand what they are working on.
- Having SwiftUI Previews with mock data to efficiently develop and review UI, sometimes directly with designers and product owners.
- Having an architecture which is suitable for Unit Testing and UI Testing so we can have a high Code Coverage of our app and thus ensure correctness of our software.
SwiftUI itself uses Combine to asynchronously communicate events between the Views and their states being directly in the Views or in some ObservableObject
. By adopting Combine, not only for the View
and ObservableObject
, but also for the rest of the app, we benefit of the Combine framework to stream events from/to the UI through the entire app. Declarative UI and functional reactive programming is a great fit, and based on our experience integration of SwiftUI and Combine works perfectly. So we decided to leverage it for our design pattern.
Our pattern is a concrete application of MVVM with SwiftUI and Combine. We’d like to share this design pattern, because we think it might help many iOS developers out there working on bigger projects and also because we’re interested in alternative ideas and discussion.
So, if this sounds interesting to you, keep on reading (we’ll assume that you have previously worked with SwiftUI and Combine). We’ll first dive into our design pattern that has suited us well and later show (with code samples) how it fits for all our needs. If you want to skip all our explanations and just dive into code, you can head directly to our sample project on GitHub:
⚒️ SwiftUI TargetArchitecture XCode project
Disclaimer: The use case in the example project is far too simple. That way, the fakes look almost like their real implementation. In a real world scenario they will differ much more.
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.
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 theView
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 parentView
. TheView
does not “own” this state but is updated/updates the value owned by a parentView
. Example: a custom checkboxView
.@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 theView
itself, it is received from its parent.ObservableObject
are recreated every time the parentView
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 theView
and its subviews, but theObservableObject
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.@EnvironmentObject var
:@EnvironmentObject
areObservableObject
injected in aView
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 usingNavigationLink
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 (onModel
in e.g. below) and to override it with fake implementation for unit testing the ViewModel
.
The typical implementation of a ViewModel looks like:
The ViewModel
implementation is not directly an ObservableObject
but it adopts a protocol which inherits of the ObservableObject
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.
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:
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:
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.func
: to triggers event down the layers.Publisher
orSubject
: to stream events down the layers.
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.
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.
Why does this design pattern fit our needs ?
Now that we have a general understanding of the different components of the SwiftUI and Combine architecture we used, let’s see how this is implemented in practice and how it helps us achieve our goals.
Separation of concerns
Separation of concerns is achieved through four layers each having its own responsability:
- Views are responsible for describing how the UI looks like.
- ViewModels provide the content for the Views and handle what to do in case of UI events (e.g. a button is touched).
- Models contain app business logic and interfaces to external components (Networking, Libraries, Persistence, etc.).
One of our requirements is to be able to test each layer of our architecture individually. Our View layer is tested with automated UI Tests and relies on fake ViewModels with mock data corresponding to our test scenario. SwiftUI Previews also rely on the same mechanism. All other layers are unit tested.
Unit Testing
ViewModel, and Model layers are all unit tested. For each of those layers, we need to fake the layer below so that they publish exactly the value(s) that are needed for our test case scenario. This is why we are using protocols for every layer, so that we can then implement a “normal” class inside the app bundle and implement a fake class inside the test bundle which can be used for testing.
This way we can control what the FakeModel
will publish in our Unit Tests:
SwiftUI Previews with fake ViewModels
Our View’s content depends on its ViewModel(s). To be able to preview our View in all possible different ViewModel states we use FakeViewModels.
We start by creating a ViewModelProtocol
and then implement the FakeViewModel
based on that protocol. Inside the view, you then use generics, to accept either the real or the fake implementation :
UITesting
Our approach for UITesting is very similar to creating different previews. We also want to use our FakeViewModel
with different content for our different UITest scenarios. When setting up UITests we set the launchEnvironment
value for the key UI_TEST_SCENARIO
.
We then check for this key in SceneDelegate
(using precompiler flags) and inject the according FakeViewModel
instead of the real ViewModel
if the key is set.
Conclusion
As you have seen, the design pattern we have presented here brings many benefits with it. It might be overkill for small apps… but we never know when an App will grow!
As soon as you work on a larger project, Unit Testing, UI Testing, previewing different view states and a clear separation of concerns become crucial for the success of the project and its maintenance on a long term. With this design pattern, we have an architecture which has proven to fit our needs.
We are still eagerly waiting for news or updates of SwiftUI and Combine. We were hoping for a larger adoption of Combine in other Apple frameworks with iOS 14. Unfortunately, this was not the case. Let’s wait and see what iOS 15 will have in petto for Combine. For SwiftUI, iOS 14 brought a few updates but it is still missing some important features.
Now, we don’t think that our design pattern is the holy grail and we are sure it might evolve over time. Yet, as of today, we have used this design pattern for the SBB Inclusive app, as well as for some internal apps, and we are very happy with it. We thought it was interesting to share our experiences on this topic as those apps are using SwiftUI and Combine exclusively and maintained by multiple developers for more than a year. You will find a glimpse of our team developing SBB Inclusive in this video.
We have prepared a demo app using our design pattern described in this article. You can check out our sample project on GitHub:
⚒️ SwiftUI TargetArchitecture XCode project
Disclaimer: The use case in the example project is far too easy. That way, the fakes look almost like their real implementation. In a real world scenario they will differ much more.
Let us know what you think about it, and what design patterns you use for the architecture of your SwiftUI and Combine apps. We’d be more than happy to start a discussion on this topic.