Architettura dell'interfaccia utente di composizione

In Compose, l'interfaccia utente è immutabile, pertanto non è possibile aggiornarla dopo disegnarla. Puoi controllare lo stato dell'interfaccia utente. Ogni volta che lo stato dell'interfaccia utente cambia, Compose ricrea le parti della struttura ad albero che sono cambiate. I componibili possono accettare stato ed esporre eventi, ad esempio un TextField accetta un valore ed espone un callback onValueChange che richiede al gestore di callback di modificare il valore.

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Poiché i componibili accettano lo stato e gli eventi di esposizione, il pattern del flusso di dati unidirezionale si adatta bene a Jetpack Compose. Questa guida è incentrata sull'implementazione del pattern di flusso di dati unidirezionale in Compose, sull'implementazione di eventi e dei titolari di stato e su come lavorare con ViewModels in Compose.

Flusso di dati unidirezionale

Un flusso di dati unidirezionale (UDF) è un pattern di progettazione in cui lo stato scorre verso il basso e gli eventi scorrono. Seguendo il flusso di dati unidirezionale, puoi disaccoppiare i componibili che mostrano lo stato nell'interfaccia utente dalle parti dell'app che archiviano e cambiano stato.

Il loop di aggiornamento dell'interfaccia utente per un'app che utilizza un flusso di dati unidirezionale ha il seguente aspetto:

  • Evento: parte dell'interfaccia utente genera un evento e lo passa verso l'alto, ad esempio un clic su un pulsante passato al modello ViewModel per la gestione oppure un evento viene trasmesso da altri livelli dell'app, ad esempio per indicare che la sessione utente è scaduta.
  • Stato dell'aggiornamento: un gestore di eventi potrebbe cambiare lo stato.
  • Stato di visualizzazione: il proprietario dello stato trasmette lo stato e lo visualizza nell'interfaccia utente.

Figura 1. Flusso di dati unidirezionale.

Seguire questo schema quando si utilizza Jetpack Compose offre diversi vantaggi:

  • Testabilità: il disaccoppiamento dello stato dalla UI che la visualizza semplifica i test isolati.
  • Incapsulamento degli stati: poiché lo stato può essere aggiornato in una sola posizione e esiste un'unica fonte attendibile per lo stato di un componibile, è meno probabile che vengano creati bug a causa di stati incoerenti.
  • Coerenza della UI: tutti gli aggiornamenti dello stato sono immediatamente visibili nell'interfaccia utente tramite l'uso di contenitori di stato osservabili, come StateFlow o LiveData.

Flusso di dati unidirezionale in Jetpack Compose

I componibili funzionano in base allo stato e agli eventi. Ad esempio, un TextField viene aggiornato solo quando il relativo parametro value viene aggiornato ed espone un callback onValueChange, un evento che richiede la modifica del valore in uno nuovo. Compose definisce l'oggetto State come contenitore del valore e le modifiche al valore dello stato attivano una ricomposizione. Puoi mantenere lo stato in remember { mutableStateOf(value) } o rememberSaveable { mutableStateOf(value), a seconda di quanto tempo devi ricordare il valore.

Il tipo di valore del componibile TextField è String, quindi può provenire da qualsiasi origine, da un valore impostato come hardcoded, da un modello ViewModel o trasmesso dall'elemento componibile principale. Non è necessario conservarlo in un oggetto State, ma devi aggiornare il valore quando viene chiamato onValueChange.

Definire i parametri componibili

Quando si definiscono i parametri di stato di un componibile, occorre tenere a mente le seguenti domande:

  • Quanto è riutilizzabile o flessibile il componibile?
  • In che modo i parametri di stato influiscono sulle prestazioni di questo componibile?

Per favorire il disaccoppiamento e il riutilizzo, ogni componibile deve contenere la minor quantità di informazioni possibile. Ad esempio, quando crei un componibile che contenga l'intestazione di un articolo, preferisci trasmettere solo le informazioni che devono essere visualizzate, piuttosto che l'intero articolo:

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

A volte l'utilizzo di parametri individuali migliora anche le prestazioni; ad esempio, se News contiene più informazioni rispetto a title e subtitle, ogni volta che viene trasferita una nuova istanza di News in Header(news), l'elemento componibile viene ricomposto, anche se title e subtitle non sono cambiati.

Considera con attenzione il numero di parametri da inserire. Avere una funzione con troppi parametri riduce l'ergonomia della funzione, quindi in questo caso è preferibile raggrupparle in una classe.

Eventi in Compose

Ogni input nell'app deve essere rappresentato come un evento: tocchi, modifiche di testo e persino timer o altri aggiornamenti. Poiché questi eventi cambiano lo stato dell'interfaccia utente, ViewModel dovrebbe essere quella che li gestisce e deve aggiornare lo stato dell'UI.

Il livello UI non dovrebbe mai cambiare stato al di fuori di un gestore di eventi perché ciò potrebbe introdurre incoerenze e bug nell'applicazione.

Preferisci il trasferimento di valori immutabili per le funzioni lambda del gestore di eventi e di stato. Questo approccio offre i seguenti vantaggi:

  • Migliorerai la riutilizzabilità.
  • Ti assicuri che la tua UI non modifichi direttamente il valore dello stato.
  • Evita problemi di contemporaneità perché ti assicuri che lo stato non sia cambiato da un altro thread.
  • Spesso riduci la complessità del codice.

Ad esempio, un componibile che accetta String e un lambda come parametri può essere chiamato da molti contesti ed è altamente riutilizzabile. Supponiamo che la barra dell'app superiore dell'app mostri sempre del testo e abbia un pulsante Indietro. Puoi definire un componibile MyAppTopAppBar più generico che riceve il testo e l'handle del pulsante Indietro come parametri:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                    Icons.Filled.ArrowBack,
                    contentDescription = localizedString
                )
            }
        },
        // ...
    )
}

ViewModel, stati ed eventi: un esempio

Utilizzando ViewModel e mutableStateOf, puoi anche introdurre un flusso di dati unidirezionale nella tua app se una delle seguenti condizioni è vera:

  • Lo stato della tua UI è esposto tramite contenitori di stati osservabili, come StateFlow o LiveData.
  • L'elemento ViewModel gestisce gli eventi provenienti dalla UI o da altri livelli della tua app e aggiorna il titolare dello stato in base agli eventi.

Ad esempio, quando implementi una schermata di accesso, toccando un pulsante Accedi dovrebbe visualizzare una rotellina di avanzamento e una chiamata di rete nell'app. Se l'accesso è riuscito, l'app passa a un'altra schermata; in caso di errore, l'app mostra uno Snackbar. Ecco come definire un modello dello stato della schermata e dell'evento:

La schermata ha quattro stati:

  • Connesso: quando l'utente non ha ancora eseguito l'accesso.
  • In corso: quando l'app sta attualmente tentando di far accedere l'utente tramite una chiamata di rete.
  • Errore: quando si è verificato un errore durante l'accesso.
  • Connesso: quando l'utente ha eseguito l'accesso.

Puoi modellare questi stati come classe sealed. ViewModel espone lo stato come State, imposta lo stato iniziale e lo aggiorna in base alle esigenze. L'ViewModel gestisce anche l'evento di accesso esponendo un metodo onSignIn().

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(UiState.SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

Oltre all'API mutableStateOf, Compose fornisce estensioni per LiveData, Flow e Observable per registrarsi come listener e rappresentare il valore come uno stato.

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(UiState.SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}

Scopri di più

Per scoprire di più sull'architettura in Jetpack Compose, consulta le seguenti risorse:

Campioni