Android (Kotlin) Intermédiaire

Architectures Android Professionnelles : MVVM, Clean Architecture et Dependency Injection

Maîtrisez les patterns architecturaux qui structurent les applications Android scalables et maintenables en production. Découvrez comment les grandes entreprises organisent leur code pour éviter la dette technique et faciliter la collaboration d'équipes.

Preparetoi.academy 30 min

1. Fondamentaux de l'Architecture MVVM

Définition
L'architecture MVVM (Model-View-ViewModel) est un pattern architectural qui sépare l'interface utilisateur (View) de la logique métier (Model) par l'intermédiaire d'une couche intermédiaire appelée ViewModel. Cette séparation garantit une testabilité maximale et une maintenabilité accrue du code. Le ViewModel expose les données et les actions via des LiveData ou StateFlow observables, permettant à la View de se mettre à jour automatiquement lors de changements de données.

Analogie
Pensez à MVVM comme à une chaîne de montage dans une usine automobile : le Model représente les composants bruts (moteur, châssis), la View est l'interface avec laquelle le client interagit (le tableau de bord), et le ViewModel est l'ingénieur qui orchestre comment les pièces s'assemblent et comment les informations remontent à l'utilisateur. La View ne commande jamais directement le Model ; elle communique toujours via le ViewModel.

Tableau : Responsabilités MVVM

Composant Responsabilité Dépendances Testabilité
View (Activity/Fragment) Affichage UI, gestion des événements utilisateur ViewModel Difficile (nécessite Instrumented Tests)
ViewModel Logique de présentation, gestion d'état, orchestration Repository, UseCase Excellente (Unit Tests)
Model (Repository) Gestion des données, appels API, base de données DataSource Excellente (Unit Tests)
DataSource Accès aux données brutes Aucune Excellente (Unit Tests)

Astuce Professionnelle
Utilisez toujours des LiveData ou StateFlow pour encapsuler complètement l'état de votre écran dans une classe scellée. Cela permet à votre Activity/Fragment de simplement observer un seul flux d'événements plutôt que plusieurs LiveData disparates, rendant la logique beaucoup plus prévisible et testable.

⚠️ Attention
Ne placez JAMAIS de logique métier directement dans la View. Les ViewModels conservent les données même après la destruction de l'Activity (rotation d'écran). Ne confondez pas cela avec une "persistance éternelle" : les ViewModels sont détruits quand l'utilisateur quitte l'écran. Pour la persistance à long terme, utilisez toujours une base de données.


2. Clean Architecture et Couches Métier

Définition
Clean Architecture est un pattern macro-architectural proposé par Robert C. Martin qui organise le code en couches concentriques indépendantes. Chaque couche a un niveau d'abstraction différent : la Presentation Layer (interface utilisateur), la Domain Layer (logique métier pure), et la Data Layer (sources de données). Les dépendances ne circulent que vers l'intérieur ; la couche la plus externe ne connaît jamais les détails d'implémentation des couches internes.

Analogie
Imaginez une ambassade : la couche Data (Data Layer) correspond à la salle des archives où sont stockées les informations brutes ; la couche Domain (Domain Layer) est le département juridique qui applique les règles métier indépendamment d'où vient l'information ; la couche Presentation (Presentation Layer) est l'accueil où les citoyens interagissent. L'accueil peut changer complètement (desktop, mobile, web), mais les lois juridiques restent identiques.

Tableau : Couches Clean Architecture en Kotlin

Couche Contenu Exemples Dépend de
Presentation Activities, Fragments, ViewModels, UI State MainActivity, AuthViewModel Domain
Domain Use Cases, Entities, Interfaces Repository GetUserUseCase, User Aucune
Data Repositories, Remote/Local DataSources, Mappers UserRepository, ApiService Domain (interfaces)

Exemple Kotlin

// Domain Layer - Pure Business Logic
data class User(val id: String, val name: String, val email: String)

interface UserRepository {
    suspend fun getUser(id: String): Result<User>
}

class GetUserUseCase(private val repository: UserRepository) {
    suspend operator fun invoke(userId: String): Result<User> {
        return repository.getUser(userId)
    }
}

// Data Layer - Implementation
class UserRepositoryImpl(private val api: ApiService) : UserRepository {
    override suspend fun getUser(id: String): Result<User> = try {
        Result.success(api.fetchUser(id).toDomain())
    } catch (e: Exception) {
        Result.failure(e)
    }
}

// Presentation Layer
class UserViewModel(private val getUserUseCase: GetUserUseCase) : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState = _uiState.asStateFlow()
    
    fun loadUser(userId: String) = viewModelScope.launch {
        getUserUseCase(userId).onSuccess { user ->
            _uiState.value = UiState.Success(user)
        }
    }
}

Astuce Professionnelle
Créez des interfaces Repository dans la couche Domain, implémentez-les dans la couche Data. Cette inversion de dépendance (Dependency Inversion Principle) permet à votre ViewModel de tester avec des faux repositories sans aucune connaissance de l'implémentation réelle. Très puissant pour les tests unitaires.

⚠️ Attention
La Clean Architecture ajoute de la complexité. Pour une petite application (< 10 écrans), elle peut sembler excessive. Adaptez le pattern à la taille de votre projet. Les startups privilégient souvent la vitesse ; les applications d'entreprise à long terme bénéficient énormément de cette structure pour accommoder les changements.


3. Dependency Injection avec Hilt

Définition
La Dependency Injection (DI) est un pattern qui fournit à une classe ses dépendances au lieu que la classe les crée elle-même. Hilt est une bibliothèque Android officielle construite au-dessus de Dagger qui automatise et simplifie énormément l'injection de dépendances en Kotlin. Au lieu de instancier manuellement des centaines d'objets, vous déclarez "j'ai besoin de X" et Hilt construit automatiquement le graphe de dépendances complet.

Analogie
Imaginez que vous êtes un restaurant. Sans DI, chaque cuisinier doit fabriquer ses propres couteaux, ses propres ingrédients, et ses propres ustensiles avant de cuisiner. Avec DI (Hilt), un responsable logistique fournit à chaque poste exactement ce dont il a besoin au moment où il en a besoin. Le cuisinier se concentre sur sa cuisine, pas sur la fabrication d'outils.

Tableau : Annotations Hilt Essentielles

Annotation Usage Scope Exemple
@HiltAndroidApp Application entière Singleton Sur la classe Application
@AndroidEntryPoint Activity, Fragment, Service Dépend du binding Sur votre Activity
@Singleton Une seule instance pour toute l'app Application lifetime Sur les Repositories, Databases
@Module Déclaration de dépendances Selon le scope Pour ApiService, Database
@Provides Fournir une instance Selon le scope Pour les interfaces
@Inject Demander une dépendance Automatique Dans les constructeurs

Exemple Kotlin Complet avec Hilt

// 1. Configuration Application
@HiltAndroidApp
class MyApp : Application()

// 2. Module Hilt
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(LoggingInterceptor())
        .build()
    
    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder()
        .client(client)
        .baseUrl("https://api.example.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    
    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService = 
        retrofit.create(ApiService::class.java)
    
    @Provides
    @Singleton
    fun provideUserRepository(api: ApiService): UserRepository = 
        UserRepositoryImpl(api)
}

// 3. Utilisation dans ViewModel
@HiltViewModel
class UserViewModel @Inject constructor(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {
    // Hilt fournit automatiquement GetUserUseCase qui dépend de UserRepository...
}

// 4. Activity avec Hilt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val viewModel: UserViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // viewModel est injecté automatiquement
    }
}

Astuce Professionnelle
Groupez vos modules Hilt logiquement : DataModule, NetworkModule, RepositoryModule, etc. Pour les tests, créez des modules de test qui override les bindings avec des fakes. Hilt offre @BindsInstance pour injecter des valeurs runtime (comme une configuration) sans créer d'instances supplémentaires.

⚠️ Attention
Les erreurs de configuration Hilt sont souvent détectées à la compilation, pas à l'exécution. Lisez les messages d'erreur du compilateur attentivement ; ils sont très verbeux mais précis. Ne créez pas circulairement des dépendances (A dépend de B qui dépend de A) : Hilt refusera de compiler et c'est tant mieux.


4. State Management avec StateFlow et Coroutines

Définition
StateFlow est une Flow spécialisée qui émet toujours un état courant et notifie les observateurs lors de changements. Associée aux Coroutines, elle permet une gestion réactive et élégante de l'état de l'application. Contrairement aux LiveData, StateFlow n'a pas de liaison avec le cycle de vie de la Vue ; elle est entièrement basée sur les Coroutines, offrant plus de flexibilité et de testabilité dans les ViewModel.

Analogie
StateFlow fonctionne comme un journal papier mis à jour en temps réel : dès qu'une nouvelle édition sort, les abonnés la reçoivent automatiquement. Les Coroutines sont les messagers qui s'assurent que les copies sont distribuées sans bloquer le flux principal du travail. À la différence des LiveData, un messager peut même délivrer des copies à plusieurs destinataires simultanément.

Tableau : StateFlow vs LiveData vs SharedFlow

Aspect StateFlow LiveData SharedFlow
Requiert Cycle Vie Non Oui Non
Thread-safe Oui (Mutex) Oui Oui
Buffer 1 (dernière valeur) Implicite Configurable
Testabilité Excellente Bonne Excellente
Coroutine-aware Oui Non (VM lifecycle) Oui
Multicast Oui Oui Oui

Exemple Kotlin Avancé

sealed class UiState {
    object Loading : UiState()
    data class Success(val users: List<User>) : UiState()
    data class Error(val message: String) : UiState()
}

@HiltViewModel
class UsersViewModel @Inject constructor(
    private val getUsersUseCase: GetUsersUseCase
) : ViewModel() {
    
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
    
    // Requête optimisée avec debounce et distinctUntilChanged
    private val _searchQuery = MutableStateFlow("")
    val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
    
    val filteredUsers: StateFlow<UiState> = searchQuery
        .debounce(300.milliseconds)
        .distinctUntilChanged()
        .flatMapLatest { query ->
            if (query.isEmpty()) {
                flowOf(UiState.Loading)
            } else {
                getUsersUseCase(query)
                    .map<List<User>, UiState> { UiState.Success(it) }
                    .catch { UiState.Error(it.message ?: "Unknown error") }
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.Lazily,
            initialValue = UiState.Loading
        )
    
    fun onSearchChange(query: String) {
        _searchQuery.value = query
    }
    
    fun loadUsers() = viewModelScope.launch {
        _uiState.value = UiState.Loading
        getUsersUseCase("")
            .onSuccess { _uiState.value = UiState.Success(it) }
            .onFailure { _uiState.value = UiState.Error(it.message ?: "Unknown") }
    }
}

Astuce Professionnelle
Utilisez stateIn() pour transformer des Flow en StateFlow avec un initialValue approprié. Combiné avec SharingStarted.Lazily, cela garantit que le Flow ne s'active que si au moins un observateur existe, économisant les ressources. Pour les requêtes de recherche, debounce() + distinctUntilChanged() combinés réduisent drastiquement les appels API inutiles.

⚠️ Attention
Ne confondez pas mutableStateFlow.value (thread-safe, synchrone) avec emit() (pour les Flow, asynchrone). StateFlow ne buffer qu'une seule valeur : si vous émettez deux valeurs identiques rapidement, le deuxième observateur ne verra jamais la première. Utilisez distinctUntilChanged() consciemment pour éviter les mises à jour UI redondantes.


5. Testing Patterns et Bonnes Pratiques

Définition
Le testing en architecture Android moderne suit des patterns spécifiques selon la couche testée. Les tests unitaires testent la Domain Layer (Use Cases) en isolation complète. Les tests d'intégration testent le Data Layer avec des faux services. Les tests UI (instrumented) testent l'interaction complète Activity + ViewModel. Une architecture propre rend les tests unitaires faciles et rapides, les tests instrumented rares et essentiels.

Analogie
Imaginez contrôler la qualité d'une usine automobile : les tests unitaires vérifient que chaque pièce manufacturée respecte les spécifications (rapide, local) ; les tests d'intégration assemblent plusieurs pièces et vérifient qu'elles s'ajustent bien (moyen, en atelier) ; les tests UI (road tests) mettent la voiture complète sur la route (lent, critères réels). Une stratégie équilibrée privilégie les tests unitaires car ils sont rapides et révèlent les bugs tôt.

Tableau : Pyramide de Tests Android

Type Cible Framework Vitesse Couverture Fréquence
Unitaires Use Cases, Repository Logic JUnit, MockK < 1s Élevée À chaque commit
Intégration Repository + DataSource JUnit, MockK, Room 1-5s Moyenne-Haute À chaque commit
UI (Instrumented) Activity + ViewModel + UI Espresso, Hilt Testing 10-60s Moyenne Avant push
E2E Scénarios complets utilisateur Maestro, Detox Très lent Critique paths Avant release

Exemple Kotlin : Tests Complets

// 1. TEST UNITAIRE - Use Case
class GetUserUseCaseTest {
    private val mockRepository: UserRepository = mockk()
    private val useCase = GetUserUseCase(mockRepository)
    
    @Test
    fun testGetUserSuccess() = runTest {
        // Given
        val testUser = User("1", "John", "john@example.com")
        coEvery { mockRepository.getUser("1") } returns Result.success(testUser)
        
        // When
        val result = useCase("1")
        
        // Then
        assertTrue(result.isSuccess)
        assertEquals(testUser, result.getOrNull())
        coVerify(exactly = 1) { mockRepository.getUser("1") }
    }
}

// 2. TEST D'INTÉGRATION - Repository avec Fake API
class UserRepositoryTest {
    private val fakeApi = FakeApiService()
    private val repository = UserRepositoryImpl(fakeApi)
    
    @Test
    fun testRepositoryMapsDataCorrectly() = runTest {
        fakeApi.setMockUser(ApiUser("1", "John", "john@example.com"))
        
        val result = repository.getUser("1")
        
        assertTrue(result.isSuccess)
        result.onSuccess { user ->
            assertEquals("john@example.com", user.email)
        }
    }
}

// 3. TEST VIEWMODEL - Avec TestDispatchers et StateFlow
class UserViewModelTest {
    private val testDispatcher = StandardTestDispatcher()
    private val mockUseCase: GetUserUseCase = mockk()
    private lateinit var viewModel: UserViewModel
    
    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        viewModel = UserViewModel(mockUseCase)
    }
    
    @Test
    fun testLoadUserEmitsSuccessState() = runTest {
        val testUser = User("1", "John", "john@example.com")
        coEvery { mockUseCase("1") } returns Result.success(testUser)
        
        viewModel.loadUser("1")
        advanceUntilIdle() // Attend que les coroutines finissent
        
        val state = viewModel.uiState.value
        assertTrue(state is UiState.Success)
        assertEquals(testUser, (state as UiState.Success).user)
    }
    
    @After
    fun cleanup() {
        Dispatchers.resetMain()
    }
}

// 4. TEST UI AVEC HILT
@HiltAndroidTest
class UserActivityTest {
    @get:Rule
    val hiltRule = HiltAndroidRule(this)
    
    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()
    
    @Before
    fun setup() {
        hiltRule.inject()
    }
    
    @Test
    fun testUserDisplayedOnSuccess() {
        composeTestRule.onNodeWithText("John").assertExists()
        composeTestRule.onNodeWithText("john@example.com").assertExists()
    }
}

// FAKE POUR TESTS D'INTÉGRATION
class FakeApiService : ApiService {
    private var mockUser: ApiUser? = null
    
    fun setMockUser(user: ApiUser) {
        mockUser = user
    }
    
    override suspend fun fetchUser(id: String): ApiUser {
        return mockUser ?: throw Exception("No mock set")
    }
}

Astuce Professionnelle
Utilisez TestDispatchers (StandardTestDispatcher/UnconfinedTestDispatcher) pour contrôler complètement le timing des Coroutines dans les tests. runTest { advanceUntilIdle() } garantit que toutes les coroutines async finissent avant d'asserter. Créez des Fakes (pas des Mocks) pour les UseCase dans les tests ViewModel ; les fakes sont déterministes et plus rapides que les mocks.

⚠️ Attention
Les tests instrumentés (Espresso) tournent sur un appareil Android réel ou un émulateur : ils sont lents et flaky (instables si les timings changent). Privilégiez les tests unitaires qui tournent sur la JVM en millisecondes. Hilt Testing (@HiltAndroidTest) offre l'injection en tests instrumented, mais gardez-les au minimum. Les erreurs de timing ou d'ordre d'exécution en StateFlow testent mal ; utilisez toujours testScheduler et advanceUntilIdle().

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