Modern MVVM iOS App Architecture with Combine and SwiftUI
The iOS Developer Community Survey shows that Model-View-ViewModel (MVVM) is the second most popular architectural pattern for designing iOS apps. It makes a good reason to study the state of modern MVVM with SwiftUI and Combine.
In this article we’ll cover:
- The purpose of MVVM.
- The components of the MVVM pattern.
- The flow of data and dependencies in MVVM.
- Why should we use unidirectional data flow instead of two-way bindings?
- How to represent the UI as a finite-state machine?
And build an iOS app using the MVVM architecture pattern, Combine and SwiftUI frameworks.
History
MVVM has its roots in the Application Model pattern, invented in the Smalltalk engineering realm in 1988. The primary goal of the pattern was to split two kinds of logic, the presentation, and the business logic, into two separate objects: the Application Model and the Domain Model, respectively.
In 2004, Martin Fowler rebranded Application Model into Presentation Model (PM). The idea of PM is to create a UI-agnostic object Presentation Model that pulls all the state and behavior out of a View. This way, the view merely projects the state of the presentation model onto the screen.
Microsoft introduced MVVM in 2006 for designing and implementing desktop client applications with the Windows Presentation Foundation (WPF) UI framework. With MVVM, Microsoft pursued the goal of standardizing the way WPF applications are developed. The pattern intended to leverage the power of the WPF framework, such as data binding.
According to Microsoft, MVVM is a specialized version of Fowler’s Presentation Model. Martin Fowler even claims that they are the same.
The Purpose of MVVM
The goal of MVVM is to separate the business and presentation logic from the UI. It improves testability and maintainability, which are often the key success factors of an app.
To achieve its goal, MVVM minimizes decision-making in the views and moves view state and behavior into the view model. This way, the view becomes passive:
- The view does not pull data from the view model.
- The view is not responsible for updating itself from the view model.
- The view has its state managed by the view model.
Such a design allows us to test presentation logic in isolation from the GUI stack.
The MVVM pattern
MVVM is the UI pattern. As with the most rich client systems, this is where a large part of your iOS app’s codebase sits. SwiftUI views, UIKit views and view controllers, storyboards and xibs all belong in here.
MVVM provides a set of guidelines on:
- How to display information on the UI.
- How to handle interactions between the user and the app.
- How to interpret user inputs into actions upon business rules and data.
MVVM can be broken down into the three components that follow a strict dependency rule:
Dependencies are organized in the following way:
- The view depends on the view model.
- The view model depends on the model.
- Neither the model nor the view model depends on the view.
Depends on means code dependency, like imports, references, function calls.
Note that the flow of the data is different as compared with that of the dependency:
That is, the data flows in both directions. It starts with a user interaction, which is handled by the view. Next, the view passes interaction events to the view model. Then the view model translates the events into CRUD (create, read, update and delete) operations upon model and data.
The reverse flow is also the case. The model fetches data from the backend, or a database, or any other source. Next, the model passes data to the view model. Then the view model prepares data in a form that is convenient for the view to consume. Lastly, the view renders data onto the screen.
Now let’s discover the roles of the MVVM components.
ViewModel
The ViewModel represents the data as it should be presented in the view, and contains presentation logic.
The responsibilities of the ViewModel are:
- Manage UI behavior and state.
- Interpret user inputs into actions upon business rules and data. Typically, a view model maintains a one-to-many relationship with model objects.
- Prepare data from a model to be presented to a user. A view model structures data in a way that is convenient for a view to consume.
The ViewModel is independent of the UI frameworks. Think twice if you are about to import SwiftUI or UIKit inside your ViewModel.swift
file.
View
The View renders the UI and passes user interactions forward. It has no state and does not contain any code-behind that interprets user actions.
The responsibilities of the View are:
- Render the UI.
- Perform animations.
- Pass user interactions to a view model.
Model
The Model is the software representation of the business concepts that earn money or bring any other value to your customer. It is the primary reason why your iOS app is actually written.
Although MVVM has the Model as a part of its name, MVVM does not make any assumptions about its implementation. It can be Redux or a variation of Clean Architecture, like VIPER.
The Modern State of MVVM
The following techniques shape what I consider to be the modern state of MVVM in Swift.
FRP and Data Binding
The single most important aspect that makes MVVM viable in the first place is data binding.
Data binding is a technique that connects the data provider with consumers and synchronizes them.
Using the data binding technique, we can create streams of values that change over time. Functional Reactive Programming (FRP) is a programming paradigm concerned with data streams and the propagation of change. In FRP, streams of values are first-class citizens. It means that we can build them at runtime, pass around, and store in variables.
The Combine and SwiftUI frameworks provide first-party FRP support, which allows us to seamlessly reflect view model changes in a view, and remove the need for writing code in a view model that directly updates a view.
Unidirectional Data Flow Over Two-Way Bindings
Many applications of the MVVM pattern use two-way bindings to synchronize a view with a view model. Explanations of such an approach are often accompanied by an example of a counter app. Although it works fine for two data streams – counter increment and counter decrement – the two-way binding approach does not scale well when applied to production-like features.
Let’s demonstrate the problems by example. Here is a sign-up screen with just four states:
If we wish to implement it with MVVM and connect a view and a view model with two-way bindings, it will likely look next:
Every arrow represents a stream of values. The streams are also connected with each other:
The figure still has some details missing. Typically, in a production app, you will send network requests, and allow your users to login with identity providers, like Google or Facebook. The two-way binding approach gets out of control very quickly and eventually ends up like this:
The second problem with the two-way bindings is error handling. Out-of-the-box, the Combine framework does not provide a concept of a never-ending-stream-of-values, like RxSwift Relay. Therefore, when an error occurs, it will terminate the whole stream and potentially leave your app’s UI unresponsive. Although you can recreate Relays in Combine on top of PublishSubject
or CurrentValueSubject
, it may not be the right way to go for the reasons explained in the next section.
UI as a State Machine
Another significant issue that falls out of the two-way bindings approach is state explosion.
What is a state? The state of an object means the combination of all values in its fields. Therefore, the combinatorial number of UI states grows with factorial complexity. However, most of such states are unwanted or even degenerate.
For instance, let’s take the isSigningUp
and errorMessage
streams from the sign-up example. It’s unclear how to render the UI in case isSigningUp
sends true
and errorMessage
sends a non-nil
value. Should we show a loading indicator? Or an alert box with an error message? Or both?
There are even more problems with unexpected states:
- They create lots of code paths that are very difficult to test exhaustively.
- The complexity of adding new states keeps accumulating.
The solution is to work out all possible states and all possible actions that trigger state transitions and make them explicit. A finite-state machine (FSM) is the computational model that formalizes this idea.
The FSM can be in exactly one of a finite number of states at any given time. It can change from one state to another in response to external inputs; such change is called a transition [1].
The UI FSM will manage the state of a view and handle user inputs via a state-transitioning function that may include additional side-effects. A state machine is fully defined in terms of its [2]:
- Set of inputs.
- Set of outputs.
- Set of states.
- Initial state.
- State transitioning function.
- Output function.
In this article, we’ll be using the CombineFeedback library that lends itself to designing reactive state machines. With CombineFeedback, the structure of the app components looks next:
Let’s describe the core components of the above figure.
State represents the state of your finite-state machine.
Event describes what has happened in a system.
Reduce specifies how the state changes in response to an event.
Feedback is the extension point between the code that generates events and the code that reduces events into a new state. All your side effects will sit in here. Feedback allows us to separate side effects from the pure structure of the state machine itself (see it in green).
ViewModel fully initializes a UI state machine.
To set up a state machine, we’ll need the system
operator and the Feedback
type. The system
operator creates a feedback loop and bootstraps all dependencies:
The snippet below is based on the implementation from RxFeedback.
extension Publishers {
static func system<State, Event, Scheduler: Combine.Scheduler>(
initial: State,
reduce: @escaping (State, Event) -> State,
scheduler: Scheduler,
feedbacks: [Feedback<State, Event>]
) -> AnyPublisher<State, Never> {
let state = CurrentValueSubject<State, Never>(initial)
let events = feedbacks.map { feedback in feedback.run(state.eraseToAnyPublisher()) }
return Deferred {
Publishers.MergeMany(events)
.receive(on: scheduler)
.scan(initial, reduce)
.handleEvents(receiveOutput: state.send)
.receive(on: scheduler)
.prepend(initial)
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
Feedback
produces a stream of events in response to state changes. It allows us to perform side effects, like IO, between the moments when an event has been sent, and when it reaches the reduce function:
The snippet below is based on the implementation from CombineFeedback.
struct Feedback<State, Event> {
let run: (AnyPublisher<State, Never>) -> AnyPublisher<Event, Never>
}
extension Feedback {
init<Effect: Publisher>(effects: @escaping (State) -> Effect) where Effect.Output == Event, Effect.Failure == Never {
self.run = { state -> AnyPublisher<Event, Never> in
state
.map { effects($0) }
.switchToLatest()
.eraseToAnyPublisher()
}
}
}
Finally, let’s discover how to put everything together.
Building an App with MVVM
To follow the code samples you’ll need some basic knowledge of the SwiftUI and Combine frameworks. Getting Started with Combine and Apple SwiftUI tutorials will get you up to speed.
Let’s explore the MVVM iOS app architecture by building a movie app from scratch. Here is how the final result will look:
Implementing Movies List ViewModel
The app has two screens:
- A list of trending movies.
- Details of a movie.
We begin with the movies list. Before writing any code, we must design the state machine:
Based on the figure, it’s trivial to represent a list of states and events in code. I prefer declaring them as inner types of a view model:
final class MoviesListViewModel: ObservableObject {
@Published private(set) var state = State.idle
...
}
extension MoviesListViewModel {
enum State {
case idle
case loading
case loaded([ListItem])
case error(Error)
}
enum Event {
case onAppear
case onSelectMovie(Int)
case onMoviesLoaded([ListItem])
case onFailedToLoadMovies(Error)
}
}
Note that MoviesListViewModel
implements the ObservableObject
protocol. This allows us to bind a view to the view model. SwiftUI will automatically update the view whenever the view model updates its state.
Some of the states have associated values in order to draw them on the UI or to pass to the next state. Similarly, events carry data, which is the only source of information when we produce a new state inside a reduce()
function.
Now we can implement a reduce()
function that defines all possible state-to-state transitions:
extension MoviesListViewModel {
static func reduce(_ state: State, _ event: Event) -> State {
switch state {
case .idle:
switch event {
case .onAppear:
return .loading
default:
return state
}
case .loading:
switch event {
case .onFailedToLoadMovies(let error):
return .error(error)
case .onMoviesLoaded(let movies):
return .loaded(movies)
default:
return state
}
case .loaded:
return state
case .error:
return state
}
}
}
On the state machine figure, you can see all the events except for onSelectMovie
. The reason for that is because onSelectMovie
is sent as a result of user interaction with an app. User input is a side effect that needs to be handled inside feedback:
extension MoviesListViewModel {
static func userInput(input: AnyPublisher<Event, Never>) -> Feedback<State, Event> {
Feedback { _ in input }
}
}
Then we initialize the state machine using the system
operator:
final class MoviesListViewModel: ObservableObject {
@Published private(set) var state = State.idle
private var bag = Set<AnyCancellable>()
private let input = PassthroughSubject<Event, Never>()
init() {
// 1.
Publishers.system(
initial: state,
reduce: Self.reduce,
scheduler: RunLoop.main,
feedbacks: [
// 2.
Self.whenLoading(),
Self.userInput(input: input.eraseToAnyPublisher())
]
)
.assign(to: \.state, on: self)
.store(in: &bag)
}
deinit {
bag.removeAll()
}
// 3.
func send(event: Event) {
input.send(event)
}
}
The takeaways are:
MoviesListViewModel
is the entry point of the feature. It connects all the dependencies and starts the state machine.- The
whenLoading()
feedback handles networking. We’ll implement it in a moment. - The
send()
method provides a way of passing user input and view lifecycle events. Using theinput
subject, we propagate the events into the feedback loop for processing.
The two pieces that are missing are the whenLoading()
feedback and the ListItem
type. Both of them are related to loading movies from the network.
When the system enters the loading
state, we initiate a network request:
static func whenLoading() -> Feedback<State, Event> {
Feedback { (state: State) -> AnyPublisher<Event, Never> in
// 1.
guard case .loading = state else { return Empty().eraseToAnyPublisher() }
// 2.
return MoviesAPI.trending()
.map { $0.results.map(ListItem.init) }
// 3.
.map(Event.onMoviesLoaded)
// 4.
.catch { Just(Event.onFailedToLoadMovies($0)) }
.eraseToAnyPublisher()
}
}
Here is what we are doing:
- Check that the system is currently in the
loading
state. - Fire a network request.
- In case the request succeeds, the feedback sends an
onMoviesLoaded
event with a list of movies. - In case of a failure, the feedback sends an
onFailedToLoadMovies
event with an error.
The network client talks to TMDB API in order to fetch trending movies. I am skipping some implementation details to keep focus on the main subject:
You can learn how to build a promise-based networking layer with Combine here.
enum MoviesAPI {
static func trending() -> AnyPublisher<PageDTO<MovieDTO>, Error> {
let request = URLComponents(url: base.appendingPathComponent("trending/movie/week"), resolvingAgainstBaseURL: true)?
.addingApiKey(apiKey)
.request
return agent.run(request!)
}
}
A list entry is represented with an object:
struct MovieDTO: Codable {
let id: Int
let title: String
let poster_path: String?
var poster: URL? { ... }
}
The DTO suffix means that we are using the Domain Transfer Object pattern.
ListItem
is a mapping of MovieDTO
for the purpose of presentation:
extension MoviesListViewModel {
struct ListItem: Identifiable {
let id: Int
let title: String
let poster: URL?
init(movie: MovieDTO) {
id = movie.id
title = movie.title
poster = movie.poster
}
}
}
Implementing Movies List View
After designing the view model, now we can start with the implementation of the view.
First, bind the view to the view model state updates by means of the @ObservedObject
property wrapper:
struct MoviesListView: View {
@ObservedObject var viewModel: MoviesListViewModel
var body: some View {
...
}
}
Next, in the body
, we want to send a lifecycle event to the view model:
struct MoviesListView: View {
...
var body: some View {
NavigationView {
content
.navigationBarTitle("Trending Movies")
}
.onAppear { self.viewModel.send(event: .onAppear) }
}
private var content: some View {
...
}
}
State rendering takes place in the content
variable:
struct MoviesListView: View {
...
private var content: some View {
switch viewModel.state {
case .idle:
return Color.clear.eraseToAnyView()
case .loading:
return Spinner(isAnimating: true, style: .large).eraseToAnyView()
case .error(let error):
return Text(error.localizedDescription).eraseToAnyView()
case .loaded(let movies):
return list(of: movies).eraseToAnyView()
}
}
private func list(of movies: [MoviesListViewModel.ListItem]) -> some View {
...
}
}
Here how the list(of:)
method is implemented:
private func list(of movies: [MoviesListViewModel.ListItem]) -> some View {
return List(movies) { movie in
NavigationLink(
destination: MovieDetailView(viewModel: MovieDetailViewModel(movieID: movie.id)),
label: { MovieListItemView(movie: movie) }
)
}
}
MovieDetailView
represents details of a movie. If a user taps a list row, it will be pushed onto the navigation stack. MovieDetailView
is initialized with a view model, which, in its turn, accepts a movie identifier.
MovieListItemView
represents a list row. Note that it accepts a view model of type MoviesListViewModel.ListItem
rather than MovieDTO
. It is important not to mix the infrastructure details, i.e. MovieDTO
, with the presentation, i.e. the view models. I am skipping the implementation of MovieListItemView
since it is not directly relevant to our subject.
Implementing Movie Details
Movie details state machine is identical as compared with that of the list of trending movies:
Here is how we represent the movie details state machine in code:
final class MovieDetailViewModel: ObservableObject {
@Published private(set) var state: State
...
}
extension MovieDetailViewModel {
enum State {
case idle(Int)
case loading(Int)
case loaded(MovieDetail)
case error(Error)
}
enum Event {
case onAppear
case onLoaded(MovieDetail)
case onFailedToLoad(Error)
}
struct MovieDetail {
...
}
}
In order to pass user events, we create a userInput
feedback:
final class MovieDetailViewModel: ObservableObject {
...
private let input = PassthroughSubject<Event, Never>()
func send(event: Event) {
input.send(event)
}
static func userInput(input: AnyPublisher<Event, Never>) -> Feedback<State, Event> {
Feedback(run: { _ in
return input
})
}
}
Next, we declare one more feedback that fires a network request:
static func whenLoading() -> Feedback<State, Event> {
Feedback { (state: State) -> AnyPublisher<Event, Never> in
guard case .loading(let id) = state else { return Empty().eraseToAnyPublisher() }
return MoviesAPI.movieDetail(id: id)
.map(MovieDetail.init)
.map(Event.onLoaded)
.catch { Just(Event.onFailedToLoad($0)) }
.eraseToAnyPublisher()
}
}
It calls movieDetail()
from MoviesAPI
that fetches movie details provided movie identifier:
enum MoviesAPI {
...
static func movieDetail(id: Int) -> AnyPublisher<MovieDetailDTO, Error> {
let request = URLComponents(url: base.appendingPathComponent("movie/\(id)"), resolvingAgainstBaseURL: true)?
.addingApiKey(apiKey)
.request
return agent.run(request!)
}
}
struct MovieDetailDTO: Codable {
let id: Int
let title: String
let overview: String?
let poster_path: String?
let vote_average: Double?
let genres: [GenreDTO]
let release_date: String?
let runtime: Int?
let spoken_languages: [LanguageDTO]
...
}
MovieDetailDTO
is created for the purpose of parsing network response. It shouldn’t leak into the UI layer. When the view model receives a successful network response, it maps MovieDetailDTO
into MovieDetailViewModel.MovieDetail
. The latter is the representation of the same data that is convenient for a view to consume.
Then we initialize the state machine:
final class MovieDetailViewModel: ObservableObject {
@Published private(set) var state: State
private var bag = Set<AnyCancellable>()
init(movieID: Int) {
state = .idle(movieID)
Publishers.system(
initial: state,
reduce: Self.reduce,
scheduler: RunLoop.main,
feedbacks: [
Self.whenLoading(),
Self.userInput(input: input.eraseToAnyPublisher())
]
)
.assign(to: \.state, on: self)
.store(in: &bag)
}
...
}
Now we can implement a view:
struct MovieDetailView: View {
@ObservedObject var viewModel: MovieDetailViewModel
var body: some View {
content
.onAppear { self.viewModel.send(event: .onAppear) }
}
private var content: some View {
switch viewModel.state {
case .idle:
return Color.clear.eraseToAnyView()
case .loading:
return spinner.eraseToAnyView()
case .error(let error):
return Text(error.localizedDescription).eraseToAnyView()
case .loaded(let movie):
return self.movie(movie).eraseToAnyView()
}
}
private func movie(_ movie: MovieDetailViewModel.MovieDetail) -> some View {
...
}
}
Source Code
You can find the final project here. It is published under the “Unlicense”, which allows you to do whatever you want with it.
References
Thanks for reading!
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.