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
- Introducción a la arquitectura de apps para Android
- Uso de la clase
ViewModel
en tu app - Retención de datos de la IU a través de cambios en la configuración de dispositivos con un
ViewModel
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:
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:
- Abre el proyecto con el código de partida en Android Studio.
- Ejecuta la app en un dispositivo Android o en un emulador.
- 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
.
// 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.
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
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).
@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.
// 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.
Arquitectura de app recomendada
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.
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.
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.
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
.
- Abre
build.gradle.kts (Module :app)
, desplázate hasta el bloquedependencies
y agrega la siguiente dependencia paraViewModel
. 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")
//...
}
- En el paquete
ui
, crea una clase o un archivo Kotlin llamadoGameViewModel
. Extiéndelo desde la claseViewModel
.
import androidx.lifecycle.ViewModel
class GameViewModel : ViewModel() {
}
- En el paquete
ui
, agrega una clase de modelo para la IU de estado llamadaGameUiState
. 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
esprivate
y mutable. Por lo tanto, solo es accesible y editable dentro de la claseViewModel
.
Fuera de la clase ViewModel
:
- El modificador de visibilidad predeterminado de Kotlin es
public
, por lo quecount
es público y accesible desde otras clases, como los controladores de IU. Un tipoval
no puede tener un método set. Es inmutable y de solo lectura, por lo que solo puedes anular el métodoget()
. 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 delViewModel
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.
- En el archivo
GameViewModel.kt
, agrega una propiedad de copia de seguridad auiState
llamada_uiState
. Asigna el nombreuiState
a la propiedad, que es del tipoStateFlow<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>
- 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.
- En
GameViewModel
, agrega una propiedad llamadacurrentWord
del tipoString
para guardar la palabra desordenada actual.
private lateinit var currentWord: String
- 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 unaString
.
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.
- En el
GameViewModel
, agrega la siguiente propiedad después de la propiedadcurrentWord
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()
- Agrega otro método auxiliar llamado
shuffleCurrentWord()
para desordenar la palabra actual, el cual toma unaString
y muestra laString
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)
}
- 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 conjuntousedWords
, e inicializa el_uiState
. Elige una palabra nueva paracurrentScrambledWord
conpickRandomWordAndShuffle()
.
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- Agrega un bloque
init
alGameViewModel
y llama aresetGame()
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 ViewModel
s 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.
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
.
- En la función
GameScreen
, pasa un segundo argumento del tipoGameViewModel
con un valor predeterminado deviewModel()
.
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun GameScreen(
gameViewModel: GameViewModel = viewModel()
) {
// ...
}
- En la función
GameScreen()
, agrega una variable nueva llamadagameUiState
. Usa el delegadoby
y llama acollectAsState()
enuiState
.
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()
// ...
}
- Pasa el elemento
gameUiState.currentScrambledWord
al elemento de componibilidadGameLayout()
. 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)
)
- Agrega
currentScrambledWord
como otro parámetro a la función de componibilidadGameLayout()
.
@Composable
fun GameLayout(
currentScrambledWord: String,
modifier: Modifier = Modifier
) {
}
- Actualiza la función de componibilidad
GameLayout()
de modo que muestre el elementocurrentScrambledWord
. Establece el parámetrotext
del primer campo de texto de la columna encurrentScrambledWord
.
@Composable
fun GameLayout(
// ...
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = currentScrambledWord,
fontSize = 45.sp,
modifier = modifier.align(Alignment.CenterHorizontally)
)
//...
}
}
- Ejecuta la app y compílala. Deberías ver la palabra desordenada.
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
.
- En el archivo
GameScreen.kt
, en el elementoGameLayout()
componible, estableceonValueChange
enonUserGuessChanged
yonKeyboardDone()
enonDone
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() }
),
- En la función de componibilidad
GameLayout()
, agrega dos argumentos más: la lambdaonUserGuessChanged
toma un argumentoString
y no muestra nada, yonKeyboardDone
no toma nada y no muestra nada.
@Composable
fun GameLayout(
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
currentScrambledWord: String,
modifier: Modifier = Modifier,
) {
}
- En la llamada a función
GameLayout()
, agrega argumentos lambda paraonUserGuessChanged
yonKeyboardDone
.
GameLayout(
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
currentScrambledWord = gameUiState.currentScrambledWord,
)
En breve, definirás el método updateUserGuess
en GameViewModel
.
- En el archivo
GameViewModel.kt
, agrega un método llamadoupdateUserGuess()
que tome un argumentoString
, que sería la palabra propuesta por el usuario. Dentro de la función, actualiza el elementouserGuess
con el elementoguessedWord
que se pasó.
fun updateUserGuess(guessedWord: String){
userGuess = guessedWord
}
A continuación, agrega userGuess
en el ViewModel.
- En el archivo
GameViewModel.kt
, agrega una propiedad var denominadauserGuess
. UsamutableStateOf()
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
- En el archivo
GameScreen.kt
, dentro deGameLayout()
, agrega otro parámetroString
parauserGuess
. Establece el parámetrovalue
del elementoOutlinedTextField
enuserGuess
.
fun GameLayout(
currentScrambledWord: String,
userGuess: String,
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
//...
OutlinedTextField(
value = userGuess,
//..
)
}
}
- En la función
GameScreen
, actualiza la llamada a la funciónGameLayout()
para incluir el parámetrouserGuess
.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
//...
)
- Compila y ejecuta tu app.
- Intenta adivinar y escribe una palabra. El campo de texto puede mostrar la propuesta del usuario.
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.
- En
GameViewModel
, agrega otro método llamadocheckUserGuess()
. - En la función
checkUserGuess()
, agrega un bloqueif else
para verificar si el intento del usuario coincide con el elementocurrentWord
. RestableceuserGuess
a una cadena vacía.
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
} else {
}
// Reset user guess
updateUserGuess("")
}
- Si el intento del usuario es incorrecto, establece
isGuessedWordWrong
entrue
.MutableStateFlow<T>.
update()
actualiza lasMutableStateFlow.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)
}
}
- En la clase
GameUiState
, agrega unBoolean
llamadoisGuessedWordWrong
y, luego, inicialízalo enfalse
.
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.
- En el archivo
GameScreen.kt
, al final de la función de componibilidadGameScreen()
, llama agameViewModel.checkUserGuess()
dentro de la expresión lambdaonClick
del botón Submit (enviar).
Button(
modifier = modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 8.dp),
onClick = { gameViewModel.checkUserGuess() }
) {
Text(stringResource(R.string.submit))
}
- En la función de componibilidad
GameScreen()
, actualiza la llamada a la funciónGameLayout()
a fin de pasargameViewModel.checkUserGuess()
en la expresión lambdaonKeyboardDone
.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() }
)
- En la función de componibilidad
GameLayout()
, agrega un parámetro de función para elBoolean
,isGuessWrong
. Establece el parámetroisError
del elementoOutlinedTextField
enisGuessWrong
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() }
),
)
}
}
- En la función de componibilidad
GameScreen()
, actualiza la llamada a la funciónGameLayout()
para pasarisGuessWrong
.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() },
isGuessWrong = gameUiState.isGuessedWordWrong,
// ...
)
- Compila y ejecuta tu app.
- 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.
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.
- En el archivo
GameScreen.kt
, en el elementoGameLayout()
componible, actualiza el parámetro de etiqueta del campo de texto segúnisGuessWrong
de la siguiente manera:
OutlinedTextField(
// ...
label = {
if (isGuessWrong) {
Text(stringResource(R.string.wrong_guess))
} else {
Text(stringResource(R.string.enter_your_word))
}
},
// ...
)
- En el archivo
strings.xml
, agrega una cadena a la etiqueta de error.
<string name="wrong_guess">Wrong Guess!</string>
- Vuelve a compilar y ejecutar tu app.
- Ingresa una respuesta incorrecta y haz clic en Submit. Observa la etiqueta de error.
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
.
- En
GameUiState
, agrega una variablescore
y, luego, inicialízala en cero.
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
val score: Int = 0
)
- Si deseas actualizar el valor de la puntuación, en
GameViewModel
, en la funcióncheckUserGuess()
, dentro de la condiciónif
para cuando la propuesta del usuario sea correcta, aumenta el valor del elementoscore
.
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 {
//...
}
}
- En
GameViewModel
, agrega otro método llamadoupdateGameState
para actualizar la puntuación, aumenta la cantidad actual de palabras y elige una palabra nueva del archivoWordsData.kt
. Agrega unInt
llamadoupdatedScore
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
)
}
}
- En la función
checkUserGuess()
, si el intento del usuario es correcto, realiza una llamada aupdateGameState
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.
- Agrega otra variable para la cantidad en
GameUiState
. LlámalacurrentWordCount
e inicialízala en1
.
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
)
- En el archivo
GameViewModel.kt
, en la funciónupdateGameState()
, aumenta la cantidad de palabras como se muestra a continuación. Se llama a la funciónupdateGameState()
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
.
- En el archivo
GameScreen.kt
, en la función de componibilidadGameLayout()
, agrega el recuento de palabras como argumento y pasa los argumentos de formatowordCount
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
)
// ...
}
- Actualiza la llamada a función
GameLayout()
para incluir el recuento de palabras.
GameLayout(
userGuess = gameViewModel.userGuess,
wordCount = gameUiState.currentWordCount,
//...
)
- En la función de componibilidad
GameScreen()
, actualiza la llamada a funciónGameStatus()
para incluir los parámetrosscore
. Pasa la puntuación degameUiState
.
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
- Compila y ejecuta la app.
- Ingresa la palabra propuesta y haz clic en Submit. Observa que se actualizan la puntuación y la cantidad de palabras.
- 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
.
- En el archivo
GameScreen.kt
, en la función de componibilidadGameScreen()
, haz una llamada agameViewModel.skipWord()
en la expresión lambdaonClick
.
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()
) {
//...
}
- En
GameViewModel
, agrega el métodoskipWord()
. - Dentro de la función
skipWord()
, haz una llamada aupdateGameState()
, pasa la puntuación y restablece el intento del usuario.
fun skipWord() {
updateGameState(_uiState.value.score)
// Reset user guess
updateUserGuess("")
}
- Ejecuta la app y juega. Ya deberías poder omitir palabras.
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.
Para implementar la lógica de fin del juego, primero debes verificar si el usuario alcanzó la cantidad máxima de palabras.
- En
GameViewModel
, agrega un bloqueif-else
y mueve el cuerpo de la función existente dentro del bloqueelse
. - Agrega una condición
if
para verificar que el tamaño del elementousedWords
sea igual aMAX_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
)
}
}
}
- Dentro del bloque
if
, agrega la marcaBoolean
isGameOver
y establécela entrue
para indicar el final del juego. - Actualiza el elemento
score
y restableceisGuessedWordWrong
dentro del bloqueif
. 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
)
}
}
}
- En
GameUiState
, agrega la variableBoolean
isGameOver
y establécela enfalse
.
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
val isGameOver: Boolean = false
)
- Ejecuta la app y juega. No puedes jugar con más de 10 palabras.
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
- Contenedor
- Ícono (opcional)
- Título (opcional)
- Texto complementario
- Divisor (opcional)
- 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.
@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.
- En el archivo
GameScreen.kt
, en la funciónFinalScoreDialog()
, 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
) {
- En la función
FinalScoreDialog()
, observa el uso de la expresión lambda del parámetrotext
para usarscore
como argumento de formato en el texto del diálogo.
text = { Text(stringResource(R.string.you_scored, score)) }
- En el archivo
GameScreen.kt
, al final de la función de componibilidadGameScreen()
, después del bloqueColumn
, agrega una condiciónif
para verificar el elementogameUiState.isGameOver
. - En el bloque
if
, muestra el diálogo de alerta. Haz una llamada aFinalScoreDialog()
pasando los valoresscore
ygameViewModel.resetGame()
para la devolución de llamada del eventoonPlayAgain
.
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
.
- En el archivo
GameViewModel.kt
, recupera la funciónresetGame()
, inicializa_uiState
y elige una palabra nueva.
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- Compila y ejecuta tu app.
- 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.
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.
- Ejecuta la app e ingresa algunas palabras. Cambia la configuración del dispositivo de vertical a horizontal, o viceversa.
- Observa que los datos guardados en la IU de estado del
ViewModel
se conserven durante el cambio de configuración.
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.
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.