Stato di dove sollevare lo stato

In un'applicazione Compose, il UI state viene sollevato a seconda che lo richiedano la logica dell'UI o la logica di business. Questo documento descrive questi due scenari principali.

Best practice

Devi sollevare lo stato dell'UI fino al minimo antenato comune tra tutti i composables che lo leggono e lo scrivono. Devi mantenere lo stato il più vicino possibile a dove viene utilizzato. Dal proprietario dello stato, esponi ai consumatori lo stato e gli eventi immutabili per modificare lo stato.

L'antenato comune più recente può anche trovarsi al di fuori della composizione. Ad esempio, quando si esegue l'hosting dello stato in un ViewModel perché è coinvolta la logica di business.

Questa pagina spiega in dettaglio questa best practice e un avviso da tenere presente.

Tipi di stato e logica dell'interfaccia utente

Di seguito sono riportate le definizioni dei tipi di stato e logica della UI utilizzati in questo documento.

Stato dell'UI

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

  • Screen UI state (Stato dell'interfaccia utente dello schermo) è ciò che devi visualizzare sullo schermo. Ad esempio, una classe NewsUiState può contenere gli articoli di notizie e altre informazioni necessarie per il rendering della UI. Questo stato è in genere collegato ad altri livelli della gerarchia perché contiene dati delle app.
  • Lo stato dell'elemento dell'interfaccia utente si riferisce alle proprietà intrinseche degli elementi dell'interfaccia utente che influenzano il modo in cui vengono visualizzati. Un elemento UI può essere mostrato o nascosto e può avere un determinato carattere, dimensione del carattere o colore del carattere. In Jetpack Compose, lo stato è esterno al componibile e puoi anche spostarlo dalla vicinanza immediata del componibile alla funzione componibile chiamante o a un contenitore di stato. Un esempio è ScaffoldState per il composable Scaffold.

Funzione logica

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

  • La logica di business è l'implementazione dei requisiti di prodotto per i dati delle app. Ad esempio, l'aggiunta di un articolo ai preferiti in un'app di lettura di notizie quando l'utente tocca il pulsante. Questa logica per salvare un segnalibro in un file o database viene in genere inserita nei livelli di dominio o dati. Il contenitore di stato in genere delega questa logica a questi livelli chiamando i metodi che espongono.
  • La logica dell'interfaccia utente è correlata a come visualizzare lo stato dell'interfaccia utente sullo schermo. Ad esempio, ottenere il suggerimento giusto per la barra di ricerca quando l'utente ha selezionato una categoria, scorrere fino a un determinato elemento di un elenco o la logica di navigazione a una schermata specifica quando l'utente fa clic su un pulsante.

Logica UI

Quando la logica dell'interfaccia utente deve leggere o scrivere lo stato, devi limitare lo stato all'interfaccia utente, seguendo il suo ciclo di vita. Per farlo, devi sollevare lo stato al livello corretto in una funzione componibile. In alternativa, puoi farlo in una classe di contenitore di stato semplice, anch'essa limitata al ciclo di vita della UI.

Di seguito è riportata una descrizione di entrambe le soluzioni e una spiegazione di quando utilizzare ciascuna.

Composable come proprietario dello stato

Avere la logica dell'interfaccia utente e lo stato degli elementi dell'interfaccia utente nei composable è un buon approccio se lo stato e la logica sono semplici. Puoi lasciare lo stato interno a un componente componibile o sollevarlo in base alle esigenze.

Non è necessario l'innalzamento dello stato

Il sollevamento dello stato non è sempre necessario. Lo stato può essere mantenuto internamente in un composable quando nessun altro composable deve controllarlo. In questo snippet, c'è un composable che si espande e si comprime quando viene toccato:

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

    Text(
        text = AnnotatedString(message.content),
        modifier = Modifier.clickable {
            showDetails = !showDetails // Apply UI logic
        }
    )

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

La variabile showDetails è lo stato interno di questo elemento UI. Viene letto e modificato solo in questo elemento componibile e la logica applicata è molto semplice. In questo caso, l'innalzamento dello stato non apporterebbe molti vantaggi, quindi puoi lasciarlo interno. In questo modo, il componente diventa il proprietario e l'unica fonte di verità dello stato espanso.

Sollevamento all'interno degli elementi composable

Se devi condividere lo stato dell'elemento UI con altri composable e applicarvi la logica UI in posizioni diverse, puoi spostarlo più in alto nella gerarchia UI. In questo modo, i tuoi componenti componibili sono più riutilizzabili e più facili da testare.

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

  • Il pulsante JumpToBottom scorre l'elenco dei messaggi fino in fondo. Il pulsante esegue la logica dell'interfaccia utente sullo stato dell'elenco.
  • L'elenco MessagesList scorre verso il basso dopo che l'utente invia nuovi messaggi. UserInput esegue la logica dell'interfaccia utente sullo stato dell'elenco.
App di chat con un pulsante Vai in basso e scorrimento verso il basso sui nuovi messaggi
Figura 1. App di chat con un pulsante JumpToBottom e scorrimento verso il basso sui nuovi messaggi

La gerarchia dei composable è la seguente:

Albero di composable di chat
Figura 2. Albero componibile della chat

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

Estrazione dello stato di LazyColumn da LazyColumn a ConversationScreen
Figura 3. Sollevamento dello stato LazyColumn da LazyColumn a ConversationScreen

Quindi, infine, i composable sono:

Albero di composizione della chat con LazyListState sollevato a ConversationScreen
Figura 4. Albero componibile della 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é viene inizializzato in una funzione componibile, viene memorizzato 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. Rende i composable più riutilizzabili e flessibili. Puoi quindi utilizzare il composable in diverse parti dell'app che potrebbero non dover controllare lo stato. Di solito è così quando si testa o si visualizza l'anteprima di un componente componibile. È esattamente così che LazyColumn definisce il suo stato.

L&#39;antenato comune più basso per LazyListState è ConversationScreen
Figura 5. Il minimo antenato comune per LazyListState è ConversationScreen

Classe contenitore di stato semplice come proprietario dello stato

Quando un elemento componibile contiene una logica UI complessa che coinvolge uno o più campi di stato di un elemento UI, deve delegare questa responsabilità ai contenitori di stato, come una semplice classe contenitore di stato. In questo modo, la logica del composable è più testabile in isolamento e la sua complessità viene ridotta. Questo approccio favorisce il principio di separazione delle responsabilità: il composable è responsabile dell'emissione degli elementi UI, mentre il contenitore di stato contiene la logica UI e lo stato degli elementi UI.

Le classi di contenitori di stato semplici forniscono funzioni pratiche ai chiamanti della tua funzione componibile, in modo che non debbano scrivere questa logica.

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

Un esempio è la classe LazyListState Plain State Holder, implementata in Compose per controllare la complessità dell'interfaccia utente 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 memorizzando scrollPosition per questo elemento UI. Espone anche metodi per modificare la posizione di scorrimento, ad esempio scorrendo fino a un determinato elemento.

Come puoi vedere, aumentare le responsabilità di un composable aumenta la necessità di un contenitore di stato. Le responsabilità potrebbero riguardare la logica dell'interfaccia utente o semplicemente la quantità di stato da tenere traccia.

Un altro pattern comune è l'utilizzo di una semplice classe contenitore di stato per gestire la complessità delle funzioni componibili radice nell'app. Puoi utilizzare una classe di questo tipo per incapsulare lo stato a livello di app, come lo stato di navigazione e il dimensionamento dello schermo. Una descrizione completa è disponibile nella pagina della logica dell'UI e del relativo contenitore di stato.

Logica di business

Se i composable e le classi semplici di contenitori di stato sono responsabili della logica UI e dello stato degli elementi UI, un contenitore di stato a livello di schermata è responsabile delle seguenti attività:

  • Fornire l'accesso alla logica di business dell'applicazione che di solito si trova in altri livelli della gerarchia, come i livelli aziendale e dei dati.
  • Preparazione dei dati dell'applicazione per la presentazione in una schermata specifica, che diventa lo stato dell'interfaccia utente della schermata.

ViewModel come proprietario dello stato

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

Quando sollevi lo stato dell'UI in ViewModel, lo sposti all'esterno della composizione.

Lo stato sollevato nella ViewModel viene archiviato al di fuori della composizione.
Figura 6. Lo stato sollevato a ViewModel viene memorizzato al di fuori della composizione.

I ViewModel non vengono memorizzati come parte della composizione. Sono forniti dal framework e sono limitati a un ViewModelStoreOwner, che può essere un'attività, un fragment, un grafico di navigazione o una destinazione di un grafico di navigazione. Per maggiori informazioni sugli ambitiViewModel, puoi consultare la documentazione.

Quindi, ViewModel è la fonte di verità e il antenato comune più basso per lo stato dell'interfaccia utente.

Stato dell'interfaccia utente dello schermo

In base alle definizioni riportate sopra, lo stato della UI della schermata viene prodotto applicando le regole aziendali. Poiché il responsabile è il contenitore di stato a livello di schermata, ciò significa che lo stato dell'UI della schermata viene in genere sollevato nel contenitore di stato a livello di schermata, in questo caso un ViewModel.

Considera l'ConversationViewModel di un'app di chat e come espone lo stato e gli eventi dell'interfaccia utente dello schermo 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 composable utilizzano lo stato dell'interfaccia utente dello schermo sollevato in ViewModel. Devi inserire l'istanza ViewModel nei composable a livello di schermata per fornire l'accesso alla logica di business.

Di seguito è riportato un esempio di ViewModel utilizzato in un composable a livello di schermata. Qui, il composable ConversationScreen() utilizza lo stato dell'interfaccia utente dello schermo sollevato 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)
    /* ... */
}

Trivellazione di proprietà

Il "drill-down della proprietà" si riferisce al passaggio dei dati attraverso diversi componenti secondari nidificati alla posizione in cui vengono letti.

Un esempio tipico di dove può apparire il property drilling in Compose è quando inserisci il contenitore di stato a livello di schermata nel livello superiore e passi lo stato e gli eventi ai composable secondari. Inoltre, potrebbe 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 cosa fa a colpo d'occhio.

L'esplorazione delle proprietà è preferibile alla creazione di classi wrapper per incapsulare lo stato e gli eventi in un unico posto, perché riduce la visibilità delle responsabilità dei componenti componibili. Se non hai classi wrapper, è più probabile che tu trasmetta ai composable solo i parametri necessari, il che è una best practice.

La stessa best practice si applica se questi eventi sono eventi di navigazione. Puoi scoprire di più in merito nella documentazione sulla navigazione.

Se hai identificato un problema di prestazioni, puoi anche scegliere di posticipare la lettura dello stato. Per scoprire di più, puoi consultare la documentazione sulle prestazioni.

Stato dell'elemento UI

Puoi trasferire lo stato dell'elemento UI al contenitore di stato a livello di schermo se è presente una logica di business che deve leggerlo o scriverlo.

Continuando con l'esempio di un'app di chat, l'app mostra i suggerimenti per gli 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 per gli utenti è considerata logica di business. La funzionalità ha questo aspetto:

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

L'ViewModel che implementa questa funzionalità avrebbe 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 di TextField. Ogni volta che l'utente digita un nuovo input, l'app chiama la logica di business per produrre suggestions.

suggestions è lo stato dell'interfaccia utente dello schermo e viene utilizzato dall'interfaccia utente Compose raccogliendo dati da StateFlow.

Caveat

Per alcuni stati degli elementi dell'interfaccia utente di Compose, il sollevamento a ViewModel potrebbe richiedere considerazioni speciali. Ad esempio, alcuni proprietari di stato degli elementi dell'interfaccia utente Compose espongono metodi per modificare lo stato. Alcuni potrebbero essere funzioni di sospensione che attivano le animazioni. Queste funzioni di sospensione possono generare eccezioni se le chiami da un CoroutineScope che non è incluso nella composizione.

Supponiamo che i contenuti del riquadro delle app siano dinamici e che tu debba recuperarli e aggiornarli dal data layer dopo la chiusura. Devi sollevare lo stato del riquadro a scomparsa nel ViewModel in modo da poter chiamare sia la UI che la logica di business su questo elemento dal proprietario dello stato.

Tuttavia, la chiamata al metodo close() di DrawerState utilizzando viewModelScope da Compose UI causa un'eccezione di runtime di tipo IllegalStateException con un messaggio che recita "a MonotonicFrameClock is not available in this CoroutineContext”".

Per risolvere il problema, utilizza un CoroutineScope con ambito impostato su Composizione. Fornisce un MonotonicFrameClock nel CoroutineContext necessario per il funzionamento delle funzioni di sospensione.

Per risolvere questo arresto anomalo, cambia il CoroutineContext della coroutine in ViewModel con uno con ambito nella composizione. Potrebbe avere un aspetto simile al seguente:

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ù su State e Jetpack Compose, consulta le seguenti risorse aggiuntive.

Esempi

Codelab

Video