MVI et async/await en Swift : une architecture moderne, simple et robuste

Quand une application grandit (écrans multiples, appels réseau, erreurs à gérer, états complexes), le code peut rapidement devenir un “spaghetti” difficile à maintenir.

Deux idées modernes permettent d’éviter ça en Swift :

  • MVI : une architecture pour organiser la logique de l’interface (Model – View – Intent).
  • async/await : une façon simple d’écrire du code asynchrone (réseau, tâches longues) sans tout rendre illisible.

Combinés, MVI + async/await donnent une architecture solide, propre, professionnelle, très adaptée aux apps SwiftUI modernes.


1. L’idée générale : qui fait quoi ?

Pour bien comprendre, imaginons un écran simple : un profil utilisateur avec :

  • un nom,
  • un bouton “Recharger”,
  • un message d’erreur éventuel,
  • un indicateur de chargement.

Sans architecture, on mélange tout :

  • le code qui dessine l’écran,
  • le code qui appelle le serveur,
  • le code qui gère les erreurs,
  • le code qui décide quoi afficher.

Résultat : difficile de comprendre, difficile à tester, difficile à faire évoluer.

Avec MVI, on sépare les responsabilités :

  • Model (State) : décrit l’état complet de l’écran.
  • View : affiche l’état et envoie les actions de l’utilisateur.
  • Intent : représente ces actions (toucher un bouton, rafraîchir, etc.).

Avec async/await, on écrit le code réseau de façon lisible, sans callbacks compliqués.


2. MVI expliqué simplement (sans jargon)

2.1 L’analogie du restaurant

Imagine une application comme un restaurant :

  • Le client : l’utilisateur de l’app.
  • Le serveur : la View (écran) qui prend les commandes.
  • La commande : une Intent (“recharger”, “ouvrir les détails”, etc.).
  • La cuisine : le Store / ViewModel, qui va chercher les données, applique les règles.
  • Le plat final : le State (état d’écran) qui dit quoi afficher.

Le client ne parle pas directement à la cuisine. Il passe toujours par le serveur (la View), qui transmet l’Intent à la cuisine (le Store).

La cuisine prépare ensuite un nouveau “plat” (un nouvel état) et le serveur l’affiche.

C’est ça MVI :

Intent ? Store (logique + async/await) ? State ? View

3. Définir le State (Model) : ce que l’écran doit afficher

On commence par décrire tout ce dont l’écran a besoin dans une seule structure Swift :

  • sait-on si on charge ?
  • a-t-on un utilisateur ?
  • y a-t-il un message d’erreur ?

struct UserState {
    var isLoading: Bool = false
    var name: String? = nil
    var errorMessage: String? = nil
}

Idée importante : il n’y a qu’un seul “State” qui représente l’écran. La View ne devine rien : elle lit simplement cet état et s’adapte.


4. Définir les Intents : ce que le user peut faire

Une Intent est une action de l’utilisateur ou de l’application :

  • l’écran apparaît,
  • l’utilisateur appuie sur “Recharger”,
  • l’utilisateur tire pour rafraîchir (pull to refresh).

On les définit dans une enum :


enum UserIntent {
    case onAppear
    case refreshTapped
}

Cela rend le flux plus clair : le Store reçoit des Intents et décide quoi faire.


5. Le Store (ViewModel) : cœur de la logique avec async/await

Le Store est la pièce centrale du MVI :

  • il possède le State,
  • il reçoit les Intents,
  • il lance les appels async/await (réseau, base de données…),
  • il met à jour le State en fonction des résultats.

Exemple de Store pour un écran “Profil utilisateur” :


@MainActor
final class UserStore: ObservableObject {

    @Published private(set) var state = UserState()

    private let api: UserAPI

    init(api: UserAPI) {
        self.api = api
    }

    func send(_ intent: UserIntent) {
        switch intent {
        case .onAppear:
            loadUserIfNeeded()
        case .refreshTapped:
            refreshUser()
        }
    }

    private func loadUserIfNeeded() {
        // Ne recharge pas si on a déjà des données
        guard state.name == nil else { return }
        refreshUser()
    }

    private func refreshUser() {
        // Lancement d'une tâche asynchrone
        Task {
            await loadUser()
        }
    }

    private func setLoading(_ isLoading: Bool) {
        state.isLoading = isLoading
        if isLoading {
            state.errorMessage = nil
        }
    }

    private func setUser(name: String) {
        state.name = name
        state.errorMessage = nil
    }

    private func setError(_ message: String) {
        state.errorMessage = message
    }

    private func clearUser() {
        state.name = nil
    }

    private func loadUser() async {
        setLoading(true)
        do {
            let user = try await api.fetchUser()
            setUser(name: user.name)
        } catch {
            clearUser()
            setError("Impossible de charger l'utilisateur.")
        }
        setLoading(false)
    }
}

Points importants (sans jargon) :

  • @MainActor : assure que les mises à jour de l’UI se font sur le bon “fil” (thread principal).
  • @Published private(set) var state : la View peut lire le State, mais seul le Store peut le modifier.
  • send(_ intent:) : point d’entrée unique des actions (Intents).
  • Task { await loadUser() } : on utilise async/await pour charger en arrière-plan.
  • loadUser() : fonction async qui gère le réseau + les erreurs.

6. L’API (couche réseau) : une fonction async claire

On sépare l’accès réseau dans une petite couche dédiée. Cela évite d’avoir du code réseau partout dans le Store.


struct User: Decodable {
    let id: Int
    let name: String
}

protocol UserAPI {
    func fetchUser() async throws -> User
}

final class RealUserAPI: UserAPI {
    func fetchUser() async throws -> User {
        let url = URL(string: "https://example.com/user")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }
}

Ici, on voit bien le bénéfice de async/await :

  • le code ressemble à une suite d’étapes logique,
  • la gestion d’erreur est standard (throws),
  • on garde une fonction courte, lisible.

7. La View (SwiftUI) : simple, déclarative, branchée sur le State

La View affiche juste le State et envoie les Intents. Elle ne fait pas d’appel réseau, elle ne décide pas des règles métiers.


struct UserScreen: View {

    @StateObject private var store: UserStore

    init(api: UserAPI) {
        _store = StateObject(wrappedValue: UserStore(api: api))
    }

    var body: some View {
        let state = store.state

        VStack(spacing: 16) {

            if state.isLoading {
                ProgressView("Chargement…")
            }

            if let name = state.name {
                Text("Bonjour, \(name) ?")
                    .font(.title)
            }

            if let error = state.errorMessage {
                Text(error)
                    .foregroundColor(.red)
            }

            Button("Recharger") {
                store.send(.refreshTapped)
            }
        }
        .padding()
        .onAppear {
            store.send(.onAppear)
        }
    }
}

Ce qu’on remarque :

  • La View lit simplement state pour savoir quoi afficher.
  • Elle réagit à isLoading, name, errorMessage.
  • Les actions utilisateur deviennent des Intent via store.send(...).
  • Aucune logique de réseau ou de parsing JSON dans la Vue : c’est propre.

8. Pourquoi cette architecture est “propre” et professionnelle ?

8.1 Une seule source de vérité pour l’écran

Tout l’état de l’écran est dans UserState. On ne disperse pas des variables partout (“isLoading” dans la View, “error” dans un autre objet, etc.).

8.2 Un flux unidirectionnel

Le sens est toujours le même :

Intent (action) ? Store (logique + async/await) ? nouvel State ? View (affichage)

Pas de raccourcis, pas de modifications cachées : c’est beaucoup plus prévisible.

8.3 async/await rend le code asynchrone lisible

Les appels réseau sont écrits comme une histoire linéaire :

  1. Je mets l’état en “chargement”.
  2. Je fais l’appel réseau.
  3. Je décode la réponse.
  4. Je mets à jour le State selon succès ou échec.

Sans pyramides de callbacks ni logique éclatée.

8.4 Testabilité améliorée

Même si on n’entre pas dans le détail technique des tests ici, cette architecture facilite :

  • les tests du Store en isolant la logique,
  • les tests de la couche réseau via le protocole UserAPI,
  • les simulations (mocker un UserAPI qui renvoie un succès ou une erreur).

Pour une équipe professionnelle, c’est un atout majeur.


9. Vue d’ensemble : comment tout se connecte

Résumons la structure :

  • UserState : décrit ce que l’écran doit afficher (chargement / données / erreur).
  • UserIntent : liste ce que l’utilisateur ou l’écran peuvent déclencher.
  • UserStore : reçoit les Intents, appelle l’API via async/await, met à jour le State.
  • UserAPI : encapsule l’accès réseau.
  • UserScreen (View SwiftUI) : observe le State et envoie des Intents.

On obtient une architecture :

  • claire (les responsabilités sont séparées),
  • prévisible (flux unidirectionnel),
  • moderne (Swift concurrent, SwiftUI),
  • professionnelle (testable, extensible).

10. Conclusion pour les non techniques

MVI + async/await en Swift, ce n’est pas “juste un autre buzzword d’architecture”.

Concrètement, cela signifie :

  • des écrans qui ont un état clair et centralisé,
  • un code asynchrone (réseau, données distantes) facile à lire et à maintenir,
  • moins d’effet “boîte noire” pour les développeurs,
  • une meilleure stabilité globale de l’application.

Pour un product owner, un manager ou toute personne non technique, retenir ceci suffit :

  • MVI organise la manière dont l’écran réagit aux actions et aux données.
  • async/await permet d’attendre des opérations longues sans bloquer l’app, avec un code lisible.
  • Ensemble, ils donnent une architecture solide, propre, évolutive pour les apps Swift modernes.

C’est ce type d’approche qui distingue une petite app bricolée d’une application pensée pour durer dans le temps.