MFormations
Modern Mobile Engineering

Chapitre 10

10 - Architecture Mobile

10 - Architecture Mobile

10 - Architecture Mobile : Cours complet

Niveau : Intermédiaire → Avancé Durée estimée : 6 h Objectif : maîtriser les architectures modernes pour concevoir des applications Android, iOS et cross-platform maintenables et testables.


Table des matières

  1. Pourquoi une architecture ?
  2. MVVM : ViewModel, LiveData, data binding
  3. Clean Architecture : use cases, repositories, data sources
  4. MVI : intent, state, side effects
  5. Multi-module Gradle
  6. Modularisation iOS
  7. Comparaison des architectures
  8. State management
  9. Injection de dépendances
  10. Layering et bonnes pratiques

1. Pourquoi une architecture ?

1.1 Le problème

Sans architecture, une application mobile évolue vers un « Big Ball of Mud » : les Activités/ViewControllers s'allongent, le code devient dupliqué, les tests sont impossibles et chaque nouvelle fonctionnalité est un risque de régression.

Les symptômes classiques :

  • God Object : une Activity de 2000 lignes qui gère le réseau, la base de données, le rendu et la navigation.
  • Duplication : les appels API copiés dans chaque écran.
  • Couplage fort : changer la base de données implique de réécrire l'UI.
  • Tests impossibles : impossible d'instancier une View sans émulateur.

1.2 Les objectifs d'une bonne architecture

ObjectifDescription
TestabilitéChaque brique peut être testée isolément
MaintenabilitéOn ajoute une fonctionnalité sans casser les autres
Séparation des responsabilitésUI ≠ logique métier ≠ données
ÉvolutivitéOn peut changer une brique sans toucher au reste
LisibilitéUn nouveau développeur comprend le projet en 1 jour

1.3 Le principe directeur : la dépendance unidirectionnelle

Règle d'or : les dépendances pointent toujours vers l'abstraction, jamais vers l'implémentation.

Diagramme en cours de génération...

L'UI dépend du ViewModel, le ViewModel dépend des use cases, les use cases dépendent des repositories, les repositories dépendent des data sources. Le sens des flèches est descendant : la couche supérieure connaît l'inférieure via des interfaces, jamais des classes concrètes.

1.4 Les principes SOLID appliqués au mobile

  1. S — Single Responsibility : une classe = une raison de changer.
  2. O — Open/Closed : ouvert à l'extension, fermé à la modification.
  3. L — Liskov : les sous-classes sont substituables à leur classe mère.
  4. I — Interface Segregation : des interfaces fines plutôt qu'une interface fourre-tout.
  5. D — Dependency Inversion : dépendre des abstractions, pas des concrétions.
Diagramme en cours de génération...

2. MVVM : ViewModel, LiveData, data binding

2.1 Principe

MVVM (Model-View-ViewModel) est l'architecture de référence d'Android depuis 2017 et se retrouve aussi sous SwiftUI/Combine.

  • View : affiche les données, écoute l'état, transmet les événements utilisateur.
  • ViewModel : expose l'état via des flux observables, exécute la logique d'UI, survit à la rotation.
  • Model : les données et la logique métier (repositories, use cases).
Diagramme en cours de génération...

2.2 La View est « bête »

La View ne contient aucune logique : elle ne fait que

  • afficher l'état,
  • réagir aux actions.

Si vous devez écrire un if dans la View pour décider du contenu d'un champ, c'est que la logique devrait être dans le ViewModel.

2.3 Le ViewModel

Le ViewModel a deux responsabilités :

  1. Préparer l'état pour l'UI (transformer les données du modèle en données affichables — UiState).
  2. Traduire les actions utilisateur en appels au modèle.

Android — ViewModel + LiveData :

class HomeViewModel(
    private val getTickets: GetTicketsUseCase
) : ViewModel() {

    private val _uiState = MutableLiveData<HomeUiState>()
    val uiState: LiveData<HomeUiState> = _uiState

    fun loadTickets() {
        viewModelScope.launch {
            _uiState.value = HomeUiState.Loading
            _uiState.value = getTickets().fold(
                onSuccess = { HomeUiState.Success(it) },
                onFailure = { HomeUiState.Error(it.message) }
            )
        }
    }
}

sealed interface HomeUiState {
    data object Loading : HomeUiState
    data class Success(val tickets: List<Ticket>) : HomeUiState
    data class Error(val message: String?) : HomeUiState
}

2.4 LiveData vs StateFlow vs Compose state

CritèreLiveDataStateFlowCompose mutableStateOf
Lifecycle-awareOuiNon (mais collectAsStateWithLifecycle)Oui (Compose)
Interop XMLExcellenteMédiocreN/A
Idéal pourLegacy XMLKotlin / coroutinesCompose pure
Flux de donnéesFroidChaud (hot)Chaud

Recommandation 2026 : en Jetpack Compose, préférer StateFlow + collectAsStateWithLifecycle(). Réserver LiveData au code XML existant.

2.5 Data binding (Android XML)

Le data binding lie directement le layout à la source de données :

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable name="vm" type="com.app.ui.HomeViewModel" />
    </data>
    <TextView
        android:text="@{vm.uiState.loading ? @string/loading : @string/ready}" />
</layout>

2.6 iOS — MVVM avec Combine / SwiftUI

final class HomeViewModel: ObservableObject {
    @Published var state: HomeUiState = .loading

    func loadTickets() async {
        state = .loading
        do {
            let tickets = try await getTickets.execute()
            state = .success(tickets)
        } catch {
            state = .error(error.localizedDescription)
        }
    }
}

Avec SwiftUI, la View est déclarative et observe le ViewModel via @StateObject / @ObservedObject.

2.7 MVVM côté Flutter et React Native

  • Flutter : le package provider + ChangeNotifier, ou la combinaison flutter_bloc avec BlocBuilder. Le ViewModel équivaut au Bloc / ChangeNotifier.
  • React Native : la librairie la plus proche du MVVM est MobX (stores observables) ou Redux Toolkit (état + reducers). Les hooks React (useState, useReducer) servent aussi de couche ViewModel légère.

3. Clean Architecture : use cases, repositories, data sources

3.1 Le modèle en cercles concentriques

Proposée par Robert C. Martin (Uncle Bob), la Clean Architecture organise le code en cercles :

Diagramme en cours de génération...

Les règles internes ne dépendent jamais des règles externes.

3.2 Les couches

CoucheRôleDépend de
PrésentationUI, ViewModels, navigationDomain
DomainEntités métier, use cases, interfaces de repositoriesRien (pur Kotlin/Swift/Dart)
DataImplémentation des repositories, API, base de donnéesDomain

3.3 Use cases

Un use case (ou interactor) encapsule une action métier unique.

Exemple — GetTicketsUseCase :

class GetTicketsUseCase(
    private val repository: TicketRepository
) {
    suspend operator fun invoke(): Result<List<Ticket>> =
        repository.getTickets()
}

Points clés :

  • Un use case = une seule opération (getTickets, createTicket, login…).
  • Il orchestre les repositories et applique la logique métier.
  • Il ne connaît ni l'UI ni les frameworks.

3.4 Repositories et data sources

Le repository est une façade qui expose des données au domaine, en masquant l'origine :

interface TicketRepository {
    suspend fun getTickets(): Result<List<Ticket>>
    suspend fun saveTicket(ticket: Ticket): Result<Unit>
}

class TicketRepositoryImpl(
    private val remoteSource: TicketRemoteDataSource,
    private val localSource: TicketLocalDataSource
) : TicketRepository {

    override suspend fun getTickets(): Result<List<Ticket>> {
        return remoteSource.fetchTickets()
            .onSuccess { localSource.saveAll(it) }
            .recoverCatching { localSource.getAll() } // cache
    }
}

Le domaine définit l'interface TicketRepository. La couche Data fournit l'implémentation. L'UI ne sait jamais d'où viennent les données.

3.5 Cycle de dépendances : la règle qui garantit tout

La couche interne ne peut pas importer de classe de la couche externe. En revanche, la couche externe peut implémenter les interfaces internes.

Concrètement, le TicketRepositoryImpl (couche Data) implémente TicketRepository (définie dans Domain). Le ViewModel appelle TicketRepository via l'interface. Au runtime, Hilt/Koin/Swinject injecte TicketRepositoryImpl. C'est l'inversion de dépendance (DIP).

3.6 Avantages et inconvénients

AvantagesInconvénients
Séparation stricte des responsabilitésBeaucoup de boilerplate (interfaces + implémentations)
Testabilité maximaleRisque de sur-ingénierie sur un petit projet
Indépendance aux frameworksPlus de fichiers, courbe d'apprentissage
Migrations facilitées (API, DB)Nécessite de la discipline

4. MVI : intent, state, side effects

4.1 Principe

MVI (Model-View-Intent) applique l'architecture Unidirectional Data Flow (UDF) :

Diagramme en cours de génération...
  • Intent : l'action utilisateur (ou un événement système), décrite par un sealed class.
  • Model : l'état immuable complet de l'écran.
  • View : rend l'état ; émet des intents.

4.2 L'état immuable (UiState)

L'état est un objet immuable représentant tout ce que l'écran affiche :

data class HomeUiState(
    val isLoading: Boolean = false,
    val tickets: List<Ticket> = emptyList(),
    val errorMessage: String? = null,
    val isRefreshing: Boolean = false
)

Une seule source de vérité, un rendu déterministe : pour un état donné, l'UI est toujours identique.

4.3 Intent (sealed class)

sealed interface HomeIntent {
    data object LoadTickets : HomeIntent
    data object Refresh : HomeIntent
    data class TicketClicked(val id: String) : HomeIntent
}

Le ViewModel « réduit » chaque intent pour produire un nouvel état :

fun onIntent(intent: HomeIntent) {
    when (intent) {
        HomeIntent.LoadTickets -> load()
        HomeIntent.Refresh -> refresh()
        is HomeIntent.TicketClicked -> _events.emit(NavigateToDetail(intent.id))
    }
}

4.4 Side effects (événements one-shot)

Tout ce qui n'est pas de l'état (navigation, toast, snackbar) est un side effect. Il est émis une seule fois, distinct de l'état :

private val _events = MutableSharedFlow<HomeEvent>()
val events: SharedFlow<HomeEvent> = _events.asSharedFlow()

sealed interface HomeEvent {
    data class NavigateToDetail(val ticketId: String) : HomeEvent
    data object ShowToastNetworkError : HomeEvent
}

Pourquoi ? Parce qu'un LiveData<Boolean> ou un champ dans le UiState serait rejoué après une rotation (le toast réapparaît au mauvais moment).

4.5 Exemple Android complet (MVI)

class HomeViewModel(
    private val getTickets: GetTicketsUseCase
) : ViewModel() {

    private val _state = MutableStateFlow(HomeUiState())
    val state: StateFlow<HomeUiState> = _state.asStateFlow()

    private val _events = MutableSharedFlow<HomeEvent>()
    val events: SharedFlow<HomeEvent> = _events.asSharedFlow()

    fun onIntent(intent: HomeIntent) {
        when (intent) {
            HomeIntent.LoadTickets, HomeIntent.Refresh -> loadTickets()
            is HomeIntent.TicketClicked -> viewModelScope.launch {
                _events.emit(HomeEvent.NavigateToDetail(intent.id))
            }
        }
    }

    private fun loadTickets() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, errorMessage = null) }
            getTickets().fold(
                onSuccess = { tickets ->
                    _state.update { it.copy(isLoading = false, tickets = tickets) }
                },
                onFailure = { error ->
                    _state.update { it.copy(isLoading = false, errorMessage = error.message) }
                }
            )
        }
    }
}

4.6 MVI vs MVVM

CritèreMVVMMVI
Flux de donnéesBidirectionnel (View ⇄ ViewModel)Unidirectionnel (Intent → State)
ÉtatSouvent fragmenté (LiveData par champ)Un seul objet immuable
DéterministePartiellementTotalement
DebugMoyenExcellent (rejouable)
VerbositéModéréeImportante
Courbe d'apprentissageFaibleMoyenne

Conseil : commencer en MVVM, évoluer vers MVI (ou « MVVM + UiState immuable », un MVI allégé) dès que l'écran devient complexe.


5. Multi-module Gradle

5.1 Pourquoi modulariser ?

Diagramme en cours de génération...

Avantages : compilation incrémentale, parallélisation du build, couplage maîtrisé, testabilité, réutilisation.

5.2 Les modules types

ModuleContenuDépend de
:appEntry point, navigation graph, DI racineTous les features
:core:uiThème, composants UI partagés:core:model
:core:modelDTOs, entitésRien
:core:networkRetrofit/OkHttp, API:core:model
:feature:<name>Écran, ViewModel, navigation interne:domain, :core:ui
:dataImplémentations repositories:domain, :core:network, :core:database
:domainEntités, use cases, interfacesRien

5.3 Convention de nommage

app
├── core
│   ├── model
│   ├── ui
│   ├── network
│   └── database
├── data
├── domain
└── feature
    ├── auth
    ├── home
    └── tickets

5.4 Publication de contraintes Gradle

Pour verrouiller les dépendances entre modules :

// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
plugins {
    id("com.android.library")
}

configurations.all {
    resolutionStrategy {
        eachDependency {
            if (requested.group == "org.jetbrains.kotlinx") {
                useVersion("2.0.0")
            }
        }
    }
}

L'API Gradle version catalogs (libs.versions.toml) centralise les versions :

[versions]
retrofit = "2.11.0"
agp = "8.5.0"

[libraries]
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }

5.5 Accesseurs (internal) et API minimale

  • Tout ce qui est interne à un module : internal (module feature:home n'expose que son composable HomeRoute()).
  • Exposer le minimum (public = contrat public du module).

5.6 Quels projets modulariser ?

Taille du projetRecommandation
< 10 écrans, équipe de 1-3Monomodule + packages bien organisés
10-30 écrans, équipe de 3-84-6 modules (app, core:ui, domain, data, feature:x)
> 30 écrans, plusieurs équipesModularisation fine par feature

6. Modularisation iOS

6.1 Les options

OutilDescription
Frameworks (Xcode projects).framework / .xcframework, configurable dans Xcode
Swift Package Manager (SPM)Standard moderne, déclaratif via Package.swift
CocoaPodsHéritage, de plus en plus abandonné au profit de SPM

6.2 Swift Package Manager

// Package.swift
// swift-tools-version: 5.10
import PackageDescription

let package = Package(
    name: "TaskFlow",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "Domain", targets: ["Domain"]),
        .library(name: "Data", targets: ["Data"]),
        .library(name: "FeatureHome", targets: ["FeatureHome"])
    ],
    dependencies: [
        .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.9.0")
    ],
    targets: [
        .target(name: "Domain"),
        .target(name: "Data", dependencies: ["Domain"]),
        .target(
            name: "FeatureHome",
            dependencies: ["Domain", "Data", "Alamofire"]
        ),
        .testTarget(
            name: "DomainTests",
            dependencies: ["Domain"]
        )
    ]
)

6.3 Le layout type d'un projet iOS modulaire

TaskFlow.xcodeproj
├── App/                        (Application layer : AppDelegate, scenes)
├── Features/
│   ├── Home/
│   │   ├── HomeView.swift
│   │   ├── HomeViewModel.swift
│   │   └── HomeRouter.swift
│   └── Auth/
├── Domain/
│   ├── Entities/
│   ├── UseCases/
│   └── RepositoryProtocols/
├── Data/
│   ├── Repositories/
│   └── Remote/
└── Core/
    ├── Networking/
    └── DesignSystem/

6.4 Frameworks et cycles d'import

Les frameworks imposent des cycles de dépendances explicites : FeatureHome importe Domain mais Domain n'importe rien. Un import cyclique est une erreur de compilation — c'est une contrainte bénéfique qui force une bonne architecture.

6.5 @testable import et tests

@testable import Domain
import XCTest

final class GetTicketsUseCaseTests: XCTestCase {
    func testSuccessReturnsTickets() async throws {
        let repo = MockTicketRepository(result: .success([Ticket(id: "1")]))
        let useCase = GetTicketsUseCase(repository: repo)
        let tickets = try await useCase.execute()
        XCTAssertEqual(tickets.count, 1)
    }
}

7. Comparaison des architectures

7.1 Vue d'ensemble

Diagramme en cours de génération...

7.2 Tableau comparatif

CritèreMVCMVVMMVIVIPER
Couche logiqueControllerViewModelViewModelInteractor + Presenter
FluxBidirectionnelBidirectionnelUnidirectionnelUnidirectionnel
TestabilitéFaibleBonneExcellenteExcellente
BoilerplateFaibleMoyenMoyenÉlevé
Adapté iOS UIKit✅ (Apple)✅ (SwiftUI)🔶✅ (gros projets)
Adapté Android
Debug des étatsMoyenMoyen

7.3 Comment choisir ?

  • Android (Kotlin + Compose) : MVVM + UiState (recommandé) ou MVI pour les écrans complexes.
  • iOS (SwiftUI) : MVVM avec ObservableObject (pattern le plus idiomatique).
  • iOS (UIKit legacy) : MVC Apple est acceptable pour les écrans simples ; VIPER pour les grosses apps legacy.
  • Flutter : Bloc (proche MVI) ou Riverpod (proche MVVM).
  • React Native : Redux Toolkit / Zustand (état global) + hooks (état local).

8. State management

8.1 État local vs global vs serveur

Type d'étatExempleOù ?
Local à l'écranChamp de rechercheViewModel / composant
Global applicatifSession utilisateur, thèmeStore central (Redux, Riverpod, Singleton DI)
ServeurListe des ticketsCache (repository), synchronisé

8.2 Panorama par plateforme

PlateformeLocalGlobal
AndroidViewModel + StateFlowHilt singletons, SharedFlow d'événements globaux
iOS@State, ObservableObjectObservableObject partagés (DI singletons)
FlutterStatefulWidget, RiverpodRiverpod providers, Bloc
React NativeHooks useStateRedux Toolkit, Zustand, Jotai, MobX

8.3 React Native — Redux Toolkit

// store.ts
import { createSlice, configureStore, createAsyncThunk } from '@reduxjs/toolkit';

const ticketsSlice = createSlice({
  name: 'tickets',
  initialState: { items: [], status: 'idle' },
  reducers: {
    ticketAdded: (state, action) => { state.items.push(action.payload); },
  },
});

export const store = configureStore({
  reducer: { tickets: ticketsSlice.reducer },
});

8.4 Flutter — Riverpod

final ticketsProvider = FutureProvider<List<Ticket>>((ref) async {
  final repo = ref.watch(ticketRepositoryProvider);
  return repo.getTickets();
});

class HomeScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncTickets = ref.watch(ticketsProvider);
    return asyncTickets.when(
      data: (tickets) => ListView.builder(
        itemCount: tickets.length,
        itemBuilder: (context, i) => Text(tickets[i].title),
      ),
      error: (e, st) => Text('Erreur : $e'),
      loading: () => const CircularProgressIndicator(),
    );
  }
}

8.5 Persistance de l'état et survie aux changements de configuration

  • Android : le ViewModel survit à la rotation. Pour le process death, utiliser SavedStateHandle ou rememberSaveable.
  • iOS : @SceneStorage / @AppStorage pour les petits états ; le reste dépend du cycle de vie du processus.

9. Injection de dépendances

9.1 Pourquoi la DI ?

  • Découplage (via interfaces),
  • testabilité (mock des dépendances),
  • cycles de vie contrôlés (singleton, scoped, factory).

9.2 Hilt (Android, basé sur Dagger)

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .baseUrl("https://api.taskflow.app/")
            .client(client)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
}

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getTickets: GetTicketsUseCase
) : ViewModel()

9.3 Koin (Android, Kotlin DSL)

val appModule = module {
    single<OkHttpClient> { OkHttpClient.Builder().build() }
    single { provideRetrofit(get()) }
    single<TicketRepository> { TicketRepositoryImpl(get(), get()) }
    factory { GetTicketsUseCase(get()) }
    viewModel { HomeViewModel(get()) }
}
CritèreHiltKoin
Compile-timeOui (génère du code)Non (runtime)
PerformanceMeilleureSlight overhead
SimplicitéModérée (annotations)Très simple
Crash à l'exécution si erreurNon (vérifié à la compilation)Oui

9.4 Swinject (iOS)

import Swinject

let container = Container()
container.register(Networking.self) { _ in URLSessionNetworking() }
container.register(TicketRepository.self) { r in
    TicketRepositoryImpl(remote: r.resolve(Networking.self)!)
}
container.register(HomeViewModel.self) { r in
    HomeViewModel(repository: r.resolve(TicketRepository.self)!)
}

let vm = container.resolve(HomeViewModel.self)!

Alternative iOS moderne : la DI « à la main » — un AppContainer / AppEnvironment qui construit l'arbre de dépendances, ou la nouvelle macros @Observable + constructeurs explicites. Swift 5.9+ dispose de l'injection par macros (à l'étude).

9.5 DI en cross-platform

  • Flutter : get_it (service locator) ou riverpod (providers = DI intégrée).
  • React Native : aucun standard ; on utilise des singletons, des contexts ou une lib type tsyringe.

10. Layering et bonnes pratiques

10.1 La règle des 4 couches mentales

UI ───► Logique (ViewModel) ───► Domaine (UseCases) ───► Data

Ne jamais sauter de couche « vers le haut » : un ViewModel ne doit pas appeler Retrofit directement. La Data ne doit pas importer de classes d'UI.

10.2 Anti-patterns à éviter

Anti-patternSymptômeCorrectif
God Activity/Controller1000+ lignes d'UI + logiqueSortir ViewModel + use cases
Direct-API-in-UIRetrofit dans la ViewPasser par repository
String keys partout"error.not_found" en durRessources typées
Magie globaleSingletons mutablesDI + scoping
Callback hellCallbacks imbriquésCoroutines / async-await / Flows

10.3 Checklist de révision d'architecture

  • L'UI est « bête » (aucune logique métier dans les composants)
  • Le ViewModel n'importe aucun framework d'UI (sauf androidx.lifecycle)
  • Les use cases sont purs et testables sans émulateur
  • Les repositories sont des interfaces dans Domain, implémentées dans Data
  • La navigation est un side effect (jamais dans l'état)
  • Les singletons sont injectés via le conteneur DI
  • Les dépendances pointent vers les abstractions
  • Chaque module expose une API minimale (internal par défaut)
  • Les tests unitaires couvrent les use cases et les ViewModels
  • On peut changer Retrofit → Ktor sans toucher à l'UI

10.4 Le point de vue industriel

En 2026, l'écosystème converge vers un standard de fait :

  • Kotlin multiplatform (KMP) popularise l'architecture « partagée » : UI par plateforme, logique et données partagées dans un module commun.
  • Compose Multiplatform et SwiftUI rendent les UI déclaratives, ce qui favorise l'UDF/MVI.
  • Les architectures deviennent des recommandations organisationnelles plus que des frameworks : le succès vient de la discipline, pas du nom du pattern.

11. Synthèse

Diagramme en cours de génération...

Points à retenir :

  1. L'architecture est un outil de communication entre développeurs.
  2. Le sens des dépendances est descendant et passe par des interfaces.
  3. MVI/MVVM se valent : ce qui compte, c'est l'état immuable et les side effects séparés.
  4. La modularisation doit servir le temps de build et le couplage, pas la mode.
  5. La DI est non négociable pour la testabilité.

Aller plus loin

  • Chapitre 11 (Data-Persistence) : matérialiser les repositories avec Room/Core Data.
  • Chapitre 13 (Testing-Mobile) : tester ViewModels et use cases.
  • Chapitre 18 (Projet-Fil-Rouge) : une architecture MVVM + Clean Architecture complète.