SwiftUI and MVI

I have been working with the MVI architecture in SwiftUI for several years and continue to do so. I previously wrote about this approach and recently I decided to update and refactor repository with an example of MVI on SwiftUI, simplified a lot and made it more convenient, and also decided to write an up-to-date Russian version of an article about the architecture of MVI on SwiftUI.

UIKit first appeared in iOS 2 and is still with us today. Over time, we got used to it and learned to work with it. We found different architectural approaches, and MVVM became the most popular, in my opinion. With the release of SwiftUI, MVVM has further strengthened its position, while other architectures do not fare well on SwiftUI.

However, architectures that may not seem to be usable with SwiftUI can be adapted to SwiftUI, and may even be more convenient than MVVM.

I want to talk about one of these architectures – MVI. But first, a little theory.

Bidirectional and Unidirectional Architectures

All existing architectures can be divided into two types:

IN bidirectional architectures data is transferred between modules, which can both transmit and receive data from each other. Each module has the ability to both send and receive data.

Bidirectional architecture

Bidirectional architecture

The main disadvantage of such architectures is data flow control. Large and complex screens can become difficult to navigate, making it nearly impossible to keep track of where the data came from, where it changes, and what screen state is ultimately displayed. These architectures are suitable for small to medium-sized applications and are generally easier to implement and understand compared to unidirectional architectures.

Unidirectional architectures, on the other hand, are structured in such a way that data flows in one direction. It is important to note that one module does not know about the existence of another module and cannot directly pass data back to the module from which it received the data.

Unidirectional architecture

Unidirectional architecture

Working with a unidirectional architecture often leads to complaints about unnecessary modules for simple screens. Another complaint is that even small changes require passing data through all modules, and some modules are proxies and do nothing else.

However, these shortcomings are compensated for on large screens with complex logic. In such architectures, responsibilities are distributed better than in bidirectional ones. Working with code is simplified because it is easy to track where data comes from, where it is changed and where it is transferred.

I was a little disingenuous when I said that there are architectures that may be more convenient than MVVM. Such architectures do exist, but they are more suitable for large projects. I'm talking about MVI, Clean Swift and other unidirectional architectures.

For small projects, bidirectional architectures are good because they are easier to understand and do not require the creation and maintenance of unnecessary modules.

Now that we've sorted out the theory, let's look at one of the unidirectional architectures.

MVI – brief history and operating principle

This pattern was first described by JavaScript developer Andre Staltz. General principles can be found Here

In the MVI architecture for the web, it is divided into components as follows:

  • Intent: A function that converts a stream (Observable) of user events into a stream of “actions”. That is, user events (for example, clicks, text input) are converted into actions that the system will process.

  • Model: A function that converts an Observable stream of “actions” into a state stream. That is, processing actions and changing the screen state.

  • View: A function that converts a state stream (Observable) into a render stream. That is, the user interface is updated according to the current screen state.

  • Custom element: View subsection, which is a UI component. They are optional, they may not exist, They can also be built on MVI

MVI uses a reactive approach, where each module functions by waiting for events, processing them, and passing them on to the next module, creating a unidirectional flow.

In the mobile application, the MVI diagram is very similar to the original one (for the web version), with only minor changes:

  • View receives state changes from Model and displays them. Also receives events from the user and sends them to Intent

  • Intent receives events from View and interacts with business logic.

  • Model receives data from the Intent and prepares it for display. Model also stores the current state of the View.

To ensure unidirectional data flow, the View must have a reference to the Intent, the Intent to the Model, and the Model to the View. However, doing this in SwiftUI is a big problem because the View is a struct and the Model cannot directly reference it.

To solve this problem, you can introduce an additional module called Container. The main role of the Container is to maintain references to the Intent and Model and provide accessibility between modules, ensuring unidirectional data flow.

Although this may seem complicated in theory, in practice it is quite simple.

Container implementation

Let's write a screen showing a small list of WWDC videos. I will describe only the basic things, but you can see the full implementation on GitHub.

Let's start with the container. Since this class will be used often, we will write a generic class for all screens.

/* Контейнер предоставит View доступ к свойствам экрана,
   но не даст изменять их напрямую,  только через Intent */
final class MVIContainer<Intent, Model>: ObservableObject {

    let intent: Intent
    let model: Model

    private var cancellable: Set<AnyCancellable> = []

    /* К сожалению, мы не можете указать тип ObjectWillChangePublisher через 
       дженерики, поэтому укажем его с помощью дополнительного свойства */
    init(intent: Intent, model: Model, modelChangePublisher: ObjectWillChangePublisher) {
        self.intent = intent
        self.model = model

        /* Это необходимо для того, чтобы изменения в Model получала View, 
           а не только Container */
        modelChangePublisher
            .receive(on: RunLoop.main)
            .sink(receiveValue: objectWillChange.send)
            .store(in: &cancellable)
    }
}

View

The View initialization will look like this:

/* ListView будет показывать список видео с WWDC */
struct ListView: View{

    @StateObject var container: MVIContainer<ListIntentProtocol, ListModelStatePotocol>

    /* Эти свойства можно не писать, но они упрощают доступ к Intent и View, 
       иначе было бы container.intent и container.state */
    private var intent: ListIntentProtocol { container.intent }
    private var state: ListModelStatePotocol { container.model }

    init() {
        let model = ListModel()
        let intent = ListIntent(model: model)
        let container = MVIContainer(
            intent: intent as ListIntentProtocol,
            model: model as ListModelStatePotocol,
            modelChangePublisher: model.objectWillChange
        )
        self._container = StateObject(wrappedValue: container)
    }

    ...
}

Let's see how View works:

struct ListView: View {

    @StateObject private var container: MVIContainer<ListIntent, ListModelStatePotocol>
    
    /* Эти свойства можно не писать, но они упрощают доступ к Intent и View, 
       иначе было бы container.intent и container.state */
    private var intent: ListIntentProtocol { container.intent }
    private var state: ListModelStatePotocol { container.model }

    ...

    var body: some View {

        /* View получает готовые к отображению данные из Model */
        Text(state.text)
            .padding()
            .onAppear(perform: {

                /* Уведомляет Inten о событиях, происходящих в View */
                intent.viewOnAppear()
            })
    }
}

In this code example, the View receives data from the Model and cannot change it, only show it. The View also notifies the Intent about various events, in this case that the View has become visible. What the Intent will do with this event, the View does not know.

Intent

The Intent waits for events from the View for further actions. It also handles business logic and can fetch data from the database, send requests to the server, etc.

final class ListIntent {

    /* ListModelActionsProtocol скрывает свойства экрана от Intent 
       и дает возможность Intent передавать данные в Model */
    private weak var model: ListModelActionsProtocol?
    
    private let numberService: NumberServiceProtocol

    init(
      model: ListModelActionsProtocol, 
      numberService: NumberServiceProtocol
    ) {
        self.model = model
        self.numberService = numberService
    }

    func viewOnAppear() {
        /* Синхронно или асинхронно получает бизнес-данные */
        numberService.getNumber(completion: { [weak self] number in

          /* После получения данных Intent отправляет данные в Model */
          self?.model?.parse(number: number)
        })
    }
}

In this code example, the viewOnAppear function was called by View, thereby notifying the Intent about the screen show event. The Intent asynchronously received the data and passed it to the Model.

Model

Model receives data from Intetn and prepares it for display. Model also keeps the current state of the screen.

The Model will have two protocols: one for Intent, which allows Intetn to pass data to the Model, and another for View, which provides access to the current screen state. The ObservableObject protocol allows the View to receive data updates reactively.

Let's take a closer look at all this.

/* Через этого протокола Model дает доступ к текущему состоянию экрана. 
   View видит только свойства и не может их менять */
protocol ListModelStatePotocol {

    var text: String { get }
}

/* Через этот протокол Intent может передавать данные в Model. 
   И этот протокол скрывает все свойства экрана от Intent */
protocol ListModelActionsProtocol: AnyObject {

    func parse(number: Int)
}

Implementation Model:

/* Чтобы использовать всю мощь SwiftUI, мы можем использовать 
   ObservableObject. Когда мы будем менять любое свойство, 
   помеченное как @Published, все изменения будет автоматически 
   получать View и отображать их */
final class ListModel: ObservableObject, ListModelStatePotocol {

    @Published var text: String = ""
}

extension ListModel: ListModelActionsProtocol {

    func parse(number: Int) {

      /* Model подготавливает полученные данные к отображению */
      let showText = "Random number: " + String(number)

      /* После подготовки Model обновляет состояние экрана.
         Поскольку свойство text помечено как @Published, View 
         получит данные практически сразу как они изменяться */
      text = showText
    }
}

ps

I wrote about this very briefly and it was possible to skip it, but just in case I’ll write again, MVI can be used not only in screens, but also in buttons, cells, and so on. This is not required, but some may like it.

A question may arise. Why invent a container? Why can't we do it like this:

struct ListView {

    let intent: ListIntentProtocol

    @StateObject var model: ListModel

    ...
}

Work through the ListModelProtocol protocol var model It won’t be able to because @StateObject requires the type to be ObservableObject, and without the protocol the View can change the Model’s data, which breaks the unidirectional data flow. This is the reason why a container is needed.

Briefly about the container. Since View is a structure and Model cannot hold a reference to View, Model logic needed to be split into protocols (one for View, one for Intent). The container holds references to Model and Intant and gives View access to screen properties and will not allow View to change them

Diagrams

Class Diagram

Class Diagram

Sequence Diagram

Sequence Diagram

Conclusion

I described the basic principles of MVI operation; for more detailed information you can look at the project at GitHub.

MVI is a reactive and unidirectional architecture. It allows you to implement complex screens and dynamically change screen states while effectively separating responsibilities. This implementation, of course, is not the only correct one. There are always alternatives, and you can experiment with this architecture, add to it, or simplify it as you see fit. Either way, this architecture pairs well with reactive SwiftUI and helps make working with heavy screens easier.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *