Producción del estado de la IU

Las IUs modernas casi nunca son estáticas. El estado de la IU cambia cuando el usuario interactúa con ella o cuando la app necesita mostrar datos nuevos.

En este documento, se describen los lineamientos para la producción y administración del estado de la IU. Al final, podrás:

  • Conocer las APIs que debes usar para producir el estado de la IU. Esto depende de la naturaleza de las fuentes de cambio de estado disponibles en tus contenedores de estado, en función de los principios del flujo unidireccional de datos.
  • Conocer cómo debes definir el alcance de la producción del estado de la IU para que sea consciente de los recursos del sistema.
  • Conocer cómo debes exponer el estado de la IU para que lo consuma la IU.

En esencia, la producción de estado es la aplicación incremental de estos cambios en el estado de la IU. El estado siempre existe y cambia como resultado de los eventos. Las diferencias entre los eventos y el estado se resumen en la siguiente tabla:

Eventos Estado
Es transitoria, impredecible y existe durante un período limitado Siempre existe
Las entradas de producción del estado Resultado de la producción del estado
Es el producto de la IU o de otras fuentes La consume la IU

Un gran valor mnemotécnico que resume lo anterior es estado es; eventos ocurren. El siguiente diagrama ayuda a visualizar los cambios de estado a medida que ocurren los eventos en un cronograma. El contenedor de estado adecuado procesa cada evento y genera un cambio de estado:

Eventos vs. estado
Figura 1: Los eventos hacen que cambie el estado

Los eventos pueden provenir de las siguientes fuentes:

  • Usuarios: Cuando interactúan con la IU de la app.
  • Otras fuentes de cambio de estado: APIs que presentan datos de app desde la IU, el dominio o capas de datos, como eventos de tiempo de espera de la barra de notificaciones, casos de uso o repositorios, respectivamente.

La canalización de la producción del estado de la IU

La producción de estado en apps para Android se puede considerar como una canalización de procesamiento que comprende lo siguiente:

  • Entradas: Las fuentes del cambio de estado. Pueden ser las siguientes:
    • Locales en la capa de la IU: Pueden ser eventos de usuarios, como un usuario que ingresa un título para una tarea pendiente en una app de administración de tareas o APIs que proporcionan acceso a la lógica de la IU que generan cambios en el estado de la IU. Por ejemplo, una llamada al método open en DrawerState en Jetpack Compose.
    • Externa a la capa de la IU: Estas son fuentes del dominio o capas de datos que causan cambios en el estado de la IU. Por ejemplo, noticias que terminaron de cargarse desde un NewsRepository o demás eventos.
    • Una combinación de todo lo anterior.
  • Contenedores de estado: Tipos que aplican la lógica empresarial o la lógica de la IU a las fuentes de cambio de estado y procesamiento de eventos de usuario para producir el estado de la IU.
  • Salidas: El estado de la IU que la app puede renderizar para brindar a los usuarios la información que necesitan.
La canalización de producción del estado
Figura 2: La canalización de producción del estado

APIs de producción de estado

Hay dos APIs principales que se usan en la producción de estados según la etapa de la canalización en la que te encuentres:

Etapa de canalización API
Entrada Debes usar APIs asíncronas para realizar trabajos fuera del subproceso de IU a fin de mantener libre el bloqueo de la IU. Por ejemplo, corrutinas o flujos en Kotlin, y RxJava o devoluciones de llamada en el lenguaje de programación Java.
Salida Debes usar las APIs de retención de datos observables para invalidar y volver a representar la IU cuando cambia el estado. Por ejemplo, StateFlow, Compose State o LiveData. Los contenedores de datos observables garantizan que la IU siempre tenga un estado de IU para mostrar en la pantalla

De las dos opciones, la elección de una API asíncrona para la entrada tiene una mayor influencia en la naturaleza de la canalización de producción que el estado que la elección de la API observable para la salida. Esto se debe a que las entradas dictan el tipo de procesamiento que se puede aplicar a la canalización.

Organización de canalizaciones de producción de estado

En las siguientes secciones, se abordan las técnicas de producción de estado más adecuadas para varias entradas y las APIs de salida que coinciden. Cada canalización de producción de estado es una combinación de entradas y salidas, y debe tener las siguientes características:

  • Ser compatible con el ciclo de vida: En el caso de que la IU no esté visible o activa, la canalización de producción del estado no debería consumir ningún recurso, a menos que se solicite de manera explícita.
  • Ser fácil de consumir: La IU debe poder renderizar con facilidad el estado de IU producido. Las consideraciones para la salida de la canalización de producción de estado variarán según las diferentes APIs de View, como el sistema de View o Jetpack Compose.

Entradas en canalizaciones de producción de estado

Las entradas de una canalización de producción de estado pueden proporcionar sus fuentes de cambio de estado mediante lo siguiente:

  • Operaciones únicas que pueden ser síncronas o asíncronas, por ejemplo, llamadas a funciones suspend
  • APIs de transmisión, por ejemplo, Flows
  • Todas las opciones anteriores

En las siguientes secciones, se explica cómo puedes organizar una canalización de producción de estado para cada una de las entradas anteriores.

APIs de operaciones únicas como fuentes de cambio de estado

Usa la API de MutableStateFlow como contenedor de estado observable y mutable. En las apps de Jetpack Compose, también puedes considerar mutableStateOf, en especial cuando trabajas con APIs de texto de Compose. Ambas APIs ofrecen métodos que permiten actualizaciones atómicas seguras en los valores que alojan, sin importar si las actualizaciones son síncronas o asíncronas.

Por ejemplo, considera las actualizaciones de estado en una app de dados simple. Cada lanzamiento del dado del usuario invoca el método Random.nextInt() síncrono y el resultado se escribe en el estado de la IU.

StateFlow

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

Estado de Compose

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

Mutación del estado de la IU a partir de llamadas asíncronas

En el caso de los cambios de estado que requieren un resultado asíncrono, inicia una corrutina en el CoroutineScope correspondiente. Esto permite que la app descarte el trabajo cuando se cancela CoroutineScope. Luego, el contenedor de estado escribe el resultado de la llamada de método de suspensión en la API observable que se usó para exponer el estado de la IU.

Por ejemplo, considera AddEditTaskViewModel en la muestra de arquitectura. Cuando el método saveTask() de suspensión guarda una tarea de forma asíncrona, el método update en MutableStateFlow propaga el cambio de estado al estado de la IU.

StateFlow

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Estado de Compose

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

Mutación del estado de la IU a partir de subprocesos en segundo plano

Se prefiere iniciar las corrutinas en el despachador principal para la producción de estado de la IU. Es decir, fuera del bloque withContext en los siguientes fragmentos de código. Sin embargo, si necesitas actualizar el estado de la IU en un contexto en segundo plano diferente, puedes hacerlo mediante las siguientes APIs:

  • Usa el método withContext para ejecutar corrutinas en un contexto simultáneo diferente.
  • Cuando uses MutableStateFlow, usa el método update como de costumbre.
  • Cuando uses el estado de Compose, usa Snapshot.withMutableSnapshot para garantizar las actualizaciones atómicas del estado en el contexto simultáneo.

Por ejemplo, supongamos que, en el fragmento DiceRollViewModel a continuación, SlowRandom.nextInt() es una función suspend de procesamiento intensivo a la que se debe llamar desde una corrutina vinculada a la CPU.

StateFlow

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Estado de Compose

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

Transmite APIs como fuentes de cambio de estado

En el caso de las fuentes de cambio de estado que producen varios valores a lo largo del tiempo en las transmisiones, agregar los resultados de todas las fuentes a un todo coherente es un enfoque sencillo para la producción de estado.

Cuando usas flujos de Kotlin, puedes lograrlo con la función combine. Puedes ver un ejemplo de esto en "Ahora en Android", en la sección InterestsViewModel:

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

El uso del operador stateIn para crear StateFlows proporciona a la IU un control más preciso sobre la actividad de la canalización de producción de estado, ya que puede ser necesario que esté activo solo cuando la IU es visible.

  • Usa SharingStarted.WhileSubscribed() si la canalización solo debe estar activa cuando la IU sea visible mientras se recopila el flujo de manera optimizada para los ciclos de vida.
  • Usa SharingStarted.Lazily si la canalización debe estar activa mientras el usuario pueda volver a la IU, es decir, si está en la pila de actividades o en otra pestaña fuera de la pantalla.

En los casos en que no se aplica la agregación de fuentes de estado basadas en transmisiones, las APIs de transmisión, como los flujos de Kotlin, ofrecen un amplio conjunto de transformaciones, como combinación y compactación, para procesar las transmisiones en el estado de la IU.

APIs de transmisión y de operación única como fuentes de cambio de estado

En los casos en que la canalización de producción de estado depende de llamadas únicas y transmisiones como fuentes de cambio de estado, las transmisiones son la restricción definida. Por lo tanto, convierte las llamadas únicas en las APIs de transmisión o canaliza sus resultados en transmisiones y reanuda el procesamiento, como se describe en la sección de transmisiones anterior.

Por lo general, con los flujos, esto significa crear una o más instancias privadas de MutableStateFlow de copia de seguridad para propagar los cambios de estado. También puedes crear flujos de instantáneas desde el estado de Compose.

Considera el TaskDetailViewModel del repositorio architecture-samples que aparece a continuación:

StateFlow

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Estado de Compose

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

Tipos de salida en canalizaciones de producción de estado

La elección de la API de salida para el estado de la IU y la naturaleza de su presentación dependen en gran medida de la API que tu app usa para procesar la IU. En las apps para Android, puedes elegir usar Views o Jetpack Compose. Se debe considerar lo siguiente:

En la siguiente tabla, se resume qué API usar para la canalización de producción del estado para cualquier entrada y consumidor:

Entrada Consumidor Salida
APIs de operación única Vistas StateFlow o LiveData
APIs de operación única Compose StateFlow o Compose State
APIs de transmisión Vistas StateFlow o LiveData
APIs de transmisión Redactar StateFlow
APIs de transmisión y operación única Vistas StateFlow o LiveData
APIs de transmisión y operación única Compose StateFlow

Inicialización de la canalización de producción del estado

La inicialización de las canalizaciones de producción del estado implica configurar las condiciones iniciales para que se ejecute la canalización. Esto puede implicar proporcionar valores de entrada iniciales que son críticos para el inicio de la canalización (por ejemplo, un id para la vista de detalles de un artículo de noticias) o para iniciar una carga asíncrona.

Debes inicializar la canalización de producción de estado de forma diferida cuando sea posible para conservar los recursos del sistema. Esto suele significar que debes esperar hasta que haya un consumidor del resultado. Las APIs de Flow permiten esto con el argumento started en el método stateIn. En los casos en que esto no sea aplicable, define una función initialize() idempotente para iniciar de forma explícita la canalización de producción del estado, como se muestra en el siguiente fragmento:

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

Ejemplos

En los siguientes ejemplos de Google, se demuestra la producción de estado en la capa de la IU. Explóralos para ver esta guía en práctica: