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
- Pourquoi une architecture ?
- MVVM : ViewModel, LiveData, data binding
- Clean Architecture : use cases, repositories, data sources
- MVI : intent, state, side effects
- Multi-module Gradle
- Modularisation iOS
- Comparaison des architectures
- State management
- Injection de dépendances
- 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
Activityde 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
| Objectif | Description |
|---|---|
| Testabilité | Chaque brique peut être testée isolément |
| Maintenabilité | On ajoute une fonctionnalité sans casser les autres |
| Séparation des responsabilités | UI ≠ 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
- S — Single Responsibility : une classe = une raison de changer.
- O — Open/Closed : ouvert à l'extension, fermé à la modification.
- L — Liskov : les sous-classes sont substituables à leur classe mère.
- I — Interface Segregation : des interfaces fines plutôt qu'une interface fourre-tout.
- 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 :
- Préparer l'état pour l'UI (transformer les données du modèle en données affichables —
UiState). - 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ère | LiveData | StateFlow | Compose mutableStateOf |
|---|---|---|---|
| Lifecycle-aware | Oui | Non (mais collectAsStateWithLifecycle) | Oui (Compose) |
| Interop XML | Excellente | Médiocre | N/A |
| Idéal pour | Legacy XML | Kotlin / coroutines | Compose pure |
| Flux de données | Froid | Chaud (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 combinaisonflutter_blocavecBlocBuilder. Le ViewModel équivaut auBloc/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
| Couche | Rôle | Dépend de |
|---|---|---|
| Présentation | UI, ViewModels, navigation | Domain |
| Domain | Entités métier, use cases, interfaces de repositories | Rien (pur Kotlin/Swift/Dart) |
| Data | Implémentation des repositories, API, base de données | Domain |
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
| Avantages | Inconvénients |
|---|---|
| Séparation stricte des responsabilités | Beaucoup de boilerplate (interfaces + implémentations) |
| Testabilité maximale | Risque de sur-ingénierie sur un petit projet |
| Indépendance aux frameworks | Plus 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ère | MVVM | MVI |
|---|---|---|
| Flux de données | Bidirectionnel (View ⇄ ViewModel) | Unidirectionnel (Intent → State) |
| État | Souvent fragmenté (LiveData par champ) | Un seul objet immuable |
| Déterministe | Partiellement | Totalement |
| Debug | Moyen | Excellent (rejouable) |
| Verbosité | Modérée | Importante |
| Courbe d'apprentissage | Faible | Moyenne |
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
| Module | Contenu | Dépend de |
|---|---|---|
:app | Entry point, navigation graph, DI racine | Tous les features |
:core:ui | Thème, composants UI partagés | :core:model |
:core:model | DTOs, entités | Rien |
:core:network | Retrofit/OkHttp, API | :core:model |
:feature:<name> | Écran, ViewModel, navigation interne | :domain, :core:ui |
:data | Implémentations repositories | :domain, :core:network, :core:database |
:domain | Entités, use cases, interfaces | Rien |
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(modulefeature:homen'expose que son composableHomeRoute()). - Exposer le minimum (
public= contrat public du module).
5.6 Quels projets modulariser ?
| Taille du projet | Recommandation |
|---|---|
| < 10 écrans, équipe de 1-3 | Monomodule + packages bien organisés |
| 10-30 écrans, équipe de 3-8 | 4-6 modules (app, core:ui, domain, data, feature:x) |
| > 30 écrans, plusieurs équipes | Modularisation fine par feature |
6. Modularisation iOS
6.1 Les options
| Outil | Description |
|---|---|
| Frameworks (Xcode projects) | .framework / .xcframework, configurable dans Xcode |
| Swift Package Manager (SPM) | Standard moderne, déclaratif via Package.swift |
| CocoaPods | Hé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ère | MVC | MVVM | MVI | VIPER |
|---|---|---|---|---|
| Couche logique | Controller | ViewModel | ViewModel | Interactor + Presenter |
| Flux | Bidirectionnel | Bidirectionnel | Unidirectionnel | Unidirectionnel |
| Testabilité | Faible | Bonne | Excellente | Excellente |
| Boilerplate | Faible | Moyen | Moyen | Élevé |
| Adapté iOS UIKit | ✅ (Apple) | ✅ (SwiftUI) | 🔶 | ✅ (gros projets) |
| Adapté Android | ❌ | ✅ | ✅ | ❌ |
| Debug des états | ❌ | Moyen | ✅ | Moyen |
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'état | Exemple | Où ? |
|---|---|---|
| Local à l'écran | Champ de recherche | ViewModel / composant |
| Global applicatif | Session utilisateur, thème | Store central (Redux, Riverpod, Singleton DI) |
| Serveur | Liste des tickets | Cache (repository), synchronisé |
8.2 Panorama par plateforme
| Plateforme | Local | Global |
|---|---|---|
| Android | ViewModel + StateFlow | Hilt singletons, SharedFlow d'événements globaux |
| iOS | @State, ObservableObject | ObservableObject partagés (DI singletons) |
| Flutter | StatefulWidget, Riverpod | Riverpod providers, Bloc |
| React Native | Hooks useState | Redux 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
SavedStateHandleourememberSaveable. - iOS :
@SceneStorage/@AppStoragepour 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ère | Hilt | Koin |
|---|---|---|
| Compile-time | Oui (génère du code) | Non (runtime) |
| Performance | Meilleure | Slight overhead |
| Simplicité | Modérée (annotations) | Très simple |
| Crash à l'exécution si erreur | Non (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) ouriverpod(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-pattern | Symptôme | Correctif |
|---|---|---|
| God Activity/Controller | 1000+ lignes d'UI + logique | Sortir ViewModel + use cases |
| Direct-API-in-UI | Retrofit dans la View | Passer par repository |
| String keys partout | "error.not_found" en dur | Ressources typées |
| Magie globale | Singletons mutables | DI + scoping |
| Callback hell | Callbacks imbriqués | Coroutines / 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 (
internalpar 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 :
- L'architecture est un outil de communication entre développeurs.
- Le sens des dépendances est descendant et passe par des interfaces.
- MVI/MVVM se valent : ce qui compte, c'est l'état immuable et les side effects séparés.
- La modularisation doit servir le temps de build et le couplage, pas la mode.
- 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.