Dónde elevar el estado

En una aplicación de Compose, la elevación del estado de la IU depende de si la lógica empresarial o la lógica de la IU lo requieren. En este documento, se presentan estas dos situaciones principales.

Práctica recomendada

Debes elevar el estado de la IU al principal común más bajo entre todos los elementos componibles que lo leen y escriben. Además, debes mantener el estado más cercano al lugar donde se consume. Desde el propietario del estado, expón a los consumidores el estado y los eventos inmutables para modificar el estado.

El principal común más bajo también puede estar fuera de la composición. Por ejemplo, cuando elevas el estado en un elemento ViewModel porque se involucra en la lógica empresarial.

En esta página, se explica esta práctica recomendada en detalle, y se deben tener en cuenta las advertencias.

Los tipos de estado y lógica de la IU

A continuación, se muestran las definiciones de los tipos de estado y lógica de la IU que se usan en todo este documento.

Estado de la IU

El estado de la IU es la propiedad que describe la IU. Existen dos tipos de estados de la IU:

  • El estado de la IU de la pantalla es aquello que necesitas mostrar en la pantalla. Por ejemplo, una clase NewsUiState puede contener los artículos de noticias y otra información necesaria para renderizar la IU. Por lo general, este estado se conecta con otras capas de la jerarquía porque incluye datos de app.
  • El estado de un elemento de la IU hace referencia a propiedades intrínsecas a los elementos de la IU que influyen en la forma en que se renderizan. Un elemento de la IU se puede ocultar o mostrar, y puede tener una fuente determinada, con cierto tamaño o color. En las vistas de Android, la Vista es la que administra este estado, ya que es un elemento inherentemente con estado, y expone métodos para modificar o consultar su estado. Un ejemplo de esto son los métodos get y set de la clase TextView para su texto. En Jetpack Compose, el estado es externo al elemento que admite composición, y hasta puedes elevarlo fuera de las inmediaciones de ese elemento hasta la función de componibilidad que realiza la llamada o hasta un contenedor de estado. Un ejemplo es ScaffoldState para el elemento Scaffold que admite composición.

Lógica

La lógica en una aplicación puede ser lógica empresarial o de IU:

  • La lógica empresarial es la implementación de los requisitos del producto para los datos de app. Por ejemplo, agregar un artículo a favoritos en una app de lectura de noticias cuando el usuario presione el botón. Por lo general, esta lógica para guardar un favorito en un archivo o una base de datos se coloca en las capas de dominio o de datos. El contenedor de estado suele delegar esta lógica a esas capas llamando a los métodos que exponen.
  • La lógica de la IU está relacionada con cómo se muestra el estado de la IU en la pantalla. Por ejemplo, obtener la sugerencia correcta de la barra de búsqueda cuando el usuario selecciona una categoría, desplazarse a un elemento determinado de una lista o establecer la lógica de navegación a una pantalla determinada cuando el usuario hace clic en un botón.

Lógica de la IU

Cuando la lógica de la IU necesita leer o escribir el estado, debes definir este último en función de la IU, de acuerdo con su ciclo de vida. Para ello, debes elevar el estado al nivel correcto en una función de componibilidad. Como alternativa, puedes hacerlo en una clase de contenedor de estado sin formato, también centrada en el ciclo de vida de la IU.

A continuación, ofrecemos una descripción de las soluciones y las explicaciones sobre cuándo utilizarlas.

Elementos componibles como propietario del estado

Contar con la lógica de la IU y el estado de sus elementos en objetos componibles es un buen enfoque si el estado y la lógica son simples. Puedes dejar tu estado interno a un elemento que admite composición o elevarlo según sea necesario.

No se necesita la elevación de estado

No siempre se requiere el estado de elevación. El estado puede ser interno en un elemento componible cuando ningún otro elemento necesita controlarlo. En este fragmento, hay un elemento componible que se expande y se contrae cuando se presiona:

@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 variable showDetails es el estado interno de este elemento de la IU. Solo se lee y modifica en este elemento componible, y la lógica que se le aplica es muy simple. Por lo tanto, la elevación del estado en este caso no generaría muchos beneficios y es posible dejarlo interno. Si lo haces, el elemento componible es propietario y la única fuente de información del estado expandido.

Elevación dentro de elementos componibles

Si necesitas compartir el estado de tu elemento de la IU con otros elementos componibles y aplicar su lógica en diferentes lugares, puedes elevarlo más en la jerarquía de la IU. También permite que se vuelvan a utilizar más, y será más fácil probarlos.

El siguiente ejemplo es una app de chat que implementa dos funciones:

  • El botón JumpToBottom desplaza la lista de mensajes hasta la parte inferior. El botón realiza la lógica de la IU en el estado de la lista.
  • La lista MessagesList se desplaza hasta la parte inferior después de que el usuario envía mensajes nuevos. UserInput realiza la lógica de la IU en el estado de la lista.
App de chat que muestra el botón JumpToBottom y un desplazamiento hasta la parte inferior para ver mensajes nuevos
Figura 1: App de Chat con un botón JumpToBottom y desplazamiento hasta la parte inferior para ver los mensajes nuevos

La jerarquía componible es la siguiente:

Árbol de chat componible
Figura 2: Árbol de chat componible

El estado LazyColumn se eleva a la pantalla de la conversación, de modo que la app pueda realizar la lógica de la IU y leer el estado de todos los elementos componibles que lo requieran:

El estado LazyColumn se eleva desde LazyColumn hasta ConversationScreen
Figura 3: El estado LazyColumn se eleva del LazyColumn al ConversationScreen

Por último, los elementos componibles son los siguientes:

Árbol de chat componible con LazyListState elevado a ConversationScreen
Figura 4: Árbol de chat componible con LazyListState elevado a ConversationScreen

El código es el siguiente:

@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 se eleva tanto como sea necesario para la lógica de la IU que se debe aplicar. Como se inicializa en una función de componibilidad, se almacena en la composición y sigue su ciclo de vida.

Ten en cuenta que lazyListState se define en el método MessagesList, con el valor predeterminado de rememberLazyListState(). Este es un patrón común en Compose. Permite que los elementos componibles sean flexibles y se puedan utilizar más. Luego, puedes usar el elemento componible en diferentes partes de la app que quizás no necesites controlar el estado. Por lo general, este es el caso cuando se realiza una prueba o se obtiene una vista previa de un elemento componible. Así es como LazyColumn define su estado.

El principal común más bajo para LazyListState es ConversationScreen.
Figura 5: El principal común más bajo para LazyListState es ConversationScreen

Clase de contenedor de estado sin formato como propietario del estado

Cuando un elemento componible contiene una lógica de IU compleja que involucra uno o varios campos de estado de un elemento de la IU, debe delegar esa responsabilidad a los contenedores de estado, como una clase de contenedor de estado sin formato. De esta manera, se permite probar, de forma aislada, la lógica del elemento componible y se reduce su complejidad. Este enfoque favorece el principio de separación de problemas: el elemento componible está a cargo de emitir los elementos de la IU y el contenedor del estado incluye la lógica de la IU y el estado de sus elementos.

Las clases de contenedores de estado sin formato proporcionan funciones convenientes a los llamadores de la función de componibilidad, de modo que no tienen que escribir esta lógica ellos mismos.

Estas clases sin formato se crean y recuerdan en la composición. Como siguen el ciclo de vida del elemento componible, pueden tomar los tipos que proporciona la biblioteca de Compose, como rememberNavController() o rememberLazyListState().

Un ejemplo de esto es la clase de contenedor de estado sin formato LazyListState, que se implementa en Compose para controlar la complejidad de la IU de 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 encapsula el estado de LazyColumn que almacena scrollPosition para este elemento de la IU. También expone métodos para modificar la posición de desplazamiento, por ejemplo, con el desplazamiento a un elemento determinado.

Como puedes ver, aumentar las responsabilidades de un elemento componible incrementa la necesidad de un contenedor de estado. Las responsabilidades pueden estar en la lógica de la IU o solo en la cantidad de estado del que se debe realizar un seguimiento.

Otro patrón común es usar una clase de contenedor de estado sin formato para controlar la complejidad de las funciones raíz de componibilidad en la app. Puedes usar esa clase para encapsular el estado a nivel de la app, como el estado de navegación y el tamaño de la pantalla. Puedes encontrar una descripción completa en la página sobre la lógica de la IU y el contenedor de estado.

Lógica empresarial

Si las clases de elementos componibles y los contenedores de estado sin formato están a cargo de la lógica de la IU y el estado del elemento de la IU, un contenedor de estado en el nivel de la pantalla está a cargo de las siguientes tareas:

  • Brindar acceso a la lógica empresarial de la aplicación (por lo general, se ubica en otras capas de la jerarquía, como las capas empresariales y de datos)
  • Preparar los datos de la aplicación para mostrarlos en una pantalla particular (que se convierte en el estado de la IU de la pantalla)

ViewModels como propietario del estado

Los beneficios de AAC ViewModels en el desarrollo de Android permiten que sean adecuados para brindar acceso a la lógica empresarial y preparar los datos de la aplicación para mostrarlos en la pantalla.

Cuando elevas el estado de la IU en ViewModel, lo quitas de la composición.

El estado elevado al ViewModel se almacena fuera de la composición.
Figura 6: El estado elevado a ViewModel se almacena fuera de la composición.

Los ViewModel no se almacenan como parte de la composición. El framework los proporciona, y se limitan a ViewModelStoreOwner, que puede ser una actividad, un fragmento, un gráfico de navegación o el destino de un gráfico de navegación. Para obtener más información sobre los alcances de ViewModel, puedes consultar la documentación.

ViewModel es la fuente de información y el principal común más bajo para el estado de la IU.

Estado de la IU de la pantalla

Según las definiciones anteriores, se produce el estado de la IU de la pantalla con la aplicación de reglas empresariales. Como el contenedor de estado en el nivel de la pantalla es responsable de esta aplicación, el estado de la IU de la pantalla, por lo general, se eleva en el contenedor de estado en este nivel, en este caso, un ViewModel.

Considera ConversationViewModel de una app de chat y cómo expone el estado de la IU de la pantalla y los eventos para modificarla:

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

Los elementos componibles consumen el estado de la IU de la pantalla que se elevó en ViewModel. Debes insertar la instancia de ViewModel en los elementos componibles en el nivel de la pantalla para brindar acceso a la lógica empresarial.

A continuación, se muestra un ejemplo de un ViewModel que se usa en un elemento componible en el nivel de la pantalla: Estos son los elementos ConversationScreen() componibles que consumen el estado de la IU de la pantalla que se elevan en 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)
    /* ... */
}

Desglose de propiedades

El "desglose de propiedades" alude a pasar datos a través de varios componentes secundarios anidados a la ubicación en la que se leyeron.

Un ejemplo típico de dónde puede aparecer el desglose de propiedades en Compose es cuando insertas en el nivel superior el contenedor de estado en el nivel de la pantalla y pasas el estado y los eventos a elementos secundarios componibles. Además, podría generar una sobrecarga de firmas de funciones de componibilidad.

Si bien exponer eventos como parámetros lambda individuales podría sobrecargar la firma de la función, maximiza la visibilidad de cuáles son las responsabilidades de la función de componibilidad. Puedes dar un vistazo para ver lo que hace.

Se recomienda el desglose de propiedades en lugar de crear clases de wrapper para encapsular estados y eventos en un solo lugar, ya que reduce la visibilidad de las responsabilidades componibles. Si no tienes clases de wrapper, es más probable que le pases solo los elementos componibles a los parámetros que necesitan, lo que es una práctica recomendada.

La misma práctica recomendada se aplica si estos eventos son de navegación. Puedes obtener más información sobre este tema en los documentos de navegación.

Si identificaste un problema de rendimiento, también puedes optar por diferir la lectura del estado. Consulta los documentos de rendimiento para obtener más información.

Estado del elemento de la IU

Puedes elevar el estado del elemento de la IU al contenedor de estado en el nivel de la pantalla si hay una lógica empresarial que necesite leerlo o escribirlo.

Para continuar con el ejemplo de una app de chat, la app muestra las sugerencias del usuario en un chat en grupo cuando el usuario escribe @ y una sugerencia. Esas sugerencias provienen de la capa de datos, y la lógica para calcular una lista de sugerencias de usuarios se considera empresarial. La función se ve de la siguiente manera:

Función que muestra sugerencias de usuarios en un chat en grupo cuando el usuario escribe &quot;@&quot; y una sugerencia
Figura 7: Función que muestra sugerencias de usuarios en un chat en grupo cuando el usuario escribe @ y una sugerencia

ViewModel que implementa esta función se verá de la siguiente manera:

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 es una variable que almacena el estado TextField. Cada vez que el usuario escribe una entrada nueva, la app llama a la lógica empresarial para producir suggestions.

suggestions es el estado de la IU de la pantalla y se consume desde la IU de Compose mediante la recopilación desde StateFlow.

Advertencias

Para algunos estados de elementos de la IU de Compose, elevar a ViewModel puede requerir consideraciones especiales. Por ejemplo, algunos contenedores de estado de elementos de la IU de Compose exponen métodos para modificar el estado. Algunos de estos podrían ser funciones de suspensión que activan animaciones. Estas funciones de suspensión pueden generar excepciones si las llamas desde un CoroutineScope que no está dentro del alcance de la composición.

Supongamos que el contenido del panel de apps es dinámico y que debes recuperarlo y actualizarlo desde la capa de datos después de cerrarlo. Debes elevar el estado del panel lateral a ViewModel, de modo que puedas llamar a la IU y la lógica empresarial de este elemento desde el propietario del estado.

Sin embargo, llamar al método close() de DrawerState mediante viewModelScope de la IU de Compose genera una excepción de tiempo de ejecución del tipo IllegalStateException con un mensaje que dice "a MonotonicFrameClock is not available in this CoroutineContext”.

Para solucionar este problema, usa un CoroutineScope que se limita a la composición. Proporciona MonotonicFrameClock en CoroutineContext que es necesario para que se puedan usar las funciones de suspensión.

Para solucionar esta falla, cambia CoroutineContext de la corrutina en ViewModel a uno que se limite a la composición. Puede verse de la siguiente manera:

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) })
}

Más información

Para obtener más información sobre el estado y Jetpack Compose, consulta los siguientes recursos adicionales.

Ejemplos

Codelabs

Videos