Stato di dove sollevare lo stato

In un'applicazione Compose, la posizione in cui eseguire l'hoisting dello stato della UI dipende dal fatto che sia richiesto dalla logica della UI o dalla logica di business. Questo documento illustra questi due scenari principali.

Best practice

Devi eseguire l'hoisting dello stato della UI al nodo padre comune più basso tra tutti i componibili che lo leggono e lo scrivono. Dovresti mantenere lo stato il più vicino possibile al punto in cui viene utilizzato. Dal proprietario dello stato, esponi ai consumer lo stato immutabile e gli eventi per modificare lo stato.

L'antenato comune più basso può anche trovarsi al di fuori della composizione. Ad esempio, quando esegui l'hoisting 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 della UI

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

Stato della UI

Lo stato della UI è la proprietà che descrive la UI. Esistono due tipi di stato della UI:

  • Lo stato della UI della schermata è ciò che devi visualizzare sullo schermo. Ad esempio, una classe NewsUiState può contenere gli articoli e altre informazioni necessarie per eseguire il rendering della UI. Questo stato è solitamente connesso ad altri livelli della gerarchia perché contiene i dati dell'app.
  • Lo stato dell'elemento UI si riferisce alle proprietà intrinseche degli elementi UI che influenzano il modo in cui vengono visualizzati. Un elemento UI può essere mostrato o nascosto e può avere un determinato carattere, una determinata dimensione o un determinato colore del carattere. In Jetpack Compose, lo stato è esterno al componibile e puoi persino eseguirne l'hoisting al di fuori delle immediate vicinanze del componibile nella funzione componibile chiamante o in un contenitore di stato. Un esempio è ScaffoldState per il composable Scaffold.

Funzione logica

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

  • La logica di business è l'implementazione dei requisiti del prodotto per i dati dell'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 preferito in un file o in un database viene solitamente inserita nei livelli di dominio o di dati. Il contenitore di stato di solito delega questa logica a questi livelli chiamando i metodi che espongono.
  • La logica della UI è correlata a come visualizzare lo stato della UI sullo schermo. Ad esempio, ottenere il suggerimento corretto per la barra di ricerca quando l'utente ha selezionato una categoria, scorrere fino a un determinato elemento in un elenco o la logica di navigazione verso una determinata schermata quando l'utente fa clic su un pulsante.

Logica UI

Quando la logica della UI deve leggere o scrivere lo stato, devi limitare lo stato alla UI, seguendo il suo ciclo di vita. Per farlo, devi eseguire l'hoisting dello stato al livello corretto in una funzione componibile. In alternativa, puoi farlo in una classe 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 utilizzarle.

Componibili come proprietari dello stato

Avere la logica della UI 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 eseguire l'hoisting in base alle esigenze.

Nessun innalzamento dello stato necessario

Il sollevamento dello stato non è sempre necessario. Lo stato può essere mantenuto interno in un componibile quando nessun altro componibile deve controllarlo. In questo snippet è presente un composable 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

    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 solo letto e modificato in questo composable e la logica applicata è molto semplice. In questo caso, l'elevazione dello stato non apporterebbe molti vantaggi, quindi puoi lasciarlo interno. In questo modo, questo composable diventa il proprietario e la Single Source Of Truth dello stato espanso.

Hoisting all'interno dei composable

Se devi condividere lo stato dell'elemento UI con altri componibili e applicare la logica UI in punti diversi, puoi eseguirne l'hoisting più in alto nella gerarchia della UI. In questo modo, i composabili sono anche 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 della UI sullo stato dell'elenco.
  • L'elenco MessagesList scorre verso il basso dopo che l'utente invia nuovi messaggi. UserInput esegue la logica della UI sullo stato dell'elenco.
App di chat con un pulsante JumpToBottom e scorrimento verso il basso per i 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 composable della chat
Figura 2. Albero componibile di Chat

Lo stato LazyColumn viene spostato nella schermata della conversazione in modo che l'app possa eseguire la logica della UI e leggere lo stato da tutti i componibili che lo richiedono:

Eseguire l'hoisting dello stato di LazyColumn da LazyColumn a ConversationScreen
Figura 3. Hoisting dello stato LazyColumn da LazyColumn a ConversationScreen

Infine, i componibili sono:

Albero componibile di Chat con LazyListState spostato in ConversationScreen
Figura 4. Albero componibile della chat con LazyListState sottoposto a hoisting in 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 spostato in alto quanto necessario per la logica della UI che deve essere applicata. Poiché viene inizializzato in una funzione componibile, viene archiviato nella composizione, seguendo il suo ciclo di vita.

Tieni presente che lazyListState è definito nel metodo MessagesList, con il valore predefinito rememberLazyListState(). Questo è un pattern comune in Compose. Rende i componibili più riutilizzabili e flessibili. Puoi quindi utilizzare il composable in diverse parti dell'app che potrebbero non dover controllare lo stato. In genere, questo è il caso durante il test o l'anteprima di un composable. È esattamente così che LazyColumn definisce il suo stato.

Il predecessore comune più basso per LazyListState è ConversationScreen
Figura 5. Il predecessore comune più basso per LazyListState è ConversationScreen

Classe contenitore di stato semplice come proprietario dello stato

Quando un composable 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 classe contenitore di stato semplice. In questo modo, la logica del composable è più testabile in isolamento e la sua complessità è ridotta. Questo approccio favorisce il principio di separazione delle responsabilità: il composable è responsabile dell'emissione degli elementi UI e 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 autonomamente.

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

Un esempio è la classe del contenitore di stato semplice LazyListState, 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 che memorizza scrollPosition per questo elemento UI. Inoltre, espone 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 contenitore di stato. Le responsabilità potrebbero riguardare la logica della UI o semplicemente la quantità di stato da monitorare.

Un altro pattern comune consiste nell'utilizzare una classe contenitore di stato semplice per gestire la complessità delle funzioni componibili principali nell'app. Puoi utilizzare una classe di questo tipo per incapsulare lo stato a livello di app, come lo stato di navigazione e le dimensioni dello schermo. Una descrizione completa di questo aspetto è disponibile nella pagina Logica della UI e relativo contenitore di stato.

Logica di business

Se i componibili e le classi di contenitori di stato semplici sono responsabili della logica della UI e dello stato degli elementi UI, un contenitore di stato a livello di schermo è 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 di business e di dati.
  • Preparazione dei dati dell'applicazione per la presentazione in una determinata schermata, 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 esegui l'hoisting dello stato della UI in ViewModel, lo sposti all'esterno della composizione.

Lo stato sottoposto a hoisting nel ViewModel viene archiviato al di fuori della composizione.
Figura 6. Lo stato innalzato a ViewModel viene archiviato al di fuori della composizione.

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

In questo caso, ViewModel è la fonte di attendibilità e l'antenato comune più basso per lo stato della UI.

Stato della UI dello schermo

In base alle definizioni riportate sopra, lo stato della UI della schermata viene generato applicando le regole aziendali. Poiché il contenitore di stato a livello di schermata è responsabile di questo, significa che lo stato della UI della schermata viene in genere sottoposto ad hoisting nel contenitore di stato a livello di schermata, in questo caso un ViewModel.

Considera la ConversationViewModel di un'app di chat e il modo in cui espone lo stato della 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 componibili utilizzano lo stato della UI della schermata innalzato in ViewModel. Devi inserire l'istanza ViewModel nei composabili 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. In questo caso, il componibile ConversationScreen() utilizza lo stato della UI dello schermo sottoposto a hoisting 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)
    /* ... */
}

Drill-down delle proprietà

Il termine "property drilling" si riferisce al passaggio di dati attraverso diversi componenti secondari nidificati alla posizione in cui vengono letti.

Un esempio tipico di dove può apparire il drilling delle proprietà in Compose è quando inserisci il contenitore di stato a livello di schermo nel livello superiore e trasmetti lo stato e gli eventi ai componibili secondari. Ciò potrebbe inoltre generare un sovraccarico delle firme delle 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.

Il property drilling è preferibile alla creazione di classi wrapper per incapsulare lo stato e gli eventi in un unico posto, perché riduce la visibilità delle responsabilità del composable. Inoltre, non avendo classi wrapper, è più probabile che tu passi ai componibili solo i parametri di cui hanno bisogno, il che è una best practice.

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

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

Stato dell'elemento UI

Puoi eseguire l'hoisting dello stato dell'elemento UI al contenitore di stato a livello di schermata se esiste 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 l'utente è considerata logica di business. La funzionalità ha il seguente aspetto:

Funzionalità che mostra i suggerimenti degli utenti in una chat di gruppo quando l'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

L'oggetto ViewModel che implementa questa funzionalità avrà 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 della schermata e viene utilizzato dall'interfaccia utente di Compose tramite la raccolta da StateFlow.

Caveat

Per alcuni stati degli elementi UI di Compose, l'innalzamento a ViewModel potrebbe richiedere considerazioni speciali. Ad esempio, alcuni contenitori di stato degli elementi UI di Compose espongono 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 un CoroutineScope che non rientra nell'ambito della composizione.

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

Tuttavia, la chiamata del metodo close() di DrawerState utilizzando viewModelScope dalla UI di Compose causa un'eccezione di runtime di tipo IllegalStateException con un messaggio che indica che MonotonicFrameClock non è disponibile in questo CoroutineContext”.

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

Per correggere questo arresto anomalo, cambia il CoroutineContext della coroutine in ViewModel con uno che abbia come ambito la 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 scoprire di più sullo stato e su Jetpack Compose, consulta le seguenti risorse aggiuntive.

Esempi

Codelab

Video