MVVM vs MVI: Welke Architectuur Kiezen in 2026?
Uitgebreide vergelijking tussen MVVM en MVI op Android: voor- en nadelen, use cases en een praktische gids voor de juiste architectuurkeuze. Bijgewerkt met Kotlin 2.4 explicit backing fields.

De juiste architectuur kiezen is een cruciale beslissing die de onderhoudbaarheid, testbaarheid en schaalbaarheid van een Android-applicatie beïnvloedt. In 2026 domineren twee patronen het ecosysteem: MVVM, de industriestandaard, en MVI, de reactieve aanpak die de natuurlijke keuze is geworden voor Jetpack Compose.
Een slechte architectuurkeuze is kostbaar: technische schuld, moeilijk te reproduceren bugs en pijnlijke refactoringen. Inzicht in de sterke en zwakke punten van elke aanpak bespaart op de lange termijn veel hoofdbrekens.
MVVM Begrijpen: De Gevestigde Standaard
MVVM (Model-View-ViewModel) is de aanbevolen architectuur van Google sinds de introductie van Jetpack. Het scheidt verantwoordelijkheden in drie duidelijke lagen, waardoor de code overzichtelijker en testbaarder wordt.
MVVM Kernprincipes
Het MVVM-patroon is gebaseerd op een duidelijke scheiding: het Model beheert data en bedrijfslogica, de View toont de UI, en het ViewModel verbindt de twee door observeerbare staten te bieden.
Dit eerste voorbeeld toont de basisstructuur met een ViewModel dat observeerbare staat en methoden voor gebruikersinteracties biedt. Let op hoe de explicit backing fields van Kotlin 2.4 het traditionele _uiState / uiState boilerplate-patroon elimineren.
// MVVM ViewModel with Kotlin 2.4 explicit backing fields
// The backing field syntax eliminates the _state / state dance
class UserProfileViewModel(
private val userRepository: UserRepository,
private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
// Kotlin 2.4: explicit backing field - no more _uiState / uiState pair
val uiState: StateFlow<UserProfileState>
field = MutableStateFlow(UserProfileState())
// Separate loading state - MVVM allows multiple flows
val isLoading: StateFlow<Boolean>
field = MutableStateFlow(false)
// One-shot error messages via SharedFlow
private val _errorMessage = MutableSharedFlow<String>()
val errorMessage: SharedFlow<String> = _errorMessage.asSharedFlow()
// Initial profile loading
fun loadProfile(userId: String) {
viewModelScope.launch {
isLoading.value = true
try {
// Repository call to fetch data
val user = userRepository.getUser(userId)
// Update state with new data
uiState.update { currentState ->
currentState.copy(
user = user,
isEditing = false
)
}
// Analytics tracking
analyticsTracker.trackProfileViewed(userId)
} catch (e: Exception) {
// Emit one-shot error message
_errorMessage.emit("Unable to load profile")
} finally {
isLoading.value = false
}
}
}
// Enable edit mode
fun enableEditMode() {
uiState.update { it.copy(isEditing = true) }
}
// Save profile changes
fun saveProfile(name: String, bio: String) {
viewModelScope.launch {
isLoading.value = true
try {
val updatedUser = userRepository.updateUser(
uiState.value.user?.id ?: return@launch,
name = name,
bio = bio
)
uiState.update {
it.copy(user = updatedUser, isEditing = false)
}
} catch (e: Exception) {
_errorMessage.emit("Failed to save profile")
} finally {
isLoading.value = false
}
}
}
}
// Data class representing the screen state
data class UserProfileState(
val user: User? = null,
val isEditing: Boolean = false
)Dit ViewModel illustreert de typische MVVM-aanpak: meerdere observeerbare flows (hoofdstatus, laden, fouten) en publieke methoden voor elke gebruikersactie. De explicit backing fields-syntax van Kotlin 2.4 maakt de code schoner door het property-type als StateFlow te declareren terwijl het backing field MutableStateFlow is.
Voordelen van MVVM
MVVM heeft meerdere sterke punten die de massale adoptie verklaren:
- Bekendheid: De meeste Android-ontwikkelaars kennen dit patroon
- Flexibiliteit: De staat kan worden gestructureerd zoals de use case vereist
- Ecosysteem: Perfecte integratie met Jetpack (LiveData, StateFlow, Hilt)
- Eenvoud: Geleidelijke leercurve voor beginners
MVVM is bijzonder geschikt voor gemengde teams met ontwikkelaars van verschillende niveaus. De conceptuele eenvoud vergemakkelijkt de onboarding.
Beperkingen van MVVM
MVVM toont echter zijn beperkingen naarmate de applicatie groeit. Het belangrijkste probleem is gedistribueerd staatsbeheer. Dit voorbeeld illustreert het veelvoorkomende probleem van gefragmenteerde staat:
// Example MVVM ViewModel with fragmented state
// This pattern becomes problematic as the screen grows in complexity
class CheckoutViewModel : ViewModel() {
// Problem: state scattered across multiple flows
val cart: StateFlow<List<CartItem>>
field = MutableStateFlow(emptyList())
val selectedAddress: StateFlow<Address?>
field = MutableStateFlow(null)
val selectedPayment: StateFlow<PaymentMethod?>
field = MutableStateFlow(null)
val promoCode: StateFlow<String?>
field = MutableStateFlow(null)
val isLoading: StateFlow<Boolean>
field = MutableStateFlow(false)
val error: StateFlow<String?>
field = MutableStateFlow(null)
// Each modification can create temporary inconsistent states
fun applyPromoCode(code: String) {
viewModelScope.launch {
isLoading.value = true
error.value = null
try {
val discount = promoRepository.validate(code)
promoCode.value = code
// Cart state also needs updating...
// but there's a delay between the two updates
recalculateCart()
} catch (e: Exception) {
error.value = e.message
promoCode.value = null
} finally {
isLoading.value = false
}
}
}
// Hard to guarantee consistency across all these states
private fun recalculateCart() {
// Complex logic depending on multiple states...
}
}Dit voorbeeld toont hoe de staat in MVVM kan fragmenteren, waardoor het moeilijker wordt om transities te volgen en bugs te reproduceren.
MVI Begrijpen: De Unidirectionele Aanpak
MVI (Model-View-Intent) hanteert een andere filosofie: unidirectionele gegevensstroom en een enkele onveranderlijke staat. Deze door Redux geïnspireerde aanpak elimineert problemen met inconsistente staat.
MVI Kernprincipes
In MVI volgt alles een duidelijke cyclus: de gebruiker stuurt een Intent (actie), de Reducer transformeert de huidige staat naar een nieuwe staat, en de View toont die enkele staat. Het is voorspelbaar, testbaar en debuggable.
Deze implementatie van hetzelfde gebruikersprofielscherm demonstreert hoe de staat gecentraliseerd is en acties expliciet getypeerd zijn:
// MVI ViewModel for the same user profile screen
// Note the structure: Intent -> Reducer -> Single State
class UserProfileMviViewModel(
private val userRepository: UserRepository,
private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
// Single, immutable state - the absolute source of truth
val state: StateFlow<UserProfileState>
field = MutableStateFlow(UserProfileState())
// Channel for side effects (navigation, snackbar)
private val _sideEffect = Channel<UserProfileSideEffect>()
val sideEffect: Flow<UserProfileSideEffect> = _sideEffect.receiveAsFlow()
// Single entry point for all user actions
fun onIntent(intent: UserProfileIntent) {
when (intent) {
is UserProfileIntent.LoadProfile -> loadProfile(intent.userId)
is UserProfileIntent.EnableEditMode -> enableEditMode()
is UserProfileIntent.SaveProfile -> saveProfile(intent.name, intent.bio)
is UserProfileIntent.CancelEdit -> cancelEdit()
}
}
private fun loadProfile(userId: String) {
viewModelScope.launch {
// Transition to loading state
state.update { it.copy(isLoading = true, error = null) }
try {
val user = userRepository.getUser(userId)
// Single atomic state update
state.update {
it.copy(
user = user,
isLoading = false,
error = null
)
}
analyticsTracker.trackProfileViewed(userId)
} catch (e: Exception) {
// Error state is part of the main state
state.update {
it.copy(
isLoading = false,
error = "Unable to load profile"
)
}
}
}
}
private fun enableEditMode() {
// Simple, predictable update
state.update { it.copy(isEditing = true) }
}
private fun saveProfile(name: String, bio: String) {
viewModelScope.launch {
val currentUser = state.value.user ?: return@launch
state.update { it.copy(isLoading = true) }
try {
val updatedUser = userRepository.updateUser(
currentUser.id,
name = name,
bio = bio
)
state.update {
it.copy(
user = updatedUser,
isEditing = false,
isLoading = false
)
}
// Side effect to notify the user
_sideEffect.send(UserProfileSideEffect.ShowSuccess("Profile updated"))
} catch (e: Exception) {
state.update {
it.copy(isLoading = false, error = "Failed to save profile")
}
}
}
}
private fun cancelEdit() {
state.update { it.copy(isEditing = false) }
}
}
// All possible actions, explicitly typed
sealed class UserProfileIntent {
data class LoadProfile(val userId: String) : UserProfileIntent()
data object EnableEditMode : UserProfileIntent()
data class SaveProfile(val name: String, val bio: String) : UserProfileIntent()
data object CancelEdit : UserProfileIntent()
}
// Single, complete screen state
data class UserProfileState(
val user: User? = null,
val isLoading: Boolean = false,
val isEditing: Boolean = false,
val error: String? = null
)
// One-shot side effects
sealed class UserProfileSideEffect {
data class ShowSuccess(val message: String) : UserProfileSideEffect()
data class NavigateTo(val destination: String) : UserProfileSideEffect()
}Het verschil is duidelijk: een enkele statestroom, expliciete acties en een schone scheiding tussen persistente staat en eenmalige effecten.
Met MVI kan elke Intent en elke statusovergang worden gelogd. Een bug reproduceren wordt triviaal: herspeel gewoon de reeks Intents.
MVI met Jetpack Compose
MVI blinkt bijzonder uit met Jetpack Compose, omdat beide dezelfde filosofie delen: onveranderlijke staat en declaratieve UI. Zo wordt het ViewModel verbonden met een Compose-scherm:
// Compose screen consuming MVI state
// The connection between ViewModel and UI is elegant and reactive
@Composable
fun UserProfileScreen(
viewModel: UserProfileMviViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
// Collect the single state
val state by viewModel.state.collectAsStateWithLifecycle()
// Handle side effects
LaunchedEffect(Unit) {
viewModel.sideEffect.collect { effect ->
when (effect) {
is UserProfileSideEffect.ShowSuccess -> {
// Show snackbar
}
is UserProfileSideEffect.NavigateTo -> {
// Navigate
}
}
}
}
// Purely declarative UI based on state
UserProfileContent(
state = state,
onIntent = viewModel::onIntent
)
}
@Composable
private fun UserProfileContent(
state: UserProfileState,
onIntent: (UserProfileIntent) -> Unit
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// Conditional rendering based on the single state
when {
state.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
state.error != null -> {
ErrorMessage(
message = state.error,
onRetry = {
state.user?.id?.let {
onIntent(UserProfileIntent.LoadProfile(it))
}
}
)
}
state.user != null -> {
ProfileCard(
user = state.user,
isEditing = state.isEditing,
onEditClick = { onIntent(UserProfileIntent.EnableEditMode) },
onSaveClick = { name, bio ->
onIntent(UserProfileIntent.SaveProfile(name, bio))
},
onCancelClick = { onIntent(UserProfileIntent.CancelEdit) }
)
}
}
}
}De UI wordt een pure functie van de staat: voorspelbaar, testbaar en zonder verborgen neveneffecten.
Gedetailleerde Vergelijking
Nu beide patronen duidelijk zijn, volgt een vergelijking op de criteria die er echt toe doen in productie.
Staatsbeheer
Het fundamentele verschil ligt in staatsbeheer. Dit onderscheid heeft directe invloed op de langetermijnonderhoudbaarheid.
// MVVM: potentially fragmented state
class MvvmViewModel : ViewModel() {
// Multiple sources of truth - manual synchronization needed
val users: StateFlow<List<User>>
field = MutableStateFlow(emptyList())
val selectedUser: StateFlow<User?>
field = MutableStateFlow(null)
val isLoading: StateFlow<Boolean>
field = MutableStateFlow(false)
val searchQuery: StateFlow<String>
field = MutableStateFlow("")
// What happens if selectedUser points to a user
// that's no longer in users after a refresh?
// -> Inconsistent state that's hard to detect
}
// MVI: consistent state by construction
class MviViewModel : ViewModel() {
// Single source of truth - inconsistencies are impossible
val state: StateFlow<UsersState>
field = MutableStateFlow(UsersState())
data class UsersState(
val users: List<User> = emptyList(),
val selectedUser: User? = null, // Always consistent with users
val isLoading: Boolean = false,
val searchQuery: String = ""
)
// Each update automatically maintains invariants
private fun selectUser(userId: String) {
state.update { currentState ->
currentState.copy(
selectedUser = currentState.users.find { it.id == userId }
)
}
}
}In MVVM manifesteren inconsistente staten zich vaak als intermitterende bugs die moeilijk te reproduceren zijn. In MVI is een ongeldige staat deterministisch ongeldig.
Testbaarheid van de Architectuur
Beide architecturen zijn testbaar, maar MVI biedt een significant voordeel dankzij de voorspelbaarheid.
// MVVM test: requires verifying multiple flows
@Test
fun `loadUsers should update state correctly`() = runTest {
val viewModel = MvvmViewModel(fakeRepository)
// Observe multiple flows simultaneously
val users = mutableListOf<List<User>>()
val loadingStates = mutableListOf<Boolean>()
val job1 = launch { viewModel.users.toList(users) }
val job2 = launch { viewModel.isLoading.toList(loadingStates) }
viewModel.loadUsers()
advanceUntilIdle()
// Assertions on different flows
assertThat(users.last()).isEqualTo(expectedUsers)
assertThat(loadingStates).containsExactly(false, true, false)
job1.cancel()
job2.cancel()
}
// MVI test: single flow to verify, clear state sequence
@Test
fun `LoadUsers intent should produce correct state sequence`() = runTest {
val viewModel = MviViewModel(fakeRepository)
// Collect all states in order
val states = mutableListOf<UsersState>()
val job = launch { viewModel.state.toList(states) }
// Send the intent
viewModel.onIntent(UsersIntent.LoadUsers)
advanceUntilIdle()
// Verify the exact state sequence
assertThat(states).containsExactly(
UsersState(), // Initial
UsersState(isLoading = true), // Loading
UsersState(users = expectedUsers, isLoading = false) // Success
)
job.cancel()
}MVI maakt het mogelijk de exacte volgorde van statusovergangen te testen, wat bijzonder nuttig is voor complexe schermen met veel interacties.
Complexiteit en Boilerplate
Sinds Kotlin 2.4 is het boilerplate-verschil tussen MVVM en MVI aanzienlijk kleiner geworden. De explicit backing fields-functie elimineert het _state / state-patroon dat voorheen regels toevoegde aan elk ViewModel.
// MVVM: quick start, less code
class SimpleViewModel : ViewModel() {
val name: StateFlow<String>
field = MutableStateFlow("")
fun updateName(newName: String) {
name.value = newName
}
}
// Total: ~8 lines
// MVI: more structure, more code
class SimpleMviViewModel : ViewModel() {
val state: StateFlow<SimpleState>
field = MutableStateFlow(SimpleState())
fun onIntent(intent: SimpleIntent) {
when (intent) {
is SimpleIntent.UpdateName -> {
state.update { it.copy(name = intent.name) }
}
}
}
}
data class SimpleState(val name: String = "")
sealed class SimpleIntent {
data class UpdateName(val name: String) : SimpleIntent()
}
// Total: ~18 linesVoor een eenvoudig scherm kan MVI overdreven lijken. Maar deze structuur loont naarmate het scherm in complexiteit groeit.
Klaar om je Android gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
Wanneer MVVM Kiezen?
MVVM blijft de pragmatische keuze in verschillende situaties:
Bestaande Projecten
Als de applicatie al MVVM gebruikt, vertegenwoordigt migratie naar MVI aanzienlijk werk. De bestaande MVVM-structuur verbeteren is vaak verstandiger.
Junior of Gemengde Teams
MVVM is toegankelijker. Een team met beginners zal sneller productief zijn met MVVM dan met MVI.
Eenvoudige Schermen
Voor schermen met weinig staten en interacties voegt MVI complexiteit toe zonder proportioneel voordeel.
// For a simple settings screen, MVVM is plenty
class SettingsViewModel(
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
val darkMode = preferencesRepository.darkModeFlow
.stateIn(viewModelScope, SharingStarted.Lazily, false)
val notificationsEnabled = preferencesRepository.notificationsFlow
.stateIn(viewModelScope, SharingStarted.Lazily, true)
fun toggleDarkMode() {
viewModelScope.launch {
preferencesRepository.setDarkMode(!darkMode.value)
}
}
fun toggleNotifications() {
viewModelScope.launch {
preferencesRepository.setNotifications(!notificationsEnabled.value)
}
}
}Wanneer MVI Kiezen?
MVI bewijst zijn waarde in specifieke contexten:
Applicaties met Complexe Staat
Wanneer een scherm veel onderling afhankelijke staten heeft, garandeert MVI consistentie.
// Checkout screen with complex state: MVI excels
data class CheckoutState(
val cartItems: List<CartItem> = emptyList(),
val selectedAddress: Address? = null,
val selectedPayment: PaymentMethod? = null,
val promoCode: PromoCode? = null,
val deliveryOptions: List<DeliveryOption> = emptyList(),
val selectedDelivery: DeliveryOption? = null,
val subtotal: Money = Money.ZERO,
val discount: Money = Money.ZERO,
val deliveryFee: Money = Money.ZERO,
val total: Money = Money.ZERO,
val isLoading: Boolean = false,
val error: CheckoutError? = null,
val step: CheckoutStep = CheckoutStep.CART
) {
// Verifiable invariants
init {
require(total == subtotal - discount + deliveryFee) {
"Total inconsistent with components"
}
}
}
sealed class CheckoutIntent {
data class AddItem(val item: CartItem) : CheckoutIntent()
data class RemoveItem(val itemId: String) : CheckoutIntent()
data class SelectAddress(val address: Address) : CheckoutIntent()
data class SelectPayment(val method: PaymentMethod) : CheckoutIntent()
data class ApplyPromo(val code: String) : CheckoutIntent()
data object RemovePromo : CheckoutIntent()
data class SelectDelivery(val option: DeliveryOption) : CheckoutIntent()
data object ProceedToPayment : CheckoutIntent()
data object ConfirmOrder : CheckoutIntent()
}Realtime Applicaties
Voor apps met WebSockets, push-notificaties of realtime synchronisatie beheert MVI meerdere datastromen elegant.
Strikte Debugvereisten
In gereguleerde domeinen (fintech, gezondheidszorg) is de mogelijkheid om een exacte reeks gebeurtenissen te reproduceren onschatbaar.
MVI maakt het eenvoudig "time-travel debugging" te implementeren: alle staten vastleggen en de gebruikerssessie opnieuw afspelen.
Opkomend Alternatief: Circuit
Circuit, ontwikkeld door Slack, biedt een Compose-native MVI-implementatie die het overwegen waard is voor nieuwe projecten. Het combineert presenters met Molecule onder de motorkap, waardoor veel MVI-boilerplate wordt geëlimineerd terwijl de unidirectionele gegevensstroom behouden blijft.
Circuit is bijzonder interessant voor Kotlin Multiplatform-projecten, omdat het het delen van presentatielogica over platforms ondersteunt. De adoptie buiten Slack blijft echter beperkt vergeleken met de standaard Jetpack ViewModel-aanpak.
Hybride Aanpak: Het Beste van Beide Werelden
In de praktijk adopteren veel teams een hybride aanpak: MVI voor complexe schermen, vereenvoudigd MVVM voor eenvoudige schermen. Hier is een aanbevolen patroon:
// Base ViewModel with lightweight MVI structure
// Reusable for all screens
abstract class MviViewModel<S, I>(initialState: S) : ViewModel() {
val state: StateFlow<S>
field = MutableStateFlow(initialState)
protected val currentState: S get() = state.value
// Single entry point for intents
abstract fun onIntent(intent: I)
// Helper to update state
protected fun updateState(reducer: S.() -> S) {
state.update { it.reducer() }
}
}
// Concrete implementation stays simple
class ProfileViewModel(
private val userRepository: UserRepository
) : MviViewModel<ProfileState, ProfileIntent>(ProfileState()) {
override fun onIntent(intent: ProfileIntent) {
when (intent) {
is ProfileIntent.Load -> load(intent.userId)
is ProfileIntent.Refresh -> refresh()
is ProfileIntent.ToggleFavorite -> toggleFavorite()
}
}
private fun load(userId: String) {
viewModelScope.launch {
updateState { copy(isLoading = true) }
val user = userRepository.getUser(userId)
updateState {
copy(user = user, isLoading = false)
}
}
}
private fun refresh() = load(currentState.user?.id ?: return)
private fun toggleFavorite() {
updateState {
copy(user = user?.copy(isFavorite = !user.isFavorite))
}
}
}Deze aanpak biedt de voordelen van MVI (enkele staat, getypeerde intents) zonder overmatige boilerplate.
Aanbevelingen
Hier zijn de aanbevelingen voor het kiezen tussen deze twee architecturen op basis van context:
Voor Nieuwe Projecten met Compose
MVI vanaf het begin adopteren. Compose en MVI delen dezelfde filosofie, en de initiële investering verdient zich snel terug. Met de explicit backing fields van Kotlin 2.4 is het boilerplate-verschil kleiner geworden.
Voor Bestaande View-gebaseerde Projecten
Bij MVVM blijven, maar geleidelijk MVI best practices adopteren: enkele staat in het ViewModel, getypeerde acties met sealed classes.
Voor Grote Teams
Standardiseren op één aanpak en deze documenteren. Consistentie in de codebase is belangrijker dan de keuze van het patroon zelf.
Het beste patroon is het patroon dat het team begrijpt en correct toepast. Een goed geïmplementeerde MVVM verslaat een slecht begrepen MVI.
Bronnen
- Kotlin 2.4.0 What's New - Explicit backing fields zijn nu stabiel en elimineren het
_state / state-boilerplate - Jetpack Compose August 2026 Release - Compose 1.12 met BOM 2026.08.00
- Circuit by Slack - Compose-gebaseerde MVI-architectuur met Molecule-integratie
- Android Architecture Guide - Googles officiële aanbevelingen voor gelaagde architectuur
Conclusie
MVVM en MVI zijn beide geldige benaderingen voor het architectureren van Android-applicaties. MVVM biedt eenvoud en bekendheid, terwijl MVI voorspelbaarheid en eenvoudiger debuggen brengt.
Beslissingschecklist
- MVVM kiezen als: junior team, eenvoudig project, kostbare migratie
- MVI kiezen als: native Compose, complexe staat, kritisch debuggen
- Hybride aanpak aanbevolen: lichtgewicht MVI met enkele staat, zonder over-engineering
- Hoogste prioriteit: consistentie door de gehele codebase
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Wat de keuze ook is, de sleutel ligt in het begrijpen van de sterke en zwakke punten van elke aanpak om een weloverwogen beslissing te nemen. De beste code is code die het team op de lange termijn rustig kan onderhouden.
Zie jij de bug in Android?
Een echt codefragment, een verborgen bug, één poging per dag. Zonder account uit te proberen.

Geschreven door
Anthony Fillion-MailletOprichter van SharpSkill
Al meer dan 10 jaar fullstack-ontwikkelaar. Hij leidt SharpSkill en staat in voor alles wat hier verschijnt.
Bijgewerkt op 19 augustus 2026
Tags
Delen
Gerelateerde artikelen

Jetpack Compose: Geavanceerde Animaties Stap voor Stap
Volledige gids voor geavanceerde Compose-animaties: transities, AnimatedVisibility, Animatable, gestures en performance voor vloeiende Android-interfaces.

De 20 meest gestelde Jetpack Compose interviewvragen in 2026
De 20 meest gestelde Jetpack Compose interviewvragen: recomposition, state management, navigatie, performance en architectuurpatronen.

Jetpack Navigation Compose in 2026: Type-Safe Navigatie en Sollicitatievragen
Een uitgebreide handleiding voor type-safe navigatie met Jetpack Compose. Praktische codevoorbeelden, best practices en veelgestelde interviewvragen voor Android-ontwikkelaars.