/ ARCHITECTURE, SWIFTUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

Every arrow represents a stream of values. The streams are also connected with each other:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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:

  1. MoviesListViewModel is the entry point of the feature. It connects all the dependencies and starts the state machine.
  2. The whenLoading() feedback handles networking. We’ll implement it in a moment.
  3. The send() method provides a way of passing user input and view lifecycle events. Using the input 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:

  1. Check that the system is currently in the loading state.
  2. Fire a network request.
  3. In case the request succeeds, the feedback sends an onMoviesLoaded event with a list of movies.
  4. 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:

Modern MVVM iOS App Architecture with Combine and SwiftUI

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.

Vadim Bulavin

Creator of Yet Another Swift Blog. Coding for fun since 2008, for food since 2012.

Follow