Il ruolo della UI è mostrare i dati dell'applicazione sullo schermo. L'interfaccia utente funge anche da punto principale di interazione con l'utente. Ogni volta che i dati cambiano, a causa dell'interazione dell'utente (ad esempio la pressione di un pulsante) o di un input esterno (ad esempio una risposta di rete), la UI si aggiorna per riflettere queste modifiche. In pratica, la UI è una rappresentazione visiva dello stato dell'applicazione recuperato dal livello dati.
Tuttavia, i dati dell'applicazione che ottieni dal livello dati sono in genere in un formato diverso rispetto alle informazioni che devi visualizzare. Ad esempio, potresti aver bisogno solo di una parte dei dati per la UI o di unire due origini dati diverse per presentare informazioni pertinenti per l'utente. Indipendentemente dalla logica applicata, devi passare alla UI tutte le informazioni necessarie per il rendering completo. Il livello UI è la pipeline che converte le modifiche ai dati dell'applicazione in un formato che la UI può presentare e poi visualizzare.
Un case study di base
Considera un'app che recupera articoli di notizie per un utente. L'app ha una schermata degli articoli che presenta gli articoli disponibili per la lettura e consente agli utenti che hanno eseguito l'accesso di aggiungere ai preferiti gli articoli che ritengono più interessanti. Dato che potrebbero esserci molti articoli in qualsiasi momento, il lettore deve essere in grado di sfogliarli per categoria. In sintesi, l'app consente agli utenti di:
- Visualizza gli articoli disponibili per la lettura.
- Sfoglia gli articoli per categoria.
- Accedere e aggiungere determinati articoli ai preferiti.
- Accedere ad alcune funzionalità premium, se idoneo.
Le sezioni seguenti utilizzano questo esempio come case study per introdurre i principi del flusso di dati unidirezionale, oltre a illustrare i problemi che questi principi aiutano a risolvere nel contesto dell'architettura dell'app per il livello UI.
Architettura del livello UI
Il termine UI si riferisce agli elementi UI come i container e le funzioni componibili che mostrano i dati. Per la creazione di UI Android, il toolkit consigliato è Jetpack Compose. Poiché il ruolo del livello dati è conservare, gestire e fornire l'accesso ai dati dell'app, il livello UI deve eseguire i seguenti passaggi:
- Utilizzare i dati dell'app e trasformarli in dati che la UI può facilmente visualizzare.
- Utilizzare i dati di cui è possibile eseguire il rendering della UI e trasformarli in elementi UI per la presentazione all'utente.
- Utilizza gli eventi di input dell'utente provenienti dagli elementi UI assemblati e rifletti i loro effetti nei dati UI, se necessario.
- Ripeti i passaggi da 1 a 3 per tutto il tempo necessario.
Il resto di questa guida mostra come implementare un livello UI che esegue questi passaggi. In particolare, questa guida illustra le seguenti attività e i seguenti concetti:
- Come definire lo stato della UI
- Flusso di dati unidirezionale (UDF) come mezzo per produrre e gestire lo stato della UI
- Come esporre lo stato della UI con tipi di dati osservabili in base ai principi UDF
- Come implementare una UI che utilizza lo stato della UI osservabile
Il più fondamentale di questi è la definizione dello stato della UI.
Definisci lo stato della UI
Nel case study descritto in precedenza, la UI mostra un elenco di articoli insieme ad alcuni metadati per ogni articolo. Queste informazioni che l'app presenta all'utente costituiscono lo stato della UI.
In altre parole, se la UI è ciò che l'utente vede, lo stato della UI è ciò che l'app dice che l'utente dovrebbe vedere. Come due facce della stessa medaglia, la UI è la rappresentazione visiva dello stato della UI. Qualsiasi modifica allo stato della UI si riflette immediatamente nella UI.
Considera il case study: per soddisfare i requisiti dell'app News, le informazioni necessarie per eseguire il rendering completo della UI possono essere incapsulate in una classe di dati NewsUiState definita come segue:
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf(), val userMessages: List<Message> = listOf() ) data class NewsItemUiState( val title: String, val body: String, val bookmarked: Boolean = false, // ... )
Per saperne di più sullo stato della UI, consulta Stato e Jetpack Compose.
Immutabilità
La definizione dello stato della UI nell'esempio precedente è immutabile. Il vantaggio principale è che gli oggetti immutabili forniscono garanzie sullo stato dell'applicazione in un istante di tempo. In questo modo, la UI può concentrarsi sul suo ruolo principale: leggere lo stato e aggiornare di conseguenza i suoi elementi UI. Non modificare mai lo stato della UI direttamente nella UI, a meno che la UI stessa non sia l'unica origine dei suoi dati. La violazione di questo principio comporta la presenza di più fonti di verità per la stessa informazione, il che porta a incoerenze dei dati e bug impercettibili.
Ad esempio, considera il case study precedente.
Se il flag bookmarked in un oggetto NewsItemUiState dello stato della UI viene aggiornato nella classe Activity, questo flag compete con il livello dati come origine dello stato dei preferiti di un articolo. Le classi di dati immutabili sono molto utili per prevenire questo tipo di incoerenza.
Convenzioni di denominazione in questa guida
In questa guida, i nomi delle classi dello stato della UI si basano sulla funzionalità della schermata o della parte della schermata che descrivono. La convenzione è la seguente:
funzionalità + UiState.
Ad esempio, lo stato di una schermata che mostra le notizie potrebbe essere chiamato
NewsUiState, mentre lo stato di una notizia in un elenco di notizie potrebbe essere
NewsItemUiState.
Gestisci lo stato con il flusso di dati unidirezionale
La sezione precedente ha stabilito che lo stato della UI è uno snapshot immutabile dei dettagli necessari per il rendering della UI. Tuttavia, la natura dinamica dei dati nelle app implica che lo stato può cambiare nel tempo. Ciò potrebbe essere dovuto all'interazione dell'utente o ad altri eventi che modificano i dati sottostanti utilizzati per compilare l'app.
Queste interazioni possono trarre vantaggio da un mediatore che le elabori, definendo la logica da applicare a ogni evento e trasformando le origini dati di supporto per creare lo stato della UI. Sebbene queste interazioni e la loro logica possano essere ospitate nell'interfaccia utente stessa, ciò può diventare rapidamente difficile da gestire, poiché l'interfaccia utente si assume troppe responsabilità. Inoltre, questo può influire sulla testabilità, perché il codice risultante è strettamente accoppiato. A meno che lo stato della UI non sia molto semplice, assicurati che l'unica responsabilità della UI sia quella di utilizzare e visualizzare lo stato della UI.
Questa sezione illustra il flusso di dati unidirezionale (UDF), un pattern di architettura che aiuta a garantire questa separazione delle responsabilità.
Contenitori di stato
I contenitori di stato sono le classi responsabili della produzione dello stato della UI e della logica necessaria per produrlo. I contenitori di stato sono di varie dimensioni a seconda dell'ambito degli elementi UI corrispondenti che gestiscono, da un singolo widget come una barra delle app in basso a un'intera schermata o una destinazione di navigazione.
In quest'ultimo caso, l'implementazione tipica è un'istanza di un ViewModel, anche se, a seconda dei requisiti dell'applicazione, potrebbe essere sufficiente una classe semplice. Ad esempio, l'app News del case study utilizza una classe NewsViewModel come contenitore di stato per produrre lo stato della UI per la schermata visualizzata in quella sezione.
Esistono molti modi per modellare la codipendenza tra la UI e il relativo producer di stato. Tuttavia, poiché l'interazione tra la UI e la relativa classe ViewModel può essere ampiamente intesa come input di eventi e il relativo output di stato, la relazione può essere rappresentata come mostrato nel seguente diagramma:
Il pattern in cui lo stato scende e gli eventi salgono è chiamato flusso di dati unidirezionale (UDF). Le implicazioni di questo pattern per l'architettura dell'app sono le seguenti:
- Il ViewModel contiene ed espone lo stato che deve essere utilizzato dalla UI. Lo stato della UI è costituito dai dati dell'applicazione trasformati dal ViewModel.
- La UI comunica gli eventi utente al ViewModel.
- Il ViewModel gestisce le azioni dell'utente e aggiorna lo stato.
- Lo stato aggiornato viene restituito alla UI per il rendering.
- Quanto sopra viene ripetuto per qualsiasi evento che causa una mutazione dello stato.
Per le destinazioni o le schermate di navigazione, ViewModel collabora con i repository o le classi dei casi d'uso per ottenere i dati e trasformarli nello stato della UI, incorporando al contempo gli effetti degli eventi che potrebbero causare mutazioni dello stato. Il case study menzionato in precedenza contiene un elenco di articoli, ognuno con un titolo, una descrizione, una fonte, il nome dell'autore, la data di pubblicazione e l'indicazione se è stato aggiunto ai preferiti. La UI per ogni elemento dell'articolo ha il seguente aspetto:
Un utente che richiede di aggiungere un articolo ai preferiti è un esempio di evento che può causare mutazioni di stato. In qualità di produttore dello stato, il ViewModel è responsabile della definizione di tutta la logica necessaria per compilare tutti i campi nello stato della UI ed elaborare gli eventi necessari per il rendering completo della UI.
Le sezioni seguenti esaminano più da vicino gli eventi che causano modifiche di stato e come possono essere elaborati utilizzando la funzione definita dall'utente.
Tipi di logica
Aggiungere un articolo ai preferiti è un esempio di logica di business perché dà valore alla tua app. Per saperne di più, consulta la pagina relativa al livello dei dati. Tuttavia, esistono diversi tipi di logica che è importante definire:
- La logica di business è l'implementazione dei requisiti del prodotto per i dati dell'app. Come già accennato, un esempio è l'aggiunta di un articolo ai preferiti nell'app del case study. La logica di business viene solitamente inserita nei livelli di dominio o di dati, ma mai nel livello UI.
- La logica di comportamento della UI o logica della UI è il modo in cui vengono visualizzate le modifiche di stato sullo schermo. Ad esempio, ottenere il testo corretto da mostrare sullo schermo utilizzando
Resourcesdi Android, passare a una determinata schermata quando l'utente fa clic su un pulsante o visualizzare un messaggio utente sullo schermo utilizzando un toast o una snackbar.
Mantieni la logica della UI nella UI, non nel ViewModel, in particolare quando coinvolge tipi di UI come Context.
Se la complessità della UI aumenta e vuoi delegare la logica della UI a un'altra classe per favorire la testabilità e la separazione delle responsabilità, puoi creare una classe semplice come contenitore di stato. Le classi semplici create nella UI possono assumere dipendenze dall'SDK Android perché seguono il ciclo di vita della UI; gli oggetti ViewModel hanno una durata maggiore.
Per saperne di più sui contenitori di stato e su come si inseriscono nel contesto della creazione della UI, consulta la guida allo stato di Jetpack Compose.
Perché utilizzare le funzioni definite dall'utente?
La funzione definita dall'utente modella il ciclo di produzione dello stato come mostrato nella Figura 4. Inoltre, separa il luogo in cui hanno origine le modifiche di stato, il luogo in cui vengono trasformate e il luogo in cui vengono infine utilizzate. Questa separazione consente alla UI di fare esattamente ciò che il suo nome implica: visualizzare le informazioni osservando le modifiche dello stato e trasmettere lo user intent passando queste modifiche al ViewModel.
In altre parole, la funzione definita dall'utente consente di:
- Coerenza dei dati. Esiste un'unica fonte attendibile per la UI.
- Verificabilità. La fonte dello stato è isolata e quindi testabile indipendentemente dalla UI.
- Manutenibilità. La mutazione dello stato segue un pattern ben definito in cui le mutazioni sono il risultato sia degli eventi utente sia delle origini dati da cui estraggono i dati.
Esporre lo stato della UI
Dopo aver definito lo stato della UI e stabilito come gestire la produzione di questo stato, il passaggio successivo consiste nel presentare lo stato prodotto alla UI.
Quando utilizzi una funzione definita dall'utente per gestire la produzione dello stato, puoi considerare lo stato prodotto come uno stream, ovvero nel tempo vengono prodotte più versioni dello stato. Esponi lo stato della UI in un contenitore di dati osservabili come StateFlow. In questo modo, la UI può reagire a qualsiasi modifica apportata allo stato senza dover estrarre manualmente i dati direttamente dal ViewModel. In questo modo, inoltre, l'ultima versione dello stato della UI viene sempre memorizzata nella cache, il che è utile per un rapido ripristino dello stato dopo le modifiche alla configurazione.
class NewsViewModel( // ... ) : ViewModel() { val uiState: NewsUiState = /* ... */ }
Per un'introduzione ai flussi Kotlin, consulta Flussi Kotlin su Android.
Per scoprire come utilizzare StateFlow come contenitore di dati osservabili, consulta il codelab Stato ed effetti collaterali avanzati in Jetpack Compose.
Nei casi in cui i dati esposti alla UI sono relativamente semplici, spesso vale la pena aggregarli in un tipo di stato della UI, perché questo trasmette la relazione tra l'emissione del contenitore di stato e la schermata o l'elemento UI associato. Man mano che l'elemento UI diventa più complesso, è semplice aggiungerlo alla definizione dello stato della UI, in modo da poter accogliere le informazioni aggiuntive necessarie per eseguire il rendering dell'elemento UI.
Un modo comune per creare uno stream di UiState è esporre una proprietà mutableStateOf con un private set, mantenendo lo stato modificabile all'interno del ViewModel, ma di sola lettura per la UI.
class NewsViewModel( // ... ) : ViewModel() { var uiState by mutableStateOf(NewsUiState()) private set // ... }
Il ViewModel può quindi esporre metodi che modificano internamente lo stato, pubblicando aggiornamenti che l'UI può utilizzare. Prendiamo, ad esempio, il caso in cui
devi eseguire un'azione asincrona.
Puoi avviare una coroutine utilizzando viewModelScope,
quindi aggiornare lo stato modificabile al termine.
class NewsViewModel( private val repository: NewsRepository, // ... ) : ViewModel() { var uiState by mutableStateOf(NewsUiState()) private set private var fetchJob: Job? = null fun fetchArticles(category: String) { fetchJob?.cancel() fetchJob = viewModelScope.launch { try { val newsItems = repository.newsItemsForCategory(category) uiState = uiState.copy(newsItems = newsItems) } catch (ioe: IOException) { // Handle the error and notify the UI when appropriate. val messages = getMessagesFromThrowable(ioe) uiState = uiState.copy(userMessages = messages) } } } }
Nell'esempio precedente, la classe NewsViewModel tenta di recuperare gli articoli
per una determinata categoria e poi riflette il risultato del tentativo, che sia
un successo o
un errore, nello stato della UI, dove la UI può reagire in modo appropriato.
Per saperne di più sulla gestione degli errori, consulta la sezione Mostra gli errori sullo schermo.
Considerazioni aggiuntive
Oltre alle indicazioni precedenti, tieni presente quanto segue quando esponi lo stato della UI:
Utilizza un singolo oggetto stato della UI per gestire gli stati correlati tra loro. In questo modo si riducono le incoerenze e il codice è più facile da comprendere. Se esponi l'elenco delle notizie e il numero di preferiti in due stream diversi, potresti ritrovarti in una situazione in cui uno è stato aggiornato e l'altro no. Quando utilizzi un singolo stream, entrambi gli elementi vengono mantenuti aggiornati. Inoltre, alcune logiche di business potrebbero richiedere una combinazione di origini. Ad esempio, potresti dover mostrare un pulsante per i preferiti solo se l'utente ha eseguito l'accesso e è abbonato a un servizio di notizie premium. Puoi definire una classe di stato della UI nel seguente modo:
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf() ) val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
In questa dichiarazione, la visibilità del pulsante dei preferiti è una proprietà derivata di altre due proprietà. Man mano che la logica di business diventa più complessa, diventa sempre più importante avere una singola classe
UiStatein cui tutte le proprietà siano immediatamente disponibili.Stati della UI: stream singolo o più stream? Il principio guida fondamentale per scegliere se esporre lo stato della UI in un singolo stream o in più stream è la relazione tra gli elementi emessi. I maggiori vantaggi di un'esposizione a un singolo stream sono la praticità e la coerenza dei dati: i consumatori dello stato hanno sempre le informazioni più recenti disponibili in qualsiasi momento. Tuttavia, esistono casi in cui potrebbe essere opportuno utilizzare stream di stato separati dal ViewModel:
Tipi di dati non correlati:alcuni stati necessari per il rendering della UI potrebbero essere completamente indipendenti l'uno dall'altro. In questi casi, i costi di raggruppamento di questi stati diversi potrebbero superare i vantaggi, soprattutto se uno di questi stati viene aggiornato più di frequente rispetto all'altro.
Differenze di
UiState: più campi sono presenti in un oggettoUiState, più è probabile che lo stream venga emesso in seguito all'aggiornamento di uno dei suoi campi. Poiché gli elementi UI non dispongono di un meccanismo di differenziazione per capire se le emissioni consecutive sono diverse o uguali, ogni emissione causa un aggiornamento dell'elemento UI. Ciò significa che potrebbe essere necessaria la mitigazione utilizzando i metodi APIFlowcomedistinctUntilChanged().
Per saperne di più sul rendering e sullo stato della UI, consulta Ciclo di vita dei componibili.
Utilizza lo stato della UI
Per utilizzare lo stream di oggetti UiState nell'interfaccia utente, utilizza l'operatore terminale per il tipo di dati osservabili che stai utilizzando. Ad esempio, per i flussi Kotlin utilizza il metodo collect() o le sue varianti.
Quando utilizzi i contenitori di dati osservabili nella UI, assicurati di considerare il ciclo di vita della UI. Non fare in modo che la UI osservi lo stato della UI quando il composable non viene mostrato all'utente. Per saperne di più su questo argomento, consulta questo blog post. Quando utilizzi i flussi, è meglio gestire i problemi del ciclo di vita con l'ambito della coroutine appropriato e l'API collectAsStateWithLifecycle:
@Composable private fun ConversationScreen( conversationViewModel: ConversationViewModel = viewModel() ) { val messages by conversationViewModel.messages.collectAsStateWithLifecycle() ConversationScreen( messages = messages, onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) } ) } @Composable private fun ConversationScreen( messages: List<Message>, onSendMessage: (Message) -> Unit ) { MessagesList(messages, onSendMessage) /* ... */ }
Mostra le operazioni in corso
Un modo semplice per rappresentare gli stati di caricamento in una classe UiState è con un campo booleano:
data class NewsUiState( val isFetchingArticles: Boolean = false, // ... )
Il valore di questo flag rappresenta la presenza o l'assenza di una barra di avanzamento nell'interfaccia utente.
@Composable fun LatestNewsScreen( modifier: Modifier = Modifier, viewModel: NewsViewModel = viewModel() ) { Box(modifier.fillMaxSize()) { if (viewModel.uiState.isFetchingArticles) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } // Add other UI elements. For example, the list. } }
Mostra gli errori sullo schermo
La visualizzazione degli errori nell'interfaccia utente è simile alla visualizzazione delle operazioni in corso, perché entrambe sono facilmente rappresentate da valori booleani che ne indicano la presenza o l'assenza. Tuttavia, gli errori potrebbero includere anche un messaggio associato da inoltrare all'utente o un'azione associata che riprova l'operazione non riuscita. Pertanto, mentre un'operazione in corso viene caricata o meno, potrebbe essere necessario modellare gli stati di errore con classi di dati che ospitano i metadati appropriati per il contesto dell'errore.
Considera l'esempio precedente che mostrava una barra di avanzamento durante il recupero degli articoli. Se questa operazione genera un errore, potresti voler mostrare all'utente uno o più messaggi che spiegano nel dettaglio cosa è andato storto.
data class Message(val id: Long, val message: String) data class NewsUiState( val userMessages: List<Message> = listOf(), // ... )
Potresti quindi presentare i messaggi di errore all'utente sotto forma di elementi UI come le snackbar. Per saperne di più su come vengono prodotti e utilizzati gli eventi UI, consulta Eventi UI.
Threading e concorrenza
Assicurati che tutto il lavoro eseguito in un ViewModel sia main-safe, ovvero sicuro da chiamare dal thread principale. I livelli di dati e di dominio sono responsabili dello spostamento del lavoro in un thread diverso.
Se un ViewModel esegue operazioni a lunga esecuzione, è anche responsabile dello spostamento di questa logica in un thread in background. Le coroutine Kotlin sono un ottimo modo per gestire le operazioni simultanee e i componenti dell'architettura Jetpack forniscono un supporto integrato. Per scoprire di più sull'utilizzo delle coroutine nelle app per Android, consulta Coroutine Kotlin su Android.
Navigazione
Le modifiche alla navigazione nell'app sono spesso generate da emissioni simili a eventi. Ad esempio, dopo che una classe SignInViewModel esegue un accesso, UiState potrebbe avere un campo isSignedIn impostato su true. Utilizza trigger come questi proprio come quelli trattati nella sezione precedente Utilizza lo stato della UI, ma rimanda l'implementazione dell'utilizzo al componente di navigazione.
Per ulteriori informazioni sulla navigazione nell'interfaccia utente, consulta Navigazione 3.
Paging
La libreria Paging viene utilizzata nella UI con un tipo chiamato PagingData. Poiché PagingData rappresenta e contiene elementi che possono cambiare nel tempo, in altre parole non è un tipo immutabile, non rappresentarlo in uno stato immutabile della UI.
Invece, esponilo dal ViewModel in modo indipendente nel suo stream.
L'esempio seguente mostra l'API Compose della libreria Paging:
@Composable fun MyScreen(flow: Flow<PagingData<String>>) { val lazyPagingItems = flow.collectAsLazyPagingItems() LazyColumn { items( lazyPagingItems.itemCount, key = lazyPagingItems.itemKey { it } ) { index -> val item = lazyPagingItems[index] Text("Item is $item") } } }
Animazioni
Per fornire transizioni di navigazione di primo livello fluide, potresti voler attendere che la seconda schermata carichi i dati prima di avviare l'animazione.
Per ulteriori informazioni sulle transizioni di navigazione, consulta Navigazione 3 e Transizioni di elementi condivisi in Compose.
Risorse aggiuntive
Visualizza contenuti
Esempi
I seguenti esempi di Google mostrano l'utilizzo del livello UI. Esplorali per vedere queste indicazioni in pratica:
Consigliati per te
- Nota: il testo del link viene visualizzato quando JavaScript è disattivato
- Produzione stato UI
- Contenitori di stato e stato della UI {:#mad-arch}
- Guida all'architettura delle app