SwiftUI App Architecture

AppBakery
The Startup
Published in
11 min readDec 22, 2020

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.

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.
  • @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.
  • @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.
  • @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.
  • @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 (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:

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.
  • func: 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.

Detailed architecture overview.

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.

Each layer can be tested individually.

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.

Real and fake Model implementation are both implementing the same protocol.

This way we can control what the FakeModel will publish in our Unit Tests:

Unit test of ViewModel relying on a fake Model implementation.

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.

Previewing the same View with different FakeViewModel states.

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 :

SwiftUI Preview based on fake ViewModel 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.

UI Test triggering scenario 1 based on fake ViewModel implementation.

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.

SceneDelegate implementation: using pre-compiler flags and launch environment variable we determine the ViewModel implementation to use.

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.

--

--

AppBakery
The Startup

In-house agency for Mobile Apps & Web at Swiss Federal Railways #sbbcffffs