iOS (Swift) Intermédiaire

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.

Preparetoi.academy 30 min

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.

Accédez à des centaines d'examens QCM — Découvrir les offres Premium