15.1 Architektúra JetPack Compose aplikácií

Kódu, ktorý píšeme okolo nášho UI sa začína množiť. Zrazu potrebujeme nejaké CoroutineScope, voláme HTTP requesty a parsujeme JSONy priamo vedľa vykresľovania tlačidla. Akonáhle pridáme trocho viac aplikačnej logiky, náš projekt bude kopa sraček.

Existujú viaceré metódy, akými vieme organizovať aplikačnú logiku, konkrétne:

  1. MVC - model-view-controller
  2. MVP - model-view-presenter
  3. MVVM - model-view-viewmodel
  4. MVI - model-view-intent

Všetky 4 majú spoločné písmenka M ako Model (manipulácia s dátami) a V ako View (UI). Zvyšné písmenka popisujú mierne odlišné metódy komunikácie medzi Modelom a Viewom. Z týchto sa v Androide používajú výlučne MVVM a MVI. Ja vám ukážem MVVM, ale MVI sa v praxi používa častejšie, lebo je podstatne prehľadnejší pre čítanie.

ViewModel

Centrom MVVM aj MVI je trieda ViewModel. Keď UI potrebuje získať dáta, alebo vykonať nejakú akciu, ktorá priamo nesúvisí s UI, zavoláme jednoducho funkciu z ViewModel - čo je len jednoduchá samostatná trieda, ktorá prežije aj configuration changes napríklad zmenu orientácie telefónu, či zmenu jazyka. Disponuje navyše vlastným CoroutineScope, takže už viac nebudeme potrebovať volať rememberCoroutineScope v UI.

Závislosti potrebujeme nasledovné:

dependencies {
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
}

Vo ViewModel-e môžeme definovať funkcie, ktoré budú volané z UI. Aby sme UI notifikovali o zmenách, budeme potrebovať pracovať s MutableStateFlow-om, na ktorý vie UI počúvať. Nikdy aktívne neposielame dáta z ViewModelu do UI.


// MV(ViewModel)
class DashboardViewModel : ViewModel() {

    val apiState = MutableStateFlow("idle") // state pocuvatelny na UI

    fun addTodo(todo: AddTodoRequest) {
        viewModelScope.launch {             // kazdy viewmodel ma vlastny CoroutineScope - nemusime ho vytvarat
            apiState.emit("loading")        // zmen stav na loading
            // do some work
            apiState.emit("success")        // zmen stav na success
        }
    }
}

A následne v UI

  1. získame ViewModel
  2. pošleme mu úlohu na vypracovanie
  3. počúvame na zmeny, ktoré ViewModel vyvolá
  4. zmeny zobrazíme na UI
// M(View)VM
setContent {
    val viewModel = viewModel<DashboardViewModel>()                 // (1) ziskanie viewmodelu
    val apiState by viewModel.apiState.collectAsState()             // (3) pocuvam na zmenu premennej apiState

    Button(onClick = {
        viewModel.addTodo(AddTodoRequest("New todo", false, 1))     // (2) volam funkciu addTodo z ViewModelu
    }) { Text(apiState) }                                           // (4) zobrazujem aktualny stav apiState
}

Ako môžeš vidieť. View časť sa značne zjednoduší a sprehľadní. Všetku aplikačnú logiku sme odsunuli do ViewModelu.

MVI (advanced)

Model-View-Intent tiež pracuje s triedou ViewModel, ale iným princípom:

  1. v MVVM nám nič nebráni z funkcie addTodo vrátiť aj nejaký výsledok. V MVI to je zakázané - UI sa updatuje vždy výlučne počúvaním zmien v MutableStateFlow
  2. v MVI máme len jednu funkciu, ktorú môžeme volať - obvykle sa volá sendIntent. Tá prijíma takzvaný Intent, často sealed triedu

Prikladám jednoduchú ukážku ViewModelu v architektúre MVI:

sealed class DashboardIntent {
    data class AddTodo(val todo: AddTodoRequest) : DashboardIntent()
    object Refresh : DashboardIntent()
}

class DashboardViewModel : ViewModel() {

    fun sendIntent(intent: DashboardIntent) {
        when (intent) {
            is DashboardIntent.AddTodo -> addTodo(intent.todo)
            DashboardIntent.Refresh -> refresh()
        }
    }
    
    private fun addTodo(todo: AddTodoRequest) { }
    private fun refresh() { }

}

Dodatky

Single UiState

V MVI máme radi použitie jediného objektu, ktorý drží komplet stav celej obrazovky, na ktorú je ViewModel naviazaný. Preto často uvidíš len jediný MutableStateFlow v danom ViewModeli.

ViewModel invoked Events

V MVVM aj MVI niekedy potrebujeme riešiť one-shot akcie, ktoré sa majú vykonať na UI. Tu sa nehodí používať MutableStateFlow-y, lebo ich úlohou je držať state (vidíš slovíčko State v MutableStateFlow?). Pre takéto one-shot akcie používame Channel-y:

sealed interface DashboardEvent {
    data object OnTodoAdded : DashboardEvent
}

class DashboardViewModel : ViewModel() {
    
    private val _events = Channel<DashboardEvent>()
    val events = _events.receiveAsFlow()

    private fun addTodo(todo: AddTodoRequest) { 
        viewModelScope.launch {
            _events.send(DashboardEvent.OnTodoAdded)
        }
    }
}

Použitie vo View je nasledovné:

setContent {
    val viewModel = viewModel<DashboardViewModel>()
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.events.collect { event ->
                when (event) {
                    is DashboardEvent.OnTodoAdded -> {
                        // tu mozem aktualizovat nejaky state...
                    }
                }
            }
        }
    }
}

Použitý je tu takzvaný LaunchedEffect, ktorý vysvetlím až v téme 18.1 LaunchedEffect.