Maîtriser les Patterns Architecturaux en Swift : De MVVM à la Réactivité
Découvrez comment structurer vos applications iOS avec des patterns professionnels éprouvés et implémentez une architecture réactive performante. Passez du code spaghetti à une base solide, maintenable et testable.
1. MVVM : Fondamentaux et Implémentation Pratique
Définition
MVVM (Model-View-ViewModel) est un pattern architectural qui sépare la logique présentation de la logique métier en trois couches distinctes : le Model contient les données, la View affiche l'interface, et le ViewModel expose les données transformées et les actions utilisateur.
Explication
En Swift, MVVM résout le problème des ViewControllers massifs en transférant la logique métier vers le ViewModel. Cette séparation permet une testabilité accrue car le ViewModel ne dépend pas de UIKit. Le ViewModel agit comme un intermédiaire qui prépare les données du Model pour la View, appliquant transformations et validations nécessaires. Dans une application réelle, vous utiliseriez un ViewModel pour récupérer des données d'une API, les transformer, les valider, puis les exposer à la View via des propriétés observables.
// Model
struct User {
let id: Int
let firstName: String
let lastName: String
let email: String
}
// ViewModel
class UserProfileViewModel {
@Published var displayName: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
private let userService: UserService
private var cancellables = Set<AnyCancellable>()
init(userService: UserService = .shared) {
self.userService = userService
}
func loadUser(id: Int) {
isLoading = true
errorMessage = nil
userService.fetchUser(id: id)
.map { user in
"\(user.firstName) \(user.lastName)"
}
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] displayName in
self?.displayName = displayName
}
)
.store(in: &cancellables)
}
}
// View
struct UserProfileView: View {
@StateObject private var viewModel = UserProfileViewModel()
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.errorMessage {
Text("Erreur: \(error)")
.foregroundColor(.red)
} else {
Text(viewModel.displayName)
.font(.title)
}
}
.onAppear {
viewModel.loadUser(id: 1)
}
}
}
| Aspect | Avantage | Inconvénient |
|---|---|---|
| Testabilité | ViewModel testé sans UIKit | Courbe apprentissage initiale |
| Maintenabilité | Code organisé et modulaire | Plus de fichiers à gérer |
| Réutilisabilité | ViewModel partageant UIKit | Binding complexe parfois |
| Performance | Logic isolée de la UI | Observable overhead |
Astuce Pro
Utilisez @Published avec eraseToAnyPublisher() pour masquer les détails d'implémentation du ViewModel et faciliter les tests. Cela vous permet de changer la source observable sans affecter la View.
⚠️ Attention
Ne mélangez jamais la logique métier avec le binding UIKit. Si vous écrivez URLSession directement dans le ViewModel, vous créez un couplage fort qui rend les tests difficiles. Injectez toujours les dépendances (UserService) via l'initialiser.
2. Combine et la Programmation Réactive en Swift
Définition
Combine est le framework Apple pour la programmation réactive en Swift. Il fournit une API déclarative pour composer des transformations asynchrones et gérer les flux de données à travers publishers et subscribers avec gestion automatique de la mémoire.
Explication
La programmation réactive change votre façon de penser aux données : au lieu de demander "donne-moi les données maintenant", vous dites "je m'intéresse aux changements de ces données dans le temps". Combine implémente le pattern Observer de manière élégante. Les Publishers émettent des valeurs, les Operators les transforment, et les Subscribers les consomment. Ce paradigme rend le code asynchrone beaucoup plus lisible qu'avec les callbacks ou les delegates traditionnels.
// Exemple : Recherche utilisateur réactive
class SearchViewModel: ObservableObject {
@Published var searchText: String = ""
@Published var searchResults: [User] = []
@Published var isSearching: Bool = false
private let userService: UserService
private var cancellables = Set<AnyCancellable>()
init(userService: UserService = .shared) {
self.userService = userService
setupSearchBinding()
}
private func setupSearchBinding() {
$searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.handleEvents(receiveOutput: { [weak self] _ in
self?.isSearching = true
})
.flatMap { [weak self] query -> AnyPublisher<[User], Never> in
guard let self = self else { return Empty().eraseToAnyPublisher() }
return self.userService.search(query: query)
.catch { _ in Just([]).eraseToAnyPublisher() }
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.handleEvents(receiveOutput: { [weak self] _ in
self?.isSearching = false
})
.assign(to: &$searchResults)
}
}
// Exemple : Validation de formulaire
class FormViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published var isFormValid: Bool = false
init() {
Publishers.CombineLatest(
$email.map { $0.contains("@") },
$password.map { $0.count >= 8 }
)
.assign(to: &$isFormValid)
}
}
| Opérateur | Utilité | Cas d'Usage |
|---|---|---|
debounce |
Délai avant émission | Recherche utilisateur |
flatMap |
Transformation asynchrone | Appels API multiples |
CombineLatest |
Fusion de publishers | Validation formulaire |
filter |
Filtrage conditionnel | Émission sélective |
map |
Transformation de valeur | Conversion de type |
Astuce Pro
Utilisez assign(to:) avec la syntaxe &$published au lieu de sink() pour éviter de gérer manuellement les AnyCancellable. C'est plus lisible et moins sujet aux fuites mémoire.
⚠️ Attention
flatMap peut créer des appels API non désirés à chaque changement. Combinez toujours avec debounce et removeDuplicates pour les recherches. Oubliez cela et votre API sera bombardée de requêtes.
3. Dependency Injection : Architecture Découpée et Testable
Définition
La Dependency Injection (DI) est un pattern où les dépendances d'une classe sont passées en paramètre plutôt que créées internement. Cela crée un couplage faible, facilite les tests avec des mocks, et rend le code plus flexible et maintenable.
Explication
Au lieu que votre ViewModel crée lui-même le UserService, vous le lui passez à l'initialisation. Cela signifie que en production vous passez le vrai service, mais en test vous passez un mock. C'est une distinction subtile mais extrêmement puissante pour la qualité logicielle. La DI rend également votre code plus testable car chaque composant peut fonctionner indépendamment. En pratique, vous verrez souvent un "Container" qui gère l'instanciation de tous les services de l'application.
// Protocol pour abstraction
protocol UserServiceProtocol {
func fetchUser(id: Int) -> AnyPublisher<User, UserServiceError>
func search(query: String) -> AnyPublisher<[User], UserServiceError>
}
// Implémentation réelle
class UserService: UserServiceProtocol {
static let shared = UserService()
func fetchUser(id: Int) -> AnyPublisher<User, UserServiceError> {
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://api.example.com/users/\(id)")!)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.mapError { _ in UserServiceError.networkError }
.eraseToAnyPublisher()
}
func search(query: String) -> AnyPublisher<[User], UserServiceError> {
// Implémentation réelle
Empty().eraseToAnyPublisher()
}
}
// Mock pour tests
class MockUserService: UserServiceProtocol {
var mockUser: User?
var mockError: UserServiceError?
var searchResults: [User] = []
func fetchUser(id: Int) -> AnyPublisher<User, UserServiceError> {
if let error = mockError {
return Fail(error: error).eraseToAnyPublisher()
}
return Just(mockUser ?? User(id: id, firstName: "Test", lastName: "User", email: "test@example.com"))
.setFailureType(to: UserServiceError.self)
.eraseToAnyPublisher()
}
func search(query: String) -> AnyPublisher<[User], UserServiceError> {
Just(searchResults)
.setFailureType(to: UserServiceError.self)
.eraseToAnyPublisher()
}
}
// Conteneur DI
class DIContainer {
static let shared = DIContainer()
let userService: UserServiceProtocol
init(userService: UserServiceProtocol = UserService.shared) {
self.userService = userService
}
}
// ViewModel avec DI
class UserProfileViewModel {
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol = DIContainer.shared.userService) {
self.userService = userService
}
}
// Test
class UserProfileViewModelTests: XCTestCase {
func testLoadUserSuccess() {
let mockService = MockUserService()
mockService.mockUser = User(id: 1, firstName: "John", lastName: "Doe", email: "john@example.com")
let viewModel = UserProfileViewModel(userService: mockService)
viewModel.loadUser(id: 1)
// Assertions...
}
}
| Pattern | Avantages | Inconvénients |
|---|---|---|
| Constructor Injection | Simple, explicite | Verbose pour beaucoup de dépendances |
| Property Injection | Flexible | Peut créer des états invalides |
| Method Injection | Spécifique | Diffuse les dépendances |
| Container | Centralisé | Complexité supplémentaire |
Astuce Pro
Créez un protocole pour chaque service même s'il n'a qu'une implémentation. Cela coûte peu et rend les mocks triviaux à créer plus tard. C'est un investissement qui paie immédiatement.
⚠️ Attention
Évitez les singletons globaux sauf pour le DIContainer lui-même. Chaque UserService.shared dur-codé rend les tests fragiles et crée du couplage invisible. Les dépendances explicites sont votre ami.
4. Gestion d'État Avancée avec State Management Patterns
Définition
La gestion d'état avancée en Swift implique des patterns structurés pour gérer l'état applicatif complexe, le flow de données bidirectionnel, et la synchronisation entre plusieurs écrans. Elle va au-delà de simples @State et @Published avec des patterns comme Redux-like ou des State Machines.
Explication
À mesure que votre application grandit, passer l'état de vue en vue devient chaotique. Les patterns de gestion d'état centralisent l'état applicatif dans un seul endroit de vérité. Redux, par exemple, fonctionne avec Actions (intentions de changement), Reducers (transformations d'état), et un Store unique. Cette approche rend le flux de données prévisible et traçable. En Swift, vous pouvez implémenter cela avec TCA (The Composable Architecture) ou un pattern maison simplifié. L'intérêt principal est que chaque changement est tracé et prévisible, ce qui facilite le debugging et les tests.
// Redux-like pattern simplifié
enum AppAction {
case user(UserAction)
case search(SearchAction)
}
enum UserAction {
case fetchUser(id: Int)
case userReceived(User)
case userFetchFailed(Error)
}
enum SearchAction {
case updateQuery(String)
case setSearchResults([User])
}
// State
struct AppState {
var user: User?
var isLoadingUser: Bool = false
var userError: Error?
var searchQuery: String = ""
var searchResults: [User] = []
}
// Reducer
func appReducer(state: inout AppState, action: AppAction) {
switch action {
case .user(let userAction):
switch userAction {
case .fetchUser(let id):
state.isLoadingUser = true
state.userError = nil
case .userReceived(let user):
state.user = user
state.isLoadingUser = false
case .userFetchFailed(let error):
state.userError = error
state.isLoadingUser = false
}
case .search(let searchAction):
switch searchAction {
case .updateQuery(let query):
state.searchQuery = query
case .setSearchResults(let results):
state.searchResults = results
}
}
}
// Store
class AppStore: ObservableObject {
@Published var state: AppState = AppState()
private let userService: UserServiceProtocol
private var cancellables = Set<AnyCancellable>()
init(userService: UserServiceProtocol = .shared) {
self.userService = userService
}
func dispatch(_ action: AppAction) {
appReducer(state: &state, action: action)
// Gérer les effets secondaires
switch action {
case .user(.fetchUser(let id)):
userService.fetchUser(id: id)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.dispatch(.user(.userFetchFailed(error)))
}
},
receiveValue: { [weak self] user in
self?.dispatch(.user(.userReceived(user)))
}
)
.store(in: &cancellables)
case .search(.updateQuery(let query)):
userService.search(query: query)
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] results in
self?.dispatch(.search(.setSearchResults(results)))
}
)
.store(in: &cancellables)
default:
break
}
}
}
// Utilisation
struct ContentView: View {
@StateObject var store = AppStore()
var body: some View {
VStack {
if store.state.isLoadingUser {
ProgressView()
} else if let user = store.state.user {
Text("\(user.firstName) \(user.lastName)")
}
Button("Charger utilisateur") {
store.dispatch(.user(.fetchUser(id: 1)))
}
}
}
}
| Pattern | Complexité | Testabilité | Scalabilité |
|---|---|---|---|
| State simple | Basse | Moyenne | Basse |
| Redux-like | Moyenne | Haute | Haute |
| TCA | Haute | Très haute | Très haute |
| CQRS | Haute | Haute | Moyenne |
Astuce Pro
Commencez par un pattern simple (Redux basique) et évoluez vers TCA si la complexité de l'app le justifie. TCA a une courbe apprentissage raide mais vaut chaque minute investie pour les grandes applications.
⚠️ Attention
Ne versez pas l'état entier dans chaque Vue. Utilisez des getters/selectors pour exposer uniquement ce dont la Vue a besoin. Sinon, chaque changement d'état rerender toutes les Vues, tuant les performances.
5. Composition et Architecture Modulaire en SwiftUI
Définition
L'architecture modulaire découpe une application iOS en modules indépendants et réutilisables, chacun responsable d'un domaine spécifique (User, Search, Settings, etc.). La composition combine ces modules pour former l'application complète avec des frontières claires et un couplage minimal.
Explication
Une monolithe est difficile à tester, paralléliser et maintenir. En architecturant votre app en modules, chaque équipe peut travailler indépendamment, les modules peuvent être testés isolément, et la réutilisation devient naturelle. Un module contient typiquement Views, ViewModels, Models, Services, et tout ce nécessaire pour fonctionner seul. SwiftUI excelle dans ce domaine grâce à sa nature composable. Vous pouvez construire des Vues complexes en les assemblant simplement. La clé est définir des contrats clairs entre modules via des protocols et des coordinators pour naviguer.
// MARK: - User Module
// UserFeature/Models
struct UserProfile {
let id: Int
let name: String
let email: String
let avatar: URL?
}
// UserFeature/ViewModels
class UserDetailViewModel: ObservableObject {
@Published var profile: UserProfile?
@Published var isLoading: Bool = false
private let userRepository: UserRepositoryProtocol
init(userId: Int, userRepository: UserRepositoryProtocol) {
self.userRepository = userRepository
loadProfile(userId: userId)
}
private func loadProfile(userId: Int) {
isLoading = true
// Charge le profil
}
}
// UserFeature/Views
struct UserDetailView: View {
@StateObject var viewModel: UserDetailViewModel
var body: some View {
if let profile = viewModel.profile {
VStack {
Text(profile.name)
.font(.title)
Text(profile.email)
.foregroundColor(.gray)
}
}
}
}
// MARK: - Search Module
class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [UserProfile] = []
private let searchRepository: SearchRepositoryProtocol
init(searchRepository: SearchRepositoryProtocol) {
self.searchRepository = searchRepository
}
func search(query: String) {
// Exécute la recherche
}
}
struct SearchView: View {
@StateObject var viewModel: SearchViewModel
var onUserSelected: (UserProfile) -> Void
var body: some View {
VStack {
TextField("Chercher un utilisateur", text: $viewModel.query)
.onChange(of: viewModel.query) { query in
viewModel.search(query: query)
}
List(viewModel.results, id: \.id) { user in
Button(action: { onUserSelected(user) }) {
Text(user.name)
}
}
}
}
}
// MARK: - Coordinator (Navigation)
class AppCoordinator {
enum Destination {
case userDetail(userId: Int)
case search
}
@Published var path: NavigationPath = NavigationPath()
func navigate(to destination: Destination) {
switch destination {
case .userDetail(let userId):
path.append(destination)
case .search:
path.append(destination)
}
}
}
// MARK: - App Assembly (DI at module level)
class AppAssembly {
let userRepository: UserRepositoryProtocol
let searchRepository: SearchRepositoryProtocol
let coordinator: AppCoordinator
init(
userRepository: UserRepositoryProtocol = UserRepository(),
searchRepository: SearchRepositoryProtocol = SearchRepository()
) {
self.userRepository = userRepository
self.searchRepository = searchRepository
self.coordinator = AppCoordinator()
}
func createUserDetailViewModel(userId: Int) -> UserDetailViewModel {
UserDetailViewModel(userId: userId, userRepository: userRepository)
}
func createSearchViewModel() -> SearchViewModel {
SearchViewModel(searchRepository: searchRepository)
}
}
// MARK: - Main App Composition
struct AppView: View {
@StateObject var assembly = AppAssembly()
var body: some View {
NavigationStack {
VStack {
Button("Aller à la recherche") {
assembly.coordinator.navigate(to: .search)
}
}
.navigationDestination(for: AppCoordinator.Destination.self) { destination in
switch destination {
case .userDetail(let userId):
let viewModel = assembly.createUserDetailViewModel(userId: userId)
UserDetailView(viewModel: viewModel)
case .search:
let viewModel = assembly.createSearchViewModel()
SearchView(viewModel: viewModel) { selectedUser in
assembly.coordinator.navigate(to: .userDetail(userId: selectedUser.id))
}
}
}
}
}
}
| Aspect | Bénéfice | Coût |
|---|---|---|
| Testabilité | Modules isolés, faciles à tester | Setup initial |
| Réutilisabilité | Partage entre apps/équipes | Boundaries strictes |
| Scalabilité | Croissance sans chaos | Complexité architecturale |
| Parallélisation | Équipes indépendantes | Coordination requise |
| Maintenance | Code clair par domaine | Conventions strictes |
Astuce Pro
Créez un protocole Coordinator abstrait que chaque module implémente. Cela permet à un module User d'appeler "naviguer vers Search" sans connaître les détails du routage global. Loose coupling, high cohesion.
⚠️ Attention
Ne créez pas trop de modules au début. Commencez monolithe, refactorisez en modules une fois les patterns clairs. Les micro-modules prématurés créent plus de boilerplate qu'ils ne résolvent de problèmes. Une bonne heuristique : un module par écran ou domaine métier majeur.