ViewModel y el estado en Compose

1. Antes de comenzar

En los codelabs anteriores, aprendiste sobre el ciclo de vida de las actividades y los problemas relacionados con los cambios de configuración. Cuando se produce un cambio de configuración, puedes guardar los datos de una app de diferentes maneras, como usando rememberSaveable o guardando el estado de la instancia. Sin embargo, estas opciones pueden crear problemas. En la mayoría de los casos, puedes usar rememberSaveable, pero eso podría significar mantener la lógica en elementos componibles o cerca de ellos. Cuando las apps crecen, debes alejar los datos y la lógica de los elementos de componibilidad. En este codelab, aprenderás una forma sólida de diseñar tu app y preservar sus datos durante los cambios de configuración aprovechando los lineamientos de arquitectura de apps para Android, ViewModel y la biblioteca de Android Jetpack.

Las bibliotecas de Android Jetpack son una colección de bibliotecas que te facilitarán el desarrollo de apps para Android geniales. Estas bibliotecas te ayudan a seguir prácticas recomendadas, te liberan de escribir código estándar y simplifican tareas complejas de modo que puedas concentrarte en el código que te interesa, como la lógica de la app.

La arquitectura de apps es un conjunto de reglas de diseño para una app. Al igual que el plano de una casa, la arquitectura proporciona la estructura para tu app. Una buena arquitectura de la app puede hacer que tu código sea robusto, flexible y escalable, que se pueda probar y que resulte fácil de mantener durante los próximos años. La Guía de arquitectura de apps brinda recomendaciones sobre arquitectura de apps y prácticas recomendadas.

En este codelab, aprenderás a usar ViewModel, uno de los componentes de la arquitectura de las bibliotecas de Android Jetpack que pueden almacenar los datos de tu app. Los datos almacenados no se pierden si el framework destruye y vuelve a crear las actividades durante un cambio de configuración u otros eventos. Sin embargo, los datos se pierden si la actividad se destruye debido al cierre del proceso. El ViewModel solo almacena en caché los datos mediante recreaciones de actividad rápidas.

Requisitos previos

  • Conocimientos sobre Kotlin, incluidas funciones, lambdas y elementos sin estado componibles
  • Conocimientos básicos de compilación de diseños en Jetpack Compose
  • Conocimientos básicos de Material Design

Qué aprenderás

Qué compilarás

  • Una app de juego llamada Unscramble, en la que el usuario puede adivinar palabras desordenadas

Requisitos

  • La versión más reciente de Android Studio
  • Conexión a Internet para descargar el código de partida

2. Descripción general de la app

Descripción general del juego

La app de Unscramble es un juego de palabras desordenadas de un solo jugador. La app muestra una palabra desordenada, y el jugador debe adivinarla a partir de las letras que se muestran. El jugador gana puntos si la palabra es correcta. De lo contrario, el jugador puede intentar adivinar la palabra cualquier cantidad de veces. La app también tiene la opción de omitir la palabra actual. En la esquina superior derecha, la app muestra el recuento de palabras, que es la cantidad de palabras desordenadas que se usaron en el juego actual. Hay 10 palabras por partida.

Obtén el código de partida

Para comenzar, descarga el código de partida:

Descargar ZIP

Como alternativa, puedes clonar el repositorio de GitHub para el código:

$ git clone
https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout starter

Puedes explorar el código de partida en el repositorio Unscramble de GitHub.

3. Descripción general de la app de inicio

Para familiarizarte con el código de partida, completa los siguientes pasos:

  1. Abre el proyecto con el código de partida en Android Studio.
  2. Ejecuta la app en un dispositivo Android o en un emulador.
  3. Presiona los botones Submit y Skip para probar la app.

Notarás errores en la app. La palabra desordenada no aparece, pero está codificada para que sea "scrambleun", y no sucede nada cuando presionas los botones.

En este codelab, implementarás la funcionalidad del juego con la arquitectura de apps para Android.

Explicación del código de inicio

El código de partida tiene el diseño de pantalla de juego prediseñado para ti. En esta ruta de aprendizaje, implementarás la lógica del juego. Usarás componentes de la arquitectura para implementar la arquitectura recomendada de la app y resolver los problemas mencionados anteriormente. Esta es una breve explicación de algunos archivos para que puedas comenzar.

WordsData.kt

Este archivo contiene una lista de palabras usadas en el juego, las constantes para la cantidad máxima de palabras por juego y la cantidad de puntos que el jugador ganará por cada palabra correcta.

package com.example.android.unscramble.data

const val MAX_NO_OF_WORDS = 10
const val SCORE_INCREASE = 20

// Set with all the words for the Game
val allWords: Set<String> =
   setOf(
       "animal",
       "auto",
       "anecdote",
       "alphabet",
       "all",
       "awesome",
       "arise",
       "balloon",
       "basket",
       "bench",
      // ...
       "zoology",
       "zone",
       "zeal"
)

MainActivity.kt

Este archivo contiene principalmente código generado por plantillas. Mostrarás el elemento GameScreen componible en el bloque setContent{}.

GameScreen.kt

Todos los elementos componibles de la IU se definen en el archivo GameScreen.kt. En las siguientes secciones, se proporciona una explicación de algunas funciones de componibilidad.

GameStatus

GameStatus es una función de componibilidad que muestra la puntuación del juego en la parte inferior de la pantalla. La función de componibilidad contiene un elemento de texto componible en una Card. Por ahora, la puntuación está codificada en 0.

1a7e4472a5638d61.png

// No need to copy, this is included in the starter code.

@Composable
fun GameStatus(score: Int, modifier: Modifier = Modifier) {
    Card(
        modifier = modifier
    ) {
        Text(
            text = stringResource(R.string.score, score),
            style = typography.headlineMedium,
            modifier = Modifier.padding(8.dp)
        )
    }
}

GameLayout

GameLayout es una función de componibilidad que muestra la funcionalidad principal del juego, como la palabra desordenada, las instrucciones del juego y un campo de texto que acepta los intentos del usuario.

b6ddb1f07f10df0c.png

Observa que el código GameLayout que aparece más abajo contiene una columna dentro de una Card con tres elementos secundarios: el texto de la palabra desordenada, el texto de las instrucciones y el campo de texto de la palabra OutlinedTextField del usuario. Por ahora, la palabra desordenada está codificada para ser scrambleun. Más adelante en el codelab, implementarás funcionalidad para mostrar una palabra del archivo WordsData.kt.

// No need to copy, this is included in the starter code.

@Composable
fun GameLayout(modifier: Modifier = Modifier) {
   val mediumPadding = dimensionResource(R.dimen.padding_medium)
   Card(
       modifier = modifier,
       elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
   ) {
       Column(
           verticalArrangement = Arrangement.spacedBy(mediumPadding),
           horizontalAlignment = Alignment.CenterHorizontally,
           modifier = Modifier.padding(mediumPadding)
       ) {
           Text(
               modifier = Modifier
                   .clip(shapes.medium)
                   .background(colorScheme.surfaceTint)
                   .padding(horizontal = 10.dp, vertical = 4.dp)
                   .align(alignment = Alignment.End),
               text = stringResource(R.string.word_count, 0),
               style = typography.titleMedium,
               color = colorScheme.onPrimary
           )
           Text(
               text = "scrambleun",
               style = typography.displayMedium
           )
           Text(
               text = stringResource(R.string.instructions),
               textAlign = TextAlign.Center,
               style = typography.titleMedium
           )
           OutlinedTextField(
               value = "",
               singleLine = true,
               shape = shapes.large,
               modifier = Modifier.fillMaxWidth(),
               colors = TextFieldDefaults.textFieldColors(containerColor = colorScheme.surface),
               onValueChange = { },
               label = { Text(stringResource(R.string.enter_your_word)) },
               isError = false,
               keyboardOptions = KeyboardOptions.Default.copy(
                   imeAction = ImeAction.Done
               ),
               keyboardActions = KeyboardActions(
                   onDone = { }
               )
           )
       }
   }
}

El elemento de componibilidad OutlinedTextField es similar al elemento TextField de las apps en codelabs anteriores.

Los campos de texto se dividen en dos tipos:

  • Campos de texto con relleno
  • Campos de texto con contorno

3df34220c3d177eb.png

Los campos de texto con contorno tienen menos énfasis visual que aquellos con relleno. Cuando aparecen en lugares como formularios, donde muchos campos de texto están juntos, su énfasis reducido ayuda a simplificar el diseño.

En el código de partida, OutlinedTextField no se actualiza cuando el usuario realiza un intento. Actualizarás esta función en el codelab.

GameScreen

El elemento GameScreen componible contiene las funciones de componibilidad GameStatus y GameLayout, el título del juego, el recuento de palabras y los elementos componibles para los botones Submit (enviar) y Skip (omitir).

ac79bf1ed6375a27.png

@Composable
fun GameScreen() {
    val mediumPadding = dimensionResource(R.dimen.padding_medium)

    Column(
        modifier = Modifier
            .verticalScroll(rememberScrollState())
            .padding(mediumPadding),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Text(
            text = stringResource(R.string.app_name),
            style = typography.titleLarge,
        )

        GameLayout(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .padding(mediumPadding)
        )
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(mediumPadding),
            verticalArrangement = Arrangement.spacedBy(mediumPadding),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {

            Button(
                modifier = Modifier.fillMaxWidth(),
                onClick = { }
            ) {
                Text(
                    text = stringResource(R.string.submit),
                    fontSize = 16.sp
                )
            }

            OutlinedButton(
                onClick = { },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(
                    text = stringResource(R.string.skip),
                    fontSize = 16.sp
                )
            }
        }

        GameStatus(score = 0, modifier = Modifier.padding(20.dp))
    }
}

Los eventos de clic de botones no se implementaron en el código de partida. Los implementarás como parte del codelab.

FinalScoreDialog

El objeto FinalScoreDialog componible muestra un diálogo, es decir, una ventana pequeña que le muestra opciones al usuario para Play Again (volver a jugar) o Exit (salir del juego). Más adelante en este codelab, implementarás lógica para mostrar este diálogo al final del juego.

dba2d9ea62aaa982.png

// No need to copy, this is included in the starter code.

@Composable
private fun FinalScoreDialog(
    score: Int,
    onPlayAgain: () -> Unit,
    modifier: Modifier = Modifier
) {
    val activity = (LocalContext.current as Activity)

    AlertDialog(
        onDismissRequest = {
           // Dismiss the dialog when the user clicks outside the dialog or on the back
           // button. If you want to disable that functionality, simply use an empty
           // onDismissRequest.
        },
        title = { Text(text = stringResource(R.string.congratulations)) },
        text = { Text(text = stringResource(R.string.you_scored, score)) },
        modifier = modifier,
        dismissButton = {
            TextButton(
                onClick = {
                    activity.finish()
                }
            ) {
                Text(text = stringResource(R.string.exit))
            }
        },
        confirmButton = {
            TextButton(onClick = onPlayAgain) {
                Text(text = stringResource(R.string.play_again))
            }
        }
    )
}

4. Obtén información sobre la arquitectura de la app

La arquitectura de una app proporciona lineamientos para ayudarte a asignar las responsabilidades de la app entre las clases. Una arquitectura de app bien diseñada te ayuda a escalar tu app y a extenderla con funciones adicionales. La arquitectura también puede simplificar la colaboración en equipo.

Los principios arquitectónicos más comunes son la separación de problemas y el control de la IU a partir de un modelo.

Separación de problemas

El principio de separación de problemas indica que la app debe dividirse en clases de funciones, cada una con responsabilidades independientes.

Control de la IU a partir de un modelo

El principio de control de la IU a partir de un modelo establece que debes controlar tu IU a partir de un modelo, preferentemente un modelo persistente. Los modelos son componentes responsables de administrar los datos de una app. Son independientes de los componentes de la app y los elementos de la IU, de modo que no se ven afectados por el ciclo de vida de la app ni los problemas asociados.

Teniendo en cuenta los principios de arquitectura comunes que se mencionaron en la sección anterior, cada app debe tener al menos dos capas:

  • Capa de la IU: Es una capa que muestra los datos de app en la pantalla, pero que es independiente de los datos.
  • Capa de datos: Es una capa que almacena, recupera y expone los datos de app.

Puedes agregar una capa adicional, llamada capa de dominio, para simplificar y volver a utilizar las interacciones entre las capas de datos y de la IU. Esta capa es opcional y está fuera del alcance de este curso.

a4da6fa5c1c9fed5.png

Capa de la IU

La función de la capa de la IU (o capa de presentación) consiste en mostrar los datos de la aplicación en la pantalla. Cuando los datos cambian debido a una interacción del usuario, como cuando se presiona un botón, la IU debe actualizarse para reflejar los cambios.

La capa de la IU consta de los siguientes componentes:

  • Elementos de la IU: Son los componentes que renderizan los datos en la pantalla. Estos elementos se compilan con Jetpack Compose.
  • Contenedores de estado: Son los componentes que contienen los datos, los exponen a la IU y controlan la lógica de la app. Un ejemplo de contenedor de estado es ViewModel.

6eaee5b38ec247ae.png

ViewModel

El componente ViewModel contiene y expone el estado que consume la IU. El estado de la IU son datos de la aplicación que transforma ViewModel. ViewModel permite que tu app siga el principio de arquitectura de controlar la IU a partir de un modelo.

ViewModel almacena los datos relacionados con la app que no se destruyen cuando el framework de Android destruye y vuelve a crear la actividad. A diferencia de la instancia de la actividad, los objetos ViewModel no se destruyen. La app retiene automáticamente objetos ViewModel durante los cambios de configuración de modo que los datos que tengan estén disponibles de inmediato después de la recomposición.

Para implementar ViewModel en tu app, extiende la clase ViewModel, que está incluida en la biblioteca de componentes de la arquitectura, y almacena los datos de app en esa clase.

Estado de la IU

La IU es lo que ve el usuario, y el estado de la IU es lo que la app indica que este debería ver. La IU es la representación visual del estado de la IU. Cualquier cambio en el estado de la IU se refleja de inmediato en la IU.

9cfedef1750ddd2c.png

La IU es el resultado de la vinculación de sus elementos en la pantalla con el estado correspondiente.

// Example of UI state definition, do not copy over

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

Inmutabilidad

La definición del estado de la IU en el ejemplo anterior es inmutable. Los objetos inmutables garantizan que el estado de la app no se altere por múltiples fuentes en un mismo momento. Esta protección libera a la IU para enfocarse en una sola función: leer el estado y actualizar los elementos de la IU según corresponda. Por lo tanto, nunca debes modificar el estado de la IU directamente en ella, a menos que esta sea la única fuente de datos. Infringir este principio genera varias fuentes de confianza para la misma información, lo que genera inconsistencias en los datos y errores leves.

5. Agrega un ViewModel

En esta tarea, agregarás un ViewModel a tu app para almacenar el estado de la IU del juego (la palabra desordenada, la cantidad de palabras y la puntuación). Para resolver el problema en el código de partida que observaste en la sección anterior, debes guardar los datos del juego en ViewModel.

  1. Abre build.gradle.kts (Module :app), desplázate hasta el bloque dependencies y agrega la siguiente dependencia para ViewModel. Esta dependencia se usa para agregar el viewmodel adaptado al ciclo de vida a tu app de Compose.
dependencies {
// other dependencies

    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
  1. En el paquete ui, crea una clase o un archivo Kotlin llamado GameViewModel. Extiéndelo desde la clase ViewModel.
import androidx.lifecycle.ViewModel

class GameViewModel : ViewModel() {
}
  1. En el paquete ui, agrega una clase de modelo para la IU de estado llamada GameUiState. Conviértela en una clase de datos y agrega una variable para la palabra desordenada actual.
data class GameUiState(
   val currentScrambledWord: String = ""
)

StateFlow

StateFlow es un flujo observable contenedor de datos que emite actualizaciones de estado actuales y nuevas. Su propiedad value refleja el valor de estado actual. Para actualizar el estado y enviarlo al flujo, asigna un nuevo valor a la propiedad de la clase MutableStateFlow.

En Android, StateFlow funciona bien con clases que necesitan mantener un estado inmutable observable.

Se puede exponer un StateFlow desde el GameUiState de modo que los objetos componibles escuchen las actualizaciones de estado de la IU y hagan que el estado de la pantalla sobreviva a los cambios de configuración.

En la clase GameViewModel, agrega la siguiente propiedad _uiState.

import kotlinx.coroutines.flow.MutableStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())

Propiedad de copia de seguridad

Una propiedad de copia de seguridad te permite mostrar algo a partir de un método get que no sea el objeto exacto.

Para cada propiedad var, el framework de Kotlin genera métodos get y set.

Para los métodos get y set, puedes anular uno o ambos métodos, y proporcionar tu propio comportamiento personalizado. Para implementar una propiedad de copia de seguridad, debes anular el método get para mostrar una versión de solo lectura de tus datos. En el siguiente ejemplo, se muestra una propiedad de copia de seguridad:

//Example code, no need to copy over

// Declare private mutable variable that can only be modified
// within the class it is declared.
private var _count = 0

// Declare another public immutable field and override its getter method.
// Return the private property's value in the getter method.
// When count is accessed, the get() function is called and
// the value of _count is returned.
val count: Int
    get() = _count

Como otro ejemplo, supongamos que deseas que los datos de app resulten privados para el ViewModel:

Dentro de la clase ViewModel:

  • La propiedad _count es private y mutable. Por lo tanto, solo es accesible y editable dentro de la clase ViewModel.

Fuera de la clase ViewModel:

  • El modificador de visibilidad predeterminado de Kotlin es public, por lo que count es público y accesible desde otras clases, como los controladores de IU. Un tipo val no puede tener un método set. Es inmutable y de solo lectura, por lo que solo puedes anular el método get(). Cuando una clase externa accede a esta propiedad, muestra el valor de _count y su valor no se puede modificar. Esta propiedad de copia de seguridad protege los datos de app dentro del ViewModel contra cambios no deseados y no seguros por parte de clases externas, pero permite que los llamadores externos accedan a su valor de forma segura.
  1. En el archivo GameViewModel.kt, agrega una propiedad de copia de seguridad a uiState llamada _uiState. Asigna el nombre uiState a la propiedad, que es del tipo StateFlow<GameUiState>.

Ahora _uiState solo es accesible y editable dentro de GameViewModel. La IU puede leer su valor usando la propiedad uiState de solo lectura. Puedes corregir el error de inicialización en el paso siguiente.

import kotlinx.coroutines.flow.StateFlow

// Game UI state

// Backing property to avoid state updates from other classes
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState>
  1. Establece uiState en _uiState.asStateFlow().

El asStateFlow() hace que este flujo de estado mutable sea de solo lectura.

import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()

Muestra una palabra desordenada aleatoria

En esta tarea, agregarás métodos auxiliares para elegir una palabra aleatoria del archivo WordsData.kt y desordenar la palabra.

  1. En GameViewModel, agrega una propiedad llamada currentWord del tipo String para guardar la palabra desordenada actual.
private lateinit var currentWord: String
  1. Agrega un método auxiliar para elegir una palabra aleatoria de la lista y desordenarla. Asígnale el nombre pickRandomWordAndShuffle() sin parámetros de entrada y haz que muestre una String.
import com.example.unscramble.data.allWords

private fun pickRandomWordAndShuffle(): String {
   // Continue picking up a new random word until you get one that hasn't been used before
   currentWord = allWords.random()
   if (usedWords.contains(currentWord)) {
       return pickRandomWordAndShuffle()
   } else {
       usedWords.add(currentWord)
       return shuffleCurrentWord(currentWord)
   }
}

Android Studio marca un error para la función y la variable no definidas.

  1. En el GameViewModel, agrega la siguiente propiedad después de la propiedad currentWord de modo que funcione como un conjunto mutable y almacene las palabras usadas en el juego.
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
  1. Agrega otro método auxiliar llamado shuffleCurrentWord() para desordenar la palabra actual, el cual toma una String y muestra la String desordenada.
private fun shuffleCurrentWord(word: String): String {
   val tempWord = word.toCharArray()
   // Scramble the word
   tempWord.shuffle()
   while (String(tempWord).equals(word)) {
       tempWord.shuffle()
   }
   return String(tempWord)
}
  1. Agrega una función auxiliar llamada resetGame() a fin de inicializar el juego. Usarás esta función más tarde para iniciar y reiniciar el juego. En esta función, borra todas las palabras del conjunto usedWords, e inicializa el _uiState. Elige una palabra nueva para currentScrambledWord con pickRandomWordAndShuffle().
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. Agrega un bloque init al GameViewModel y llama a resetGame() desde él.
init {
   resetGame()
}

Cuando compilas tu app ahora, no ves cambios en la IU. No estás pasando los datos del ViewModel a los elementos componibles en la GameScreen.

6. Crea la arquitectura de tu IU de Compose

En Compose, la única forma de actualizar la IU es cambiando el estado de la app. Lo que sí puedes controlar es el estado de la IU. Cada vez que cambia el estado de la IU, Compose vuelve a crear las partes del árbol de IU que cambiaron. Los elementos de componibilidad pueden aceptar estados y exponer eventos. Por ejemplo, un elemento TextField/OutlinedTextField acepta un valor y expone una devolución de llamada onValueChange que solicita al controlador de devolución de llamada que cambie el valor.

//Example code no need to copy over

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Debido a que los elementos componibles aceptan estados y exponen eventos, el patrón de flujo de datos unidireccional se adapta bien a Jetpack Compose. Esta sección se centra en cómo implementar el patrón de flujo de datos unidireccional en Compose, cómo implementar eventos y contenedores de estados, y cómo trabajar con ViewModels en Compose.

Flujo de datos unidireccional

Un flujo de datos unidireccional (UDF) es un patrón de diseño en el que el estado circula hacia abajo y los eventos hacia arriba. Si sigues el flujo unidireccional de datos, podrás separar los elementos de componibilidad que muestran el estado de la IU respecto de las partes de la app que almacenan y cambian el estado.

El bucle de actualización de la IU de una app que usa un flujo unidireccional de datos se ve de la siguiente manera:

  • Evento: Parte de la IU genera un evento y lo pasa hacia arriba, como un clic en un botón que se pasa al ViewModel para que lo procese, o un evento que se pasa desde otras capas de tu app, como una indicación de que caducó la sesión del usuario.
  • Estado de actualización: Un controlador de evento puede cambiar el estado.
  • Estado de visualización: El contenedor hace circular el estado hacia abajo, y la IU lo muestra.

61eb7bcdcff42227.png

El uso del patrón de flujo unidireccional de datos para la arquitectura de la app implica lo siguiente:

  • El componente ViewModel contiene y expone el estado que consume la IU.
  • El estado de la IU son los datos de la aplicación que transforma ViewModel.
  • La IU notifica al ViewModel los eventos de usuario.
  • El ViewModel controla las acciones del usuario y actualiza el estado.
  • El estado actualizado se envía a la IU para su renderización.
  • Este proceso se repite para cualquier evento que cause una mutación del estado.

Pasa los datos

Pasa la instancia de ViewModel a la IU, es decir, del elemento GameViewModel al GameScreen() en el archivo GameScreen.kt. En GameScreen(), usa la instancia de ViewModel para acceder a uiState usando collectAsState().

La función collectAsState() recopila valores de este StateFlow y representa su valor más reciente conState. El elemento StateFlow.value se usa como valor inicial. Cada vez que se publique un valor nuevo en el StateFlow, se actualiza el State que se muestra, lo que causa la recomposición de cada uso de State.value.

  1. En la función GameScreen, pasa un segundo argumento del tipo GameViewModel con un valor predeterminado de viewModel().
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun GameScreen(
   gameViewModel: GameViewModel = viewModel()
) {
   // ...
}

de93b81a92416c23.png

  1. En la función GameScreen(), agrega una variable nueva llamada gameUiState. Usa el delegado by y llama a collectAsState() en uiState.

Este enfoque garantiza que, cada vez que haya un cambio en el valor de uiState, se produzca una recomposición para los elementos componibles con el valor de gameUiState.

import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

@Composable
fun GameScreen(
   // ...
) {
   val gameUiState by gameViewModel.uiState.collectAsState()
   // ...
}
  1. Pasa el elemento gameUiState.currentScrambledWord al elemento de componibilidad GameLayout(). Agregarás el argumento en un paso posterior, así que ignora el error por ahora.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   modifier = Modifier
       .fillMaxWidth()
       .wrapContentHeight()
       .padding(mediumPadding)
)
  1. Agrega currentScrambledWord como otro parámetro a la función de componibilidad GameLayout().
@Composable
fun GameLayout(
   currentScrambledWord: String,
   modifier: Modifier = Modifier
) {
}
  1. Actualiza la función de componibilidad GameLayout() de modo que muestre el elemento currentScrambledWord. Establece el parámetro text del primer campo de texto de la columna en currentScrambledWord.
@Composable
fun GameLayout(
   // ...
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       Text(
           text = currentScrambledWord,
           fontSize = 45.sp,
           modifier = modifier.align(Alignment.CenterHorizontally)
       )
    //...
    }
}
  1. Ejecuta la app y compílala. Deberías ver la palabra desordenada.

6d93a8e1ba5dad6f.png

Muestra la palabra propuesta

En el elemento GameLayout() componible, la actualización de la palabra propuesta por el usuario es una de las devoluciones de llamada de eventos que fluye de GameScreen a ViewModel. Los datos gameViewModel.userGuess fluirán desde el ViewModel hasta el elemento GameScreen.

el evento de devoluciones de llamada cuando se presiona la tecla listo del teclado y los cambios del intento del usuario se pasan de la IU al modelo de vista

  1. En el archivo GameScreen.kt, en el elemento GameLayout() componible, establece onValueChange en onUserGuessChanged y onKeyboardDone() en onDone de la acción del teclado. Corregirás los errores en el siguiente paso.
OutlinedTextField(
   value = "",
   singleLine = true,
   modifier = Modifier.fillMaxWidth(),
   onValueChange = onUserGuessChanged,
   label = { Text(stringResource(R.string.enter_your_word)) },
   isError = false,
   keyboardOptions = KeyboardOptions.Default.copy(
       imeAction = ImeAction.Done
   ),
   keyboardActions = KeyboardActions(
       onDone = { onKeyboardDone() }
   ),
  1. En la función de componibilidad GameLayout(), agrega dos argumentos más: la lambda onUserGuessChanged toma un argumento String y no muestra nada, y onKeyboardDone no toma nada y no muestra nada.
@Composable
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
   ) {
}
  1. En la llamada a función GameLayout(), agrega argumentos lambda para onUserGuessChanged y onKeyboardDone.
GameLayout(
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   currentScrambledWord = gameUiState.currentScrambledWord,
)

En breve, definirás el método updateUserGuess en GameViewModel.

  1. En el archivo GameViewModel.kt, agrega un método llamado updateUserGuess() que tome un argumento String, que sería la palabra propuesta por el usuario. Dentro de la función, actualiza el elemento userGuess con el elemento guessedWord que se pasó.
  fun updateUserGuess(guessedWord: String){
     userGuess = guessedWord
  }

A continuación, agrega userGuess en el ViewModel.

  1. En el archivo GameViewModel.kt, agrega una propiedad var denominada userGuess. Usa mutableStateOf() de modo que Compose observe este valor y establezca el valor inicial en "".
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

var userGuess by mutableStateOf("")
   private set
  1. En el archivo GameScreen.kt, dentro de GameLayout(), agrega otro parámetro String para userGuess. Establece el parámetro value del elemento OutlinedTextField en userGuess.
fun GameLayout(
   currentScrambledWord: String,
   userGuess: String,
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       //...
       OutlinedTextField(
           value = userGuess,
           //..
       )
   }
}
  1. En la función GameScreen, actualiza la llamada a la función GameLayout() para incluir el parámetro userGuess.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   //...
)
  1. Compila y ejecuta tu app.
  2. Intenta adivinar y escribe una palabra. El campo de texto puede mostrar la propuesta del usuario.

ed10c7f522495a.png

7. Verifica las palabras propuestas y actualiza la puntuación

En esta tarea, implementarás un método para verificar la palabra que propone un usuario y, luego, actualizarás la puntuación del juego o harás que se muestre un error. Más adelante, actualizarás la IU del estado del juego con la puntuación nueva y la palabra nueva.

  1. En GameViewModel, agrega otro método llamado checkUserGuess().
  2. En la función checkUserGuess(), agrega un bloque if else para verificar si el intento del usuario coincide con el elemento currentWord. Restablece userGuess a una cadena vacía.
fun checkUserGuess() {

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
   }
   // Reset user guess
   updateUserGuess("")
}
  1. Si el intento del usuario es incorrecto, establece isGuessedWordWrong en true. MutableStateFlow<T>. update() actualiza las MutableStateFlow.value con el valor especificado.
import kotlinx.coroutines.flow.update

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
       // User's guess is wrong, show an error
       _uiState.update { currentState ->
           currentState.copy(isGuessedWordWrong = true)
       }
   }
  1. En la clase GameUiState, agrega un Boolean llamado isGuessedWordWrong y, luego, inicialízalo en false.
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
)

A continuación, pasa la devolución de llamada del evento checkUserGuess() de GameScreen a ViewModel cuando el usuario haga clic en el botón Enviar o en la tecla Done del teclado. Pasa los datos, gameUiState.isGuessedWordWrong de ViewModel al GameScreen, para establecer el error en el campo de texto.

7f05d04164aa4646.png

  1. En el archivo GameScreen.kt, al final de la función de componibilidad GameScreen(), llama a gameViewModel.checkUserGuess() dentro de la expresión lambda onClick del botón Submit (enviar).
Button(
   modifier = modifier
       .fillMaxWidth()
       .weight(1f)
       .padding(start = 8.dp),
   onClick = { gameViewModel.checkUserGuess() }
) {
   Text(stringResource(R.string.submit))
}
  1. En la función de componibilidad GameScreen(), actualiza la llamada a la función GameLayout() a fin de pasar gameViewModel.checkUserGuess() en la expresión lambda onKeyboardDone.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() }
)
  1. En la función de componibilidad GameLayout(), agrega un parámetro de función para el Boolean, isGuessWrong. Establece el parámetro isError del elemento OutlinedTextField en isGuessWrong para mostrar el error en el campo de texto si el intento del usuario es incorrecto.
fun GameLayout(
   currentScrambledWord: String,
   isGuessWrong: Boolean,
   userGuess: String,
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       // ,...
       OutlinedTextField(
           // ...
           isError = isGuessWrong,
           keyboardOptions = KeyboardOptions.Default.copy(
               imeAction = ImeAction.Done
           ),
           keyboardActions = KeyboardActions(
               onDone = { onKeyboardDone() }
           ),
       )
}
}
  1. En la función de componibilidad GameScreen(), actualiza la llamada a la función GameLayout() para pasar isGuessWrong.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() },
   isGuessWrong = gameUiState.isGuessedWordWrong,
   // ...
)
  1. Compila y ejecuta tu app.
  2. Ingresa una respuesta incorrecta y haz clic en Submit. Observa que el campo de texto se vuelve rojo, lo que indica que existe un error.

a1bc55781d627b38.png

Observa que la etiqueta del campo de texto aún indica "Enter your word" (Ingresa la palabra). Para que sea fácil de usar, debes agregar texto de error para indicar que la palabra es incorrecta.

  1. En el archivo GameScreen.kt, en el elemento GameLayout() componible, actualiza el parámetro de etiqueta del campo de texto según isGuessWrong de la siguiente manera:
OutlinedTextField(
   // ...
   label = {
       if (isGuessWrong) {
           Text(stringResource(R.string.wrong_guess))
       } else {
           Text(stringResource(R.string.enter_your_word))
       }
   },
   // ...
)
  1. En el archivo strings.xml, agrega una cadena a la etiqueta de error.
<string name="wrong_guess">Wrong Guess!</string>
  1. Vuelve a compilar y ejecutar tu app.
  2. Ingresa una respuesta incorrecta y haz clic en Submit. Observa la etiqueta de error.

8c17eb61e9305d49.png

8. Actualiza la puntuación y la cantidad de palabras

En esta tarea, actualizarás la puntuación y el recuento de palabras mientras el usuario juega. La puntuación debe ser parte de _ uiState.

  1. En GameUiState, agrega una variable score y, luego, inicialízala en cero.
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
   val score: Int = 0
)
  1. Si deseas actualizar el valor de la puntuación, en GameViewModel, en la función checkUserGuess(), dentro de la condición if para cuando la propuesta del usuario sea correcta, aumenta el valor del elemento score.
import com.example.unscramble.data.SCORE_INCREASE

fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
   } else {
       //...
   }
}
  1. En GameViewModel, agrega otro método llamado updateGameState para actualizar la puntuación, aumenta la cantidad actual de palabras y elige una palabra nueva del archivo WordsData.kt. Agrega un Int llamado updatedScore como parámetro. Actualiza las variables de la IU del estado del juego de la siguiente manera:
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           isGuessedWordWrong = false,
           currentScrambledWord = pickRandomWordAndShuffle(),
           score = updatedScore
       )
   }
}
  1. En la función checkUserGuess(), si el intento del usuario es correcto, realiza una llamada a updateGameState con la puntuación actualizada a fin de preparar el juego para la próxima ronda.
fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       // and call updateGameState() to prepare the game for next round
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
       updateGameState(updatedScore)
   } else {
       //...
   }
}

El elemento checkUserGuess() completo debería verse de la siguiente manera:

fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       // and call updateGameState() to prepare the game for next round
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
       updateGameState(updatedScore)
   } else {
       // User's guess is wrong, show an error
       _uiState.update { currentState ->
           currentState.copy(isGuessedWordWrong = true)
       }
   }
   // Reset user guess
   updateUserGuess("")
}

A continuación, al igual que con las actualizaciones de la puntuación, debes actualizar la cantidad de palabras.

  1. Agrega otra variable para la cantidad en GameUiState. Llámala currentWordCount e inicialízala en 1.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
)
  1. En el archivo GameViewModel.kt, en la función updateGameState(), aumenta la cantidad de palabras como se muestra a continuación. Se llama a la función updateGameState() para preparar el juego para la próxima ronda.
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           //...
           currentWordCount = currentState.currentWordCount.inc(),
           )
   }
}

Pasa la puntuación y la cantidad de palabras

Completa los siguientes pasos para pasar los datos de puntuación y cantidad de palabras del ViewModel a la GameScreen.

546e101980380f80.png

  1. En el archivo GameScreen.kt, en la función de componibilidad GameLayout(), agrega el recuento de palabras como argumento y pasa los argumentos de formato wordCount al elemento de texto.
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   wordCount: Int,
   //...
) {
   //...

   Card(
       //...
   ) {
       Column(
           // ...
       ) {
           Text(
               //..
               text = stringResource(R.string.word_count, wordCount),
               style = typography.titleMedium,
               color = colorScheme.onPrimary
           )

// ...

}
  1. Actualiza la llamada a función GameLayout() para incluir el recuento de palabras.
GameLayout(
   userGuess = gameViewModel.userGuess,
   wordCount = gameUiState.currentWordCount,
   //...
)
  1. En la función de componibilidad GameScreen(), actualiza la llamada a función GameStatus() para incluir los parámetros score. Pasa la puntuación de gameUiState.
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
  1. Compila y ejecuta la app.
  2. Ingresa la palabra propuesta y haz clic en Submit. Observa que se actualizan la puntuación y la cantidad de palabras.
  3. Haz clic en Skip y observa que no sucede nada.

Para implementar la funcionalidad de omisión, debes pasar la devolución de llamada de evento de omisión al elemento GameViewModel.

  1. En el archivo GameScreen.kt, en la función de componibilidad GameScreen(), haz una llamada a gameViewModel.skipWord() en la expresión lambda onClick.

Android Studio muestra un error porque aún no implementaste la función. Para solucionar este error en el siguiente paso, agrega el método skipWord(). Cuando el usuario omite una palabra, debes actualizar las variables del juego y prepararlo para la próxima ronda.

OutlinedButton(
   onClick = { gameViewModel.skipWord() },
   modifier = Modifier.fillMaxWidth()
) {
   //...
}
  1. En GameViewModel, agrega el método skipWord().
  2. Dentro de la función skipWord(), haz una llamada a updateGameState(), pasa la puntuación y restablece el intento del usuario.
fun skipWord() {
   updateGameState(_uiState.value.score)
   // Reset user guess
   updateUserGuess("")
}
  1. Ejecuta la app y juega. Ya deberías poder omitir palabras.

e87bd75ba1269e96.png

Aún puedes jugar con más de 10 palabras. En tu próxima tarea, controlarás la última ronda del juego.

9. Controla la última ronda del juego

En la implementación actual, los usuarios pueden omitir o adivinar más de 10 palabras. En esta tarea, agregarás lógica para finalizar el juego.

d3fd67d92c5d3c35.png

Para implementar la lógica de fin del juego, primero debes verificar si el usuario alcanzó la cantidad máxima de palabras.

  1. En GameViewModel, agrega un bloque if-else y mueve el cuerpo de la función existente dentro del bloque else.
  2. Agrega una condición if para verificar que el tamaño del elemento usedWords sea igual a MAX_NO_OF_WORDS.
import com.example.android.unscramble.data.MAX_NO_OF_WORDS

private fun updateGameState(updatedScore: Int) {
   if (usedWords.size == MAX_NO_OF_WORDS){
       //Last round in the game
   } else{
       // Normal round in the game
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               currentScrambledWord = pickRandomWordAndShuffle(),
               currentWordCount = currentState.currentWordCount.inc(),
               score = updatedScore
           )
       }
   }
}
  1. Dentro del bloque if, agrega la marca Boolean isGameOver y establécela en true para indicar el final del juego.
  2. Actualiza el elemento score y restablece isGuessedWordWrong dentro del bloque if. En el siguiente código, se muestra cómo debería verse tu función:
private fun updateGameState(updatedScore: Int) {
   if (usedWords.size == MAX_NO_OF_WORDS){
       //Last round in the game, update isGameOver to true, don't pick a new word
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               score = updatedScore,
               isGameOver = true
           )
       }
   } else{
       // Normal round in the game
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               currentScrambledWord = pickRandomWordAndShuffle(),
               currentWordCount = currentState.currentWordCount.inc(),
               score = updatedScore
           )
       }
   }
}
  1. En GameUiState, agrega la variable Boolean isGameOver y establécela en false.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
   val isGameOver: Boolean = false
)
  1. Ejecuta la app y juega. No puedes jugar con más de 10 palabras.

ac8a12e66111f071.png

Cuando el juego termine, sería bueno que se lo informes al usuario y le preguntes si quiere volver a jugar. Implementarás esta función en tu próxima tarea.

Muestra el diálogo de fin del juego

En esta tarea, pasarás datos del elemento isGameOver hacia GameScreen desde el ViewModel y los usarás para mostrar un diálogo de alerta con opciones de finalizar o reiniciar el juego.

Un diálogo es una ventana pequeña que le indica al usuario que debe tomar una decisión o ingresar información adicional. Por lo general, no ocupa toda la pantalla y requiere que los usuarios realicen una acción para poder continuar. Android ofrece diferentes tipos de diálogos. En este codelab, aprenderás sobre los diálogos de alerta.

Anatomía del diálogo de alerta

eb6edcdd0818b900.png

  1. Contenedor
  2. Ícono (opcional)
  3. Título (opcional)
  4. Texto complementario
  5. Divisor (opcional)
  6. Acciones

El archivo GameScreen.kt del código de partida ya proporciona una función que muestra un diálogo de alerta con opciones para salir o reiniciar el juego.

78d43c7aa01b414d.png

@Composable
private fun FinalScoreDialog(
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
   val activity = (LocalContext.current as Activity)

   AlertDialog(
       onDismissRequest = {
           // Dismiss the dialog when the user clicks outside the dialog or on the back
           // button. If you want to disable that functionality, simply use an empty
           // onDismissRequest.
       },
       title = { Text(stringResource(R.string.congratulations)) },
       text = { Text(stringResource(R.string.you_scored, 0)) },
       modifier = modifier,
       dismissButton = {
           TextButton(
               onClick = {
                   activity.finish()
               }
           ) {
               Text(text = stringResource(R.string.exit))
           }
       },
       confirmButton = {
           TextButton(
               onClick = {
                   onPlayAgain()
               }
           ) {
               Text(text = stringResource(R.string.play_again))
           }
       }
   )
}

En esta función, los parámetros title y text muestran el título y el texto complementario en el diálogo de la alerta. dismissButton y confirmButton son los botones de texto. En el parámetro dismissButton, muestras el texto Salir y cierras la app cuando finalizas la actividad. En el parámetro confirmButton, reinicias el juego y muestras el texto Play Again.

a24f59b84a178d9b.png

  1. En el archivo GameScreen.kt, en la función FinalScoreDialog(), observa el parámetro de la puntuación para mostrar la puntuación del juego en el diálogo de alerta.
@Composable
private fun FinalScoreDialog(
   score: Int,
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
  1. En la función FinalScoreDialog(), observa el uso de la expresión lambda del parámetro text para usar score como argumento de formato en el texto del diálogo.
text = { Text(stringResource(R.string.you_scored, score)) }
  1. En el archivo GameScreen.kt, al final de la función de componibilidad GameScreen(), después del bloque Column, agrega una condición if para verificar el elemento gameUiState.isGameOver.
  2. En el bloque if, muestra el diálogo de alerta. Haz una llamada a FinalScoreDialog() pasando los valores score y gameViewModel.resetGame() para la devolución de llamada del evento onPlayAgain.
if (gameUiState.isGameOver) {
   FinalScoreDialog(
       score = gameUiState.score,
       onPlayAgain = { gameViewModel.resetGame() }
   )
}

resetGame() es una devolución de llamada de evento que se pasa de GameScreen a ViewModel.

  1. En el archivo GameViewModel.kt, recupera la función resetGame(), inicializa _uiState y elige una palabra nueva.
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. Compila y ejecuta tu app.
  2. Juega hasta llegar al final y observa el diálogo de alerta con opciones como Exit, para salir del juego, o Play again, para volver a jugar. Prueba las opciones que aparecen en el diálogo de alerta.

c6727347fe0db265.png

10. El estado en la rotación del dispositivo

En codelabs anteriores, aprendiste sobre los cambios de configuración en Android. Cuando se produce un cambio de configuración, Android reinicia la actividad desde cero y ejecuta todas las devoluciones de llamada de inicio del ciclo de vida.

El ViewModel almacena los datos relacionados con la app que no se destruyen cuando el framework de Android destruye y vuelve a crear la actividad. Los objetos ViewModel se retienen automáticamente y no se destruyen como la instancia de la actividad durante el cambio de configuración. Los datos que conservan están disponibles de inmediato después de la recomposición.

En esta tarea, verificarás si la app retiene la IU de estado durante un cambio de configuración.

  1. Ejecuta la app e ingresa algunas palabras. Cambia la configuración del dispositivo de vertical a horizontal, o viceversa.
  2. Observa que los datos guardados en la IU de estado del ViewModel se conserven durante el cambio de configuración.

4a63084643723724.png

4134470d435581dd.png

11. Obtén el código de solución

Para descargar el código del codelab terminado, puedes usar estos comandos de git:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout viewmodel

También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Descargar ZIP

Si deseas ver el código de la solución para este codelab, míralo en GitHub.

12. Conclusión

¡Felicitaciones! Completaste el codelab. Ahora entiendes como los lineamientos de la arquitectura de apps para Android recomiendan separar las clases que tienen responsabilidades diferentes y controlar la IU a partir de un modelo.

No olvides compartir tu trabajo en redes sociales con el hashtag #AndroidBasics.

Más información