Eventos de la IU

Los eventos de la IU son acciones que deben controlarse en la capa de la IU, ya sea mediante la IU o el ViewModel. El tipo de evento más común es el de evento de usuario. El usuario produce eventos de usuario interactuando con la app, por ejemplo, presionando la pantalla o generando gestos. Luego, la IU consume estos eventos mediante devoluciones de llamada, como objetos de escucha onClick().

ViewModel normalmente es responsable de controlar la lógica empresarial de un evento de usuario en particular, por ejemplo, el clic en un botón para actualizar algunos datos. Por lo general, ViewModel controla esto mostrando las funciones que la IU puede llamar. Los eventos de usuario también pueden tener una lógica de comportamiento de IU que la IU puede controlar directamente, por ejemplo, navegar a una pantalla diferente o mostrar un Snackbar.

Si bien la lógica empresarial se mantiene igual para la misma app en diferentes plataformas móviles o factores de forma, la lógica del comportamiento de la IU es un detalle de implementación que puede variar entre esos casos. La página de capas de IU define estos tipos de lógica de la siguiente manera:

  • La lógica empresarial se refiere a qué hacer con los cambios de estado. Por ejemplo, realizar un pago o almacenar las preferencias del usuario. Por lo general, las capas de dominio y los datos controlan esta lógica. En esta guía, se usa la clase ViewModel de componentes de arquitectura como una solución ofrecida para clases que manejan la lógica empresarial.
  • La lógica del comportamiento de la IU o la lógica de la IU se refiere a cómo mostrar los cambios de estado. Por ejemplo, la lógica de navegación o cómo mostrar mensajes al usuario. La IU controla esta lógica.

Árbol de decisión de eventos de la IU

En el siguiente diagrama, se muestra un árbol de decisión a fin de encontrar el mejor enfoque para controlar un caso de uso de un evento en particular. En el resto de esta guía, se explican estos enfoques en detalle.

Si el evento se originó en ViewModel, actualiza el estado de la IU. Si el evento se originó en la IU y requiere lógica empresarial, debes delegar esa lógica al ViewModel. Si el evento se originó en la IU y requiere lógica de comportamiento de la IU, modifica el estado del elemento de la IU directamente en ella.
Figura 1: Árbol de decisión para controlar eventos.

Cómo controlar eventos de usuario

La IU puede controlar eventos de usuario directamente si esos eventos se relacionan con la modificación del estado de un elemento de la IU; por ejemplo, el estado de un elemento expandible. Si el evento requiere lógica empresarial, como actualizar los datos en la pantalla, ViewModel debería procesarlo.

El siguiente ejemplo muestra cómo se usan los diferentes botones para expandir un elemento de la IU (lógica de la IU) y cómo se actualizan los datos en la pantalla (lógica empresarial):

Vistas

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        // The expand details event is processed by the UI that
        // modifies a View's internal state.
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the business logic.
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
          // The expand details event is processed by the UI that
          // modifies this composable's internal state.
          onClick = { expanded = !expanded }
        ) {
          val expandText = if (expanded) "Collapse" else "Expand"
          Text("$expandText details")
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the UI's business logic.
        Button(onClick = { viewModel.refreshNews() }) {
            Text("Refresh data")
        }
    }
}

Eventos de usuario en RecyclerViews

Si la acción se produce más abajo en el árbol de IU, como en un elemento RecyclerView o una View personalizada, el elemento ViewModel debería seguir siendo el que controle los eventos del usuario.

Por ejemplo, supongamos que todos los elementos de noticias de NewsActivity contienen un botón de favoritos. La ViewModel necesita conocer el ID del artículo destacado de noticias. Cuando un usuario agrega un elemento de noticias a favoritos, el adaptador RecyclerView no llama al elemento expuesto addBookmark(newsId) función de ViewModel, que requiere una dependencia delViewModel. En cambio, el ViewModel expone un objeto de estado llamado NewsItemUiState que contiene la implementación para controlar el evento:

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
)
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // Business logic is passed as a lambda function that the
            // UI calls on click events.
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

De esta manera, el adaptador RecyclerView solo funciona con los datos que necesita: la lista de objetos NewsItemUiState. El adaptador no tiene acceso a todo ViewModel, por lo que es menos probable que abuse de la funcionalidad que expone el ViewModel. Cuando permites que solo la clase de actividad funcione con ViewModel, separas las responsabilidades. Esto garantiza que los objetos específicos de la IU, como las vistas o los adaptadores de RecyclerView, no interactúen directamente con ViewModel.

Convenciones de asignación de nombres para las funciones de eventos de los usuarios

En esta guía, las funciones ViewModel que controlan los eventos de usuario se nombran con un verbo en función de la acción que manejan, como addBookmark(id) o logIn(username, password).

Cómo controlar eventos ViewModel

Las acciones de la IU que se originan en el ViewModel (eventos ViewModel) siempre deben dar como resultado una actualización del estado de la IU. Esto cumple con los principios del flujo de datos unidireccional. Permite que los eventos se puedan reproducir después de los cambios de configuración y garantiza que no se pierdan las acciones de IU. De forma opcional, también puedes hacer que los eventos sean reproducibles después del cierre del proceso si usas el módulo de estado guardado.

Asignar acciones de la IU al estado de la IU no siempre es un proceso simple, pero conduce a una lógica más simple. Tu proceso de pensamiento no debería terminar con la determinación de cómo hacer que la IU navegue a una pantalla en particular; por ejemplo, debes pensar más a fondo y considerar cómo representar ese flujo de usuarios en el estado de tu IU. En otras palabras, no pienses en las acciones que debe realizar la IU, piensa en cómo esas acciones afectan el estado de la IU.

Por ejemplo, considera el caso de navegar a la pantalla principal cuando el usuario se conecta a la pantalla de acceso. Puedes modelar esto en el estado de la IU de la siguiente manera:

data class LoginUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

Esta IU reacciona a los cambios en el estado isUserLoggedIn y navega al destino correcto según sea necesario:

Vistas

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
    /* ... */
}

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

Compose

class LoginViewModel : ViewModel() {
    var uiState by mutableStateOf(LoginUiState())
        private set
    /* ... */
}

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel(),
    onUserLogIn: () -> Unit
) {
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)

    // Whenever the uiState changes, check if the user is logged in.
    LaunchedEffect(viewModel.uiState)  {
        if (viewModel.uiState.isUserLoggedIn) {
            currentOnUserLogIn()
        }
    }

    // Rest of the UI for the login screen.
}

Consumir eventos puede activar actualizaciones de estado

Consumir ciertos eventos ViewModel en la IU puede dar como resultado otras actualizaciones de estado de la IU. Por ejemplo, cuando se muestran mensajes transitorios en la pantalla para informar al usuario que algo ocurrió, la IU debe notificar al ViewModel a fin de activar otra actualización de estado cuando el mensaje se haya mostrado en la pantalla. El evento que ocurre cuando el usuario consume el mensaje (ya sea que lo descarte o se agote el tiempo de espera) se puede tratar como "entrada del usuario", por lo que ViewModel debe estar al tanto. En esta situación, el estado de la IU se puede modelar de la siguiente manera:

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

ViewModel actualizaría el estado de la IU de la siguiente manera cuando la lógica empresarial requiera mostrar un nuevo mensaje transitorio al usuario:

Vistas

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

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                _uiState.update { currentUiState ->
                    currentUiState.copy(userMessage = "No Internet connection")
                }
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null)
        }
    }
}

Compose

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

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

ViewModel no necesita saber cómo la IU muestra el mensaje en la pantalla. Solo sabe que hay un mensaje del usuario que se debe mostrar. Una vez que se muestra el mensaje transitorio, la IU debe notificar a ViewModel al respecto, lo que hará que otra actualización del estado de la IU borre la propiedad userMessage:

Vistas

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

Otros casos de uso

Si crees que no se puede resolver el caso de uso de tu evento de la IU con actualizaciones de estado de la IU, es posible que debas volver a considerar cómo fluyen los datos en tu app. Considera los siguientes principios:

  • Cada clase debe hacer la tarea por la que es responsable, y no más. La IU se encarga de la lógica de comportamiento específica de la pantalla, como las llamadas de navegación, los eventos de clic y la obtención de permisos. ViewModel contiene lógica empresarial y convierte los resultados de capas inferiores de la jerarquía en el estado de la IU.
  • Piensa en el lugar donde se origina el evento. Sigue el árbol de decisión que se presenta al comienzo de esta guía y haz que cada clase controle la tarea por la que es responsable. Por ejemplo, si el evento se origina en la IU y genera un evento de navegación, ese evento se debe controlar en la IU. Parte de la lógica se puede delegar al ViewModel, pero el control del evento no se puede delegar por completo al ViewModel.
  • Si tienes varios consumidores y te preocupa que el evento se consuma varias veces, es posible que debas reconsiderar la arquitectura de tu app. Tener varios consumidores simultáneos hace que el contrato entregado exactamente una vez sea muy difícil de garantizar, por lo que el aumenta nivel de complejidad y comportamiento sutil. Si tienes este problema, considera enviar esas inquietudes hacia arriba en tu árbol de IU. Es posible que necesites una entidad diferente con un alcance más alto en la jerarquía.
  • Piensa en cuándo se debe consumir el estado. En ciertas situaciones, es posible que no quieras seguir consumiendo el estado cuando la app está en segundo plano, por ejemplo, se muestra un Toast. En esos casos, considera consumir el estado cuando la IU está en primer plano.