Stato di dove sollevare lo stato

In un'applicazione Compose, il cui stato dell'UI dipende dalla necessità o meno della logica dell'interfaccia utente o della logica di business. Questo documento illustra questi due scenari principali.

Best practice

Dovresti sollevare lo stato dell'interfaccia utente al predecessore comune più basso tra tutti gli elementi componibili che lo leggono e lo scrivono. Dovresti mantenere lo stato più vicino a dove viene consumato. Dal proprietario dello stato, esponi ai consumatori lo stato e gli eventi immutabili per modificarlo.

L'antenato comune più basso può anche trovarsi al di fuori della composizione. Ad esempio, quando istruisci lo stato in un ViewModel perché è coinvolta la logica di business.

Questa pagina illustra questa best practice in modo dettagliato e un'avvertenza da tenere presente.

Tipi di stato e logica dell'interfaccia utente

Di seguito sono riportate le definizioni dei tipi di stato e logica dell'interfaccia utente utilizzati in questo documento.

Stato UI

Lo stato UI è la proprietà che descrive l'interfaccia utente. Esistono due tipi di stato dell'interfaccia utente:

  • Lo stato UI schermo è ciò che devi visualizzare sullo schermo. Ad esempio, una classe NewsUiState può contenere gli articoli e altre informazioni necessarie per il rendering dell'interfaccia utente. Questo stato è solitamente collegato ad altri livelli della gerarchia perché contiene dati dell'app.
  • Lo stato degli elementi UI si riferisce a proprietà intrinseche agli elementi dell'interfaccia utente che influiscono sul modo in cui vengono visualizzati. Un elemento dell'interfaccia utente può essere mostrato o nascosto e può avere un carattere, una certa dimensione o un colore del carattere. Nelle viste Android, la vista gestisce questo stato in quanto è intrinsecamente stateful, mostrando metodi per modificarne lo stato o eseguire query. Un esempio di ciò sono i metodi get e set della classe TextView per il relativo testo. In Jetpack Compose, lo stato è esterno al componibile e puoi persino sollevarlo dalle immediate vicinanze del componibile nella funzione componibile chiamante o in un contenitore di stato. Un esempio è ScaffoldState per l'elemento componibile Scaffold.

Logica

La logica in un'applicazione può essere una logica di business o di UI:

  • La logica di business è l'implementazione dei requisiti di prodotto per i dati delle app. Ad esempio, aggiungere ai preferiti un articolo in un'app di lettori di notizie quando l'utente tocca il pulsante. Questa logica per salvare un preferito in un file o in un database è solitamente inserita nel dominio o nei livelli dati. Il titolare dello stato di solito delega questa logica ai livelli richiamando i metodi che espongono.
  • La logica dell'UI dipende dalla modalità di visualizzazione dello stato dell'UI sullo schermo. Ad esempio, il suggerimento nella barra di ricerca corretta quando l'utente ha selezionato una categoria, lo scorrimento di un determinato elemento in un elenco o la logica di navigazione a una determinata schermata quando l'utente fa clic su un pulsante.

Logica UI

Quando la logica UI deve leggere o scrivere lo stato, devi definire l'ambito dell'interfaccia utente seguendo il suo ciclo di vita. Per ottenere questo risultato, devi sollevare lo stato al livello corretto in una funzione componibile. In alternativa, puoi farlo in una classe di titolare dello stato normale, anch'essa con l'ambito del ciclo di vita della UI.

Di seguito sono riportate entrambe le soluzioni e una spiegazione di quando utilizzarle.

Elementi componibili come proprietario dello stato

Avere la logica dell'interfaccia utente e lo stato degli elementi UI nei componibili è un buon approccio se lo stato e la logica sono semplici. Puoi lasciare lo stato interno a un componibile o un paranco come necessario.

Non è necessario sollevare lo stato

Lo stato di sollevamento non è sempre obbligatorio. Lo stato può essere mantenuto all'interno di un componibile quando nessun altro componibile ha bisogno di controllarlo. In questo snippet è presente un componibile che si espande e si comprime al tocco:

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

La variabile showDetails è lo stato interno di questo elemento UI. È solo letto e modificato in questo componibile e la logica applicata è molto semplice. In questo caso, sollevare lo stato non porterebbe molti benefici, quindi puoi lasciarlo all'interno. Ciò rende componibile il proprietario e l'unica fonte attendibile dello stato espanso.

Sollevamento all'interno di componibili

Se devi condividere lo stato di un elemento UI con altri componibili e applicare la logica dell'interfaccia utente in punti diversi, puoi sollevarlo più in alto nella gerarchia dell'interfaccia utente. Inoltre, i componibili sono più riutilizzabili e facili da testare.

L'esempio seguente è un'app di chat che implementa due funzionalità:

  • Il pulsante JumpToBottom consente di scorrere l'elenco dei messaggi fino in fondo. Il pulsante esegue la logica dell'interfaccia utente nello stato dell'elenco.
  • L'elenco MessagesList scorre fino in fondo dopo che l'utente invia nuovi messaggi. UserInput esegue la logica UI nello stato dell'elenco.
App di chat con un pulsante Vai in basso e scorri verso il basso per i nuovi messaggi
Figura 1. App di chat con un pulsante JumpToBottom e scorri verso il basso nei nuovi messaggi

La gerarchia componibile è la seguente:

Albero componibile di Chat
Figura 2. Albero componibile di Chat

Lo stato LazyColumn viene iscritta alla schermata della conversazione in modo che l'app possa eseguire la logica dell'interfaccia utente e leggere lo stato da tutti i componibili che lo richiedono:

Sollevamento dello stato LazyColumn da LazyColumn alla ConversationScreen
Figura 3. Sollevamento dello stato LazyColumn da LazyColumn a ConversationScreen

Infine, i componibili sono:

Albero componibile di Chat con LazyListState attivato su ConversationScreen
Figura 4. Albero componibile di Chat con LazyListState sollevato a ConversationScreen

Il codice è il seguente:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState viene sollevato all'altezza necessaria per la logica dell'interfaccia utente da applicare. Poiché è inizializzata in una funzione componibile, viene archiviata nella composizione, seguendo il suo ciclo di vita.

Tieni presente che lazyListState è definito nel metodo MessagesList, con il valore predefinito di rememberLazyListState(). Questo è un pattern comune in Compose. Questo rende i componibili più riutilizzabili e flessibili. Puoi quindi usare l'elemento componibile in diverse parti dell'app, per cui non è necessario controllarne lo stato. Questo avviene di solito durante il test o l'anteprima di un componibile. Questo è esattamente il modo in cui LazyColumn definisce il suo stato.

L&#39;antenato comune minimo per LazyListState è ConversationScreen
Figura 5. Il predecessore comune minimo di LazyListState è ConversationScreen

Classe del titolare dello stato normale come proprietario dello stato

Quando un componibile contiene una logica di interfaccia utente complessa che coinvolge uno o più campi di stato di un elemento UI, dovrebbe delegare questa responsabilità ai contenenti di stato, ad esempio una classe titolare di stato semplice. Ciò rende la logica del componibile più testabile in isolamento e ne riduce la complessità. Questo approccio favorisce il principio di separazione delle preoccupazioni: il componibile è responsabile dell'emissione di elementi UI e il titolare dello stato contiene la logica dell'interfaccia utente e lo stato degli elementi dell'interfaccia utente.

Le classi proprietario dello stato normale offrono utili funzioni ai chiamanti della tua funzione componibile, in modo che non debbano scrivere personalmente questa logica.

Queste classi semplici vengono create e memorizzate nella Composizione. Poiché seguono il ciclo di vita del componibile, possono accettare tipi forniti dalla libreria di scrittura, come rememberNavController() o rememberLazyListState().

Un esempio di ciò è la classe LazyListState plain_state holder, implementata in Compose per controllare la complessità della UI di LazyColumn o LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState incapsula lo stato di LazyColumn in cui è archiviato scrollPosition per questo elemento UI. Inoltre, vengono esposti i metodi per modificare la posizione di scorrimento, ad esempio scorrendo fino a un determinato elemento.

Come puoi vedere, l'incremento delle responsabilità di un componibile aumenta la necessità di un proprietario di stato. Le responsabilità potrebbero trovarsi nella logica dell'interfaccia utente o solo nella quantità di stato da tenere traccia.

Un altro pattern comune è l'utilizzo di una classe di stati componibili semplice per gestire la complessità delle funzioni componibili principali nell'app. Puoi utilizzare questa classe per incapsulare lo stato a livello di app, ad esempio lo stato di navigazione e le dimensioni dello schermo. Una descrizione completa di questo processo è disponibile nella pagina logica UI e relativo stato del proprietario.

Logica di business

Se le classi dei componenti componibili e dei titolari di stati semplici sono responsabili della logica dell'interfaccia utente e dello stato dell'elemento UI, un titolare dello stato a livello di schermata si occupa delle seguenti attività:

  • Fornisce l'accesso alla logica di business dell'applicazione, generalmente posizionata in altri livelli della gerarchia, come il livello aziendale e quello di dati.
  • Preparazione dei dati dell'applicazione per la presentazione in una schermata particolare, che diventa lo stato dell'interfaccia utente della schermata.

ViewModels come proprietario dello stato

I vantaggi dei modelli AAC ViewModel nello sviluppo per Android li rendono adatti a fornire accesso alla logica di business e a preparare i dati dell'applicazione per la presentazione sullo schermo.

Quando istruisci lo stato dell'interfaccia utente nell'elemento ViewModel, lo sposti al di fuori della composizione.

Lo stato istruito al ViewModel viene archiviato al di fuori della composizione.
Figura 6. Lo stato istruito su ViewModel viene memorizzato al di fuori della composizione.

I modelli ViewModel non vengono archiviati come parte della composizione. Sono forniti dal framework e hanno come ambito una ViewModelStoreOwner che può essere un'attività, un frammento, un grafico di navigazione o la destinazione di un grafico di navigazione. Per ulteriori informazioni sugli ambiti ViewModel, puoi consultare la documentazione.

Quindi, ViewModel è la fonte attendibile e il predecessore più basso per lo stato dell'UI.

Stato UI schermata

Come indicato nelle definizioni precedenti, lo stato dell'interfaccia utente delle schermate viene generato applicando regole aziendali. Dato che ne è responsabile il proprietario dello stato a livello di schermata, ciò significa che lo stato dell'interfaccia utente della schermata è in genere attivato nello stato a livello di schermata, in questo caso un ViewModel.

Considera il ConversationViewModel di un'app di chat e come espone lo stato dell'UI della schermata e gli eventi per modificarlo:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

I componenti componibili consumano lo stato dell'interfaccia utente della schermata visualizzato in ViewModel. Devi inserire l'istanza ViewModel nei componenti componibili a livello di schermo per fornire l'accesso alla logica di business.

Di seguito è riportato un esempio di ViewModel utilizzato in un componibile a livello di schermo. In questo caso, l'elemento componibile ConversationScreen() utilizza lo stato dell'UI della schermata visualizzato in ViewModel:

@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)
    /* ... */
}

foratura di proprietà

Per "analisi dettagliata delle proprietà" si intende il passaggio di dati attraverso diversi componenti secondari nidificati nella posizione in cui vengono letti.

Un esempio tipico di come in Compose può essere visualizzata la visualizzazione in dettaglio delle proprietà è l'inserimento del titolare dello stato a livello di schermo al livello più alto e il trasferimento di stato e eventi agli elementi componibili secondari. Ciò potrebbe anche generare un sovraccarico di firme di funzioni componibili.

Anche se l'esposizione degli eventi come singoli parametri lambda potrebbe sovraccaricare la firma della funzione, massimizza la visibilità delle responsabilità della funzione componibile. Puoi vedere a colpo d'occhio ciò che fa.

La visualizzazione in dettaglio delle proprietà è preferibile rispetto alla creazione di classi wrapper per incapsulare stato ed eventi in un'unica posizione, poiché ciò riduce la visibilità delle responsabilità componibili. Se non utilizzi le classi di wrapper, è più probabile inoltre che i componenti componibili vengano trasferiti solo i parametri necessari, il che rappresenta una best practice.

La stessa best practice si applica se questi eventi sono eventi di navigazione. Per saperne di più, consulta i documenti sulla navigazione.

Se hai identificato un problema di prestazioni, puoi anche scegliere di posticipare la lettura dello stato. Per ulteriori informazioni, puoi consultare la documentazione sul rendimento.

Stato degli elementi UI

Puoi sollevare lo stato dell'elemento UI al titolare dello stato a livello di schermo se c'è una logica di business che deve leggerlo o scriverlo.

Proseguendo con l'esempio di un'app di chat, l'app mostra i suggerimenti degli utenti in una chat di gruppo quando l'utente digita @ e un suggerimento. Questi suggerimenti provengono dal livello dati e la logica per calcolare un elenco di suggerimenti degli utenti è considerata logica di business. L'elemento ha il seguente aspetto:

Funzionalità che mostra i suggerimenti degli utenti in una chat di gruppo quando l&#39;utente digita &quot;@&quot; e un suggerimento
Figura 7. Funzionalità che mostra i suggerimenti degli utenti in una chat di gruppo quando l'utente digita @ e un suggerimento

I ViewModel che implementano questa funzionalità avranno il seguente aspetto:

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessage è una variabile che memorizza lo stato TextField. Ogni volta che l'utente digita un nuovo input, l'app chiama la logica di business per produrre suggestions.

suggestions è lo stato dell'UI della schermata e viene utilizzato dall'UI di Compose mediante la raccolta da StateFlow.

Avvertenza

Per alcuni stati dell'elemento dell'interfaccia utente di Compose, il sollevamento di ViewModel potrebbe richiedere considerazioni speciali. Ad esempio, alcuni stati degli elementi UI di Compose mostrano metodi per modificare lo stato. Alcune di queste potrebbero essere funzioni di sospensione che attivano animazioni. Queste funzioni di sospensione possono generare eccezioni se le chiami da una CoroutineScope non limitata all'ambito della composizione.

Supponiamo che i contenuti del riquadro a scomparsa dell'app siano dinamici e che tu debba recuperarli e aggiornarli dal livello dati dopo la chiusura. Dovresti sollevare lo stato del riquadro a scomparsa su ViewModel in modo da poter chiamare sia l'UI che la logica di business di questo elemento dal proprietario dello stato.

Tuttavia, la chiamata al metodo close() di DrawerState utilizzando viewModelScope dall'interfaccia utente di Compose provoca un'eccezione di runtime di tipo IllegalStateException con il messaggio "MonotonicFrameClock non è disponibile in CoroutineContext”.

Per risolvere il problema, utilizza un CoroutineScope con ambito alla composizione. Fornisce un valore MonotonicFrameClock in CoroutineContext necessario al funzionamento delle funzioni di sospensione.

Per correggere questo arresto anomalo, cambia il valore CoroutineContext della coroutine in ViewModel con uno che abbia come ambito la composizione. Potrebbe avere il seguente aspetto:

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

Scopri di più

Per saperne di più sullo stato e su Jetpack Compose, consulta le seguenti risorse aggiuntive.

Campioni

Codelab

Video