1. Antes de começar
Nos codelabs anteriores, você aprendeu sobre o ciclo de vida das atividades e os problemas relacionados com as mudanças de configuração. Quando ocorre uma mudança de configuração, é possível salvar os dados de um app de maneiras diferentes, como usar rememberSaveable
ou salvar o estado da instância. No entanto, essas opções podem criar problemas. Na maioria das vezes, é possível usar rememberSaveable
, mas isso pode significar manter a lógica dentro ou perto de elementos combináveis. Quando os apps crescerem, afaste os dados e a lógica dos elementos combináveis. Neste codelab, você vai aprender a criar um app com eficiência e preservar os dados dele durante mudanças de configuração usando as diretrizes da biblioteca Android Jetpack, do ViewModel
e da arquitetura de apps Android.
As bibliotecas do Android Jetpack são uma coleção que facilita o desenvolvimento de excelentes apps Android. Essas bibliotecas ajudam a seguir as práticas recomendadas e eliminar o código boilerplate, além de simplificar tarefas complexas para que seja possível se concentrar na parte importante do código, como a lógica do app.
A arquitetura de apps é um conjunto de regras de design para um app. Assim como a planta de uma casa, a arquitetura fornece a estrutura. Uma boa arquitetura pode deixar seu código otimizado, flexível, escalonável, testável e sustentável por anos. O Guia para a arquitetura de apps fornece recomendações e práticas recomendadas.
Neste codelab, você vai aprender a usar o ViewModel
, um dos componentes de arquitetura das bibliotecas do Android Jetpack que podem armazenar os dados do app. Os dados armazenados não serão perdidos se o framework destruir e recriar as atividades durante uma mudança de configuração ou outros eventos. No entanto, os dados serão perdidos se a atividade for destruída devido à interrupção do processo. O ViewModel
só armazena dados em cache usando recriações rápidas de atividades.
Pré-requisitos
- Conhecimento sobre Kotlin, incluindo funções, lambdas e elementos combináveis sem estado.
- Conhecimento básico sobre como criar layouts no Jetpack Compose.
- Conhecimento básico do Material Design.
O que você vai aprender
- Introdução à arquitetura de apps Android
- Como usar a classe
ViewModel
no seu app - Como armazenar dados da interface após mudanças na configuração do dispositivo usando um
ViewModel
O que você vai criar
- Um app de jogo Unscramble, em que o usuário consegue adivinhar as palavras embaralhadas
O que é necessário
- A versão mais recente do Android Studio.
- Conexão de Internet para fazer o download do código inicial
2. Visão geral do app
Visão geral do jogo
O app Unscramble é um jogo de palavras embaralhadas para um só jogador. O app exibe uma palavra embaralhada, e o jogador precisa adivinhá-la usando todas as letras mostradas. O jogador marca pontos se a palavra está correta. Caso contrário, o jogador pode tentar adivinhar a palavra quantas vezes quiser. O app também tem a opção de pular a palavra atual. No canto superior direito, ele mostra a contagem de palavras, que é o número de palavras embaralhadas jogadas na sessão atual. Cada partida tem 10 palavras embaralhadas.
Acessar o código inicial
Para começar, faça o download do código inicial:
Outra opção é clonar o repositório do GitHub:
$ 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
Procure o código inicial no repositório do GitHub do Unscramble
(link em inglês).
3. Visão geral do app inicial
Para se familiarizar com o código inicial, siga estas etapas:
- Abra o projeto com o código inicial no Android Studio.
- Execute o app em um dispositivo Android ou em um emulador.
- Toque nos botões Submit e Skip para testar o app.
Você vai notar bugs no app. A palavra embaralhada não é mostrada, mas está fixada no código como "scrambleun", e nada acontece quando você toca nos botões.
Neste codelab, você vai implementar as funcionalidades do jogo usando a arquitetura de apps Android.
Código inicial etapa por etapa
O código inicial tem o layout predefinido da tela de jogo. Neste módulo, você vai implementar a lógica do jogo. Você usará componentes da arquitetura para implementar a arquitetura recomendada do app e resolver os problemas mencionados acima. Confira a seguir um breve tutorial sobre alguns arquivos para começar.
WordsData.kt
Esse arquivo contém uma lista das palavras usadas no jogo, constantes para o número máximo de palavras por partida e o número de pontos que o jogador marca para cada palavra correta.
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
Esse arquivo contém principalmente o código gerado pelo modelo. Você mostra o elemento combinável GameScreen
no bloco setContent{}
.
GameScreen.kt
Todos os elementos combináveis de interface estão definidos no arquivo GameScreen.kt
. As seções abaixo oferecem um tutorial de algumas funções de composição.
GameStatus
GameStatus
é uma função combinável que mostra a pontuação do jogo na parte de baixo da tela. Ela tem um texto combinável em um Card
. Por enquanto, a pontuação está fixada no código como 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
é uma função combinável que mostra a funcionalidade principal do jogo, incluindo a palavra embaralhada, as instruções do jogo e um campo de texto que aceita os palpites do usuário.
O código de GameLayout
abaixo contém uma coluna dentro de um Card
com três elementos filhos: o texto da palavra embaralhada, o texto das instruções e o campo de texto do OutlinedTextField
para a palavra do usuário. Por enquanto, a palavra embaralhada está fixada no código como scrambleun
. Mais adiante no codelab, você vai implementar uma funcionalidade para mostrar uma palavra do arquivo 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 = { }
)
)
}
}
}
O elemento combinável OutlinedTextField
é semelhante ao elemento combinável TextField
de apps em codelabs anteriores.
Há dois tipos de campos de texto:
- Campos de texto preenchidos
- Campos de texto contornados
Os campos de texto contornados têm menos ênfase visual do que os preenchidos. Quando eles aparecem em lugares como formulários, onde muitos campos de texto são colocados juntos, a ênfase reduzida deles ajuda a simplificar o layout.
No código inicial, o OutlinedTextField
não é atualizado quando o usuário digita um palpite. Você vai atualizar esse recurso no codelab.
GameScreen
O elemento combinável GameScreen
contém as funções combináveis GameStatus
e GameLayout
, o título do jogo, a contagem de palavras e os elementos combináveis dos botões Submit (Enviar) e Skip (Pular).
@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))
}
}
Os eventos de clique do botão não são implementados no código inicial. Você vai implementar esses eventos como parte do codelab.
FinalScoreDialog
O elemento combinável FinalScoreDialog
mostra uma caixa de diálogo, ou seja, uma janela pequena que dá ao usuário opções para Play Again (Jogar novamente) ou Exit (Sair) do jogo. Mais adiante neste codelab, você vai implementar uma lógica para mostrar essa caixa de diálogo no final do jogo.
// 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. Saiba mais sobre a arquitetura de apps
A arquitetura de um app fornece diretrizes para ajudar a alocar as responsabilidades do app entre as classes. Uma arquitetura de app bem projetada ajuda a escalonar o app e a ampliá-lo com outros recursos. A arquitetura também pode simplificar a colaboração em equipe.
Os princípios de arquitetura mais comuns são a separação de conceitos e o modelo de base da interface.
Separação de conceitos
O princípio de separação de conceitos diz que o app é dividido em classes de funções, cada uma com responsabilidades separadas.
Basear a interface em um modelo
A interface de base de um princípio de modelo determina que você precisa basear sua interface em um modelo, de preferência um que seja persistente. Os modelos são responsáveis por processar os dados de um app e são independentes dos elementos da interface e dos componentes do app. Portanto, eles não são afetados pelo ciclo de vida e por preocupações relacionadas ao app.
Arquitetura de app recomendada
Considerando os princípios de arquitetura comuns mencionados na seção anterior, cada app precisa ter pelo menos duas camadas:
- Camada de interface: uma camada que exibe os dados do app na tela, mas é independente dos dados.
- Camada de dados: uma camada que armazena, recupera e expõe os dados do app.
É possível adicionar uma camada extra, conhecida como camada de domínios, para simplificar e reutilizar as interações entre a interface e as camadas de dados. Essa camada é opcional e está fora do escopo deste curso.
Camada de interface
A função da camada de interface, ou camada de apresentação, é exibir os dados do aplicativo na tela. Sempre que os dados mudam devido a uma interação do usuário, como o pressionamento de um botão, a interface precisa ser atualizada para refletir as mudanças.
A camada de interface é composta pelos seguintes componentes:
- Elementos da interface: componentes que renderizam os dados na tela. Crie esses elementos usando o Jetpack Compose.
- Detentores de estado: componentes que armazenam os dados, os expõem à interface e processam a lógica do app. Um exemplo de detentor de estado é o ViewModel.
ViewModel
O componente ViewModel
armazena e expõe o estado que a interface consome. O estado da interface são os dados do aplicativo transformados por ViewModel
. O ViewModel
permite que o app siga o princípio da arquitetura para orientar a interface do modelo.
O ViewModel
armazena os dados relacionados ao app que não são destruídos quando a atividade é destruída e recriada pelo framework do Android. Ao contrário da instância de atividade, os objetos ViewModel
não são destruídos. O app retém automaticamente objetos ViewModel
durante mudanças de configuração para que os dados retidos fiquem imediatamente disponíveis após a recomposição.
Para implementar o ViewModel
no seu app, estenda a classe ViewModel
, que é da biblioteca de componentes da arquitetura, e armazene os dados do app nessa classe.
Estado da interface
A interface é o que o usuário vê, e o estado dela é o que o app diz que precisa ser mostrado. A interface é a representação visual do estado da interface. Todas as mudanças no estado são imediatamente refletidas na interface.
A interface é resultado da vinculação dos elementos mostrados com o estado dela.
// Example of UI state definition, do not copy over
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
Imutabilidade
A definição do estado da interface no exemplo acima é imutável. Os objetos imutáveis fornecem garantias de que várias fontes não alteram o estado do app instantaneamente. Essa proteção libera a interface para se concentrar em um único papel: ler o estado e atualizar os elementos da interface de acordo com isso. Portanto, nunca modifique o estado diretamente na interface, a menos que ela seja a única fonte dos dados. A violação desse princípio resulta em várias fontes da verdade para a mesma informação, levando a inconsistências de dados e bugs sutis.
5. Adicionar um ViewModel
Nesta tarefa, você vai adicionar um ViewModel
ao app para armazenar o estado da interface do jogo (palavra embaralhada, contagem de palavras e pontuação). Para resolver o problema do código inicial que você percebeu na seção anterior, salve os dados do jogo no ViewModel
.
- Abra
build.gradle.kts (Module :app)
, role até o blocodependencies
e adicione a seguinte dependência paraViewModel
. Ela é usada para adicionar o modelo de visualização com reconhecimento de ciclo de vida ao app do Compose.
dependencies {
// other dependencies
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
- No pacote
ui
, crie uma classe/arquivo Kotlin com o nomeGameViewModel
. Estenda-a da classeViewModel
.
import androidx.lifecycle.ViewModel
class GameViewModel : ViewModel() {
}
- No pacote
ui
, adicione uma classe de modelo para a interface do estado chamadaGameUiState
. Transforme-a em uma classe de dados e adicione uma variável para a palavra embaralhada atual.
data class GameUiState(
val currentScrambledWord: String = ""
)
StateFlow
StateFlow
é um fluxo observável detentor de dados que emite as atualizações de estado novas e atuais. A propriedade value
reflete o valor do estado atual. Para atualizar o estado e enviá-lo ao fluxo, atribua um novo valor à propriedade da classe MutableStateFlow
.
No Android, StateFlow
funciona bem com classes que precisam manter um estado imutável observável.
Um StateFlow
pode ser exposto no GameUiState
para que os elementos combináveis possam detectar atualizações de estado da interface e fazer com que o estado da tela sobreviva às mudanças de configuração.
Na classe GameViewModel
, adicione a seguinte propriedade _uiState
:
import kotlinx.coroutines.flow.MutableStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
Propriedade de apoio
Uma propriedade de apoio permite que você retorne algo de um getter diferente do objeto exato.
Para cada propriedade var
, o framework Kotlin gera getters e setters.
Para os métodos getter e setter, é possível substituir um deles, ou ambos, e fornecer um comportamento personalizado próprio. Para implementar uma propriedade de apoio, você substitui o método getter para retornar uma versão somente leitura dos dados. O exemplo a seguir mostra uma propriedade de apoio.
//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 outro exemplo, digamos que você queira que os dados do app sejam particulares para o ViewModel
:
Na classe ViewModel
:
- A propriedade
_count
éprivate
e mutável. Portanto, ela só pode ser acessada e editada na classeViewModel
.
Fora da classe ViewModel
:
- O modificador de visibilidade padrão no Kotlin é
public
. Portanto, a classecount
é pública e pode ser acessada por outras classes, como controladores de interface. Um tipoval
não pode ter um setter. Ele é imutável e destinado somente para leitura, então só é possível substituir o métodoget()
. Quando uma classe externa acessar essa propriedade, ela vai retornar o valor de_count
, que não pode ser modificado. Essa propriedade de apoio protege os dados do app dentro doViewModel
contra mudanças indesejadas e não seguras por classes externas, mas permite que autores de chamadas externos acessem o valor com segurança.
- No arquivo
GameViewModel.kt
, adicione auiState
uma propriedade de apoio com o nome_uiState
. Nomeie a propriedade comouiState
e use o tipoStateFlow<GameUiState>
.
Agora, _uiState
só pode ser acessado e editado no GameViewModel
. A interface pode ler o valor dela usando a propriedade somente leitura, uiState
. É possível corrigir o erro de inicialização na próxima etapa.
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>
- Defina
uiState
como_uiState.asStateFlow()
.
O asStateFlow()
transforma esse fluxo de estado mutável em um fluxo de estado somente leitura.
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()
Exibir palavra embaralhada aleatória
Nesta tarefa, você vai adicionar métodos auxiliares para escolher uma palavra aleatória do WordsData.kt
e criar a palavra embaralhada.
- No
GameViewModel
, adicione uma propriedade com o nomecurrentWord
do tipoString
para salvar a palavra embaralhada atual.
private lateinit var currentWord: String
- Adicione um método auxiliar para escolher uma palavra aleatória na lista e a embaralhar. Chame o arquivo de
pickRandomWordAndShuffle()
sem parâmetros de entrada e faça com que ele retorne umaString
.
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)
}
}
O Android Studio sinaliza um erro para a variável e a função indefinidas.
- No
GameViewModel
, adicione a seguinte propriedade após acurrentWord
para servir como um conjunto mutável no armazenamento de palavras usadas no jogo:
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
- Adicione outro método auxiliar para embaralhar a palavra atual com o nome
shuffleCurrentWord()
, que usa umaString
e retorna aString
embaralhada.
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)
}
- Adicione uma função auxiliar para inicializar o jogo com o nome
resetGame()
. Você vai usar essa função mais tarde para iniciar e reiniciar o jogo. Nessa função, limpe todas as palavras do conjuntousedWords
e inicie o_uiState
. Escolha uma nova palavra paracurrentScrambledWord
usandopickRandomWordAndShuffle()
.
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- Adicione um bloco
init
aoGameViewModel
e chame oresetGame()
dele.
init {
resetGame()
}
Ao criar seu app agora, ainda não vão aparecer mudanças na interface. Você não transmite os dados do ViewModel
aos elementos combináveis na GameScreen
.
6. Como arquitetar a interface do Compose
No Compose, a única maneira de atualizar a interface é mudando o estado do app. O que você pode controlar é o estado da interface. Cada vez que o estado da interface muda, o Compose recria as partes da árvore da interface que mudaram. Os elementos combináveis podem aceitar estado e expor eventos. Por exemplo, um TextField
/OutlinedTextField
aceita um valor e expõe um callback onValueChange
que solicita que o gerenciador de callback mude o valor.
//Example code no need to copy over
var name by remember { mutableStateOf("") }
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
Como as funções de composição aceitam estados e expõem eventos, o padrão de fluxo de dados unidirecional é adequado para o Jetpack Compose. Esta seção se concentra em como implementar o padrão de fluxo de dados unidirecional no Compose, como implementar detentores de estados e eventos e como trabalhar com ViewModel
s no Compose.
Fluxo de dados unidirecional
Um fluxo de dados unidirecional (UDF, na sigla em inglês) é um padrão de design em que os estados fluem para baixo, e os eventos para cima. Ao seguir o fluxo de dados unidirecional, você pode dissociar os elementos que exibem o estado na interface das partes do app que armazenam e mudam o estado.
O loop de atualização da interface para um app usando o fluxo de dados unidirecional é semelhante a este:
- Evento: parte da interface gera um evento e o transmite para cima, como um clique de botão transmitido ao ViewModel para ser processado, ou um evento transmitido de outras camadas do app, como indicação de que a sessão do usuário expirou.
- Estado de atualização: um manipulador de eventos pode mudar o estado.
- Estado de exibição: o detentor do estado o transmite, e a interface o mostra.
O uso do padrão UDF para a arquitetura de apps tem estas implicações:
- O
ViewModel
armazena e expõe o estado que a interface consome. - O estado da interface são os dados do app transformados pelo
ViewModel
. - A interface notifica o
ViewModel
sobre eventos do usuário. - O
ViewModel
processa as ações do usuário e atualiza o estado. - O estado atualizado é retornado à interface para renderização.
- Esse processo se repete para qualquer evento que cause uma mutação de estado.
Transmitir os dados
Transmita a instância do ViewModel para a interface, ou seja, do GameViewModel
para a GameScreen()
no arquivo GameScreen.kt
. Na GameScreen()
, use a instância do ViewModel para acessar o uiState
usando collectAsState()
.
A função collectAsState()
coleta valores desse StateFlow
e representa o valor mais recente dela usando State
. O StateFlow.value
é usado como um valor inicial. Sempre que há um novo valor postado no StateFlow
(link em inglês), o valor retornado de State
é atualizado, causando a recomposição de cada uso de State.value
.
- Na função
GameScreen
, transmita um segundo argumento do tipoGameViewModel
com um valor padrão deviewModel()
.
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun GameScreen(
gameViewModel: GameViewModel = viewModel()
) {
// ...
}
- Na função
GameScreen()
, adicione uma nova variável com o nomegameUiState
. Use o delegadoby
e chamecollectAsState()
nouiState
.
Essa abordagem garante que, sempre que haja uma mudança no valor do uiState
, a recomposição ocorra para os elementos combináveis usando o valor gameUiState
.
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@Composable
fun GameScreen(
// ...
) {
val gameUiState by gameViewModel.uiState.collectAsState()
// ...
}
- Transmita
gameUiState.currentScrambledWord
para o elemento combinávelGameLayout()
. Você vai adicionar o argumento em uma etapa posterior, então ignore o erro por enquanto.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(mediumPadding)
)
- Adicione
currentScrambledWord
como outro parâmetro à função combinávelGameLayout()
.
@Composable
fun GameLayout(
currentScrambledWord: String,
modifier: Modifier = Modifier
) {
}
- Atualize a função de composição
GameLayout()
para mostrarcurrentScrambledWord
. Defina o parâmetrotext
do primeiro campo de texto na coluna comocurrentScrambledWord
.
@Composable
fun GameLayout(
// ...
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = currentScrambledWord,
fontSize = 45.sp,
modifier = modifier.align(Alignment.CenterHorizontally)
)
//...
}
}
- Execute e crie o app. A palavra embaralhada vai aparecer.
Mostrar a palavra do palpite
No elemento combinável GameLayout()
, a atualização do palpite do usuário é um dos callbacks de evento que fluem de GameScreen
para ViewModel
. Os dados gameViewModel.userGuess
fluem do ViewModel
para a GameScreen
.
- No arquivo
GameScreen.kt
, no elemento combinávelGameLayout()
, definaonValueChange
comoonUserGuessChanged
eonKeyboardDone()
como ação do tecladoonDone
. Você vai corrigir os erros na próxima etapa.
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() }
),
- Na função combinável
GameLayout()
, adicione mais dois argumentos: o lambdaonUserGuessChanged
recebe um argumentoString
e não retorna nada, eonKeyboardDone
não recebe nem retorna nada.
@Composable
fun GameLayout(
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
currentScrambledWord: String,
modifier: Modifier = Modifier,
) {
}
- Na chamada de função
GameLayout()
, adicione argumentos lambda paraonUserGuessChanged
eonKeyboardDone
.
GameLayout(
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
currentScrambledWord = gameUiState.currentScrambledWord,
)
Você vai definir o método updateUserGuess
no GameViewModel
em breve.
- No arquivo
GameViewModel.kt
, adicione um método com o nomeupdateUserGuess()
que usa um argumentoString
, a palavra adivinhada pelo usuário. Dentro da função, atualize auserGuess
usando aguessedWord
transmitida.
fun updateUserGuess(guessedWord: String){
userGuess = guessedWord
}
Em seguida, adicione userGuess
ao ViewModel.
- No arquivo
GameViewModel.kt
, adicione uma propriedade var chamadauserGuess
. UsemutableStateOf()
para que o Compose observe esse valor e defina o valor inicial como""
.
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
var userGuess by mutableStateOf("")
private set
- No arquivo
GameScreen.kt
, emGameLayout()
, adicione outro parâmetroString
parauserGuess
. Defina o parâmetrovalue
doOutlinedTextField
comouserGuess
.
fun GameLayout(
currentScrambledWord: String,
userGuess: String,
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
//...
OutlinedTextField(
value = userGuess,
//..
)
}
}
- Na função
GameScreen
, atualize a chamada de funçãoGameLayout()
para incluir o parâmetrouserGuess
.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
//...
)
- Compile e execute o app.
- Tente adivinhar e insira uma palavra. O campo de texto pode mostrar o palpite do usuário.
7. Verificar a palavra adivinhada e atualizar a pontuação
Nesta tarefa, você vai implementar um método para verificar a palavra que o usuário imagina e, em seguida, atualizar a pontuação do jogo ou exibir um erro. Você atualizará a interface do estado de jogo com a nova pontuação e a nova palavra mais tarde.
- No arquivo
GameViewModel
, adicione outro método com o nomecheckUserGuess()
. - Na função
checkUserGuess()
, adicione um blocoif else
para verificar se o palpite do usuário é igual àcurrentWord
. RedefiniruserGuess
para uma string vazia.
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
} else {
}
// Reset user guess
updateUserGuess("")
}
- Se o palpite do usuário estiver errado, defina
isGuessedWordWrong
comotrue
.MutableStateFlow<T>.
update()
atualiza oMutableStateFlow.value
usando o valor especificado (links em inglês).
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)
}
}
- Na classe
GameUiState
, adicione umBoolean
chamadoisGuessedWordWrong
e inicialize-o comofalse
.
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
)
Em seguida, você transmite o callback do evento checkUserGuess()
de GameScreen
para ViewModel
quando o usuário clica no botão Enviar ou na tecla concluído do teclado. Transmita os dados, gameUiState.isGuessedWordWrong
do ViewModel
para o GameScreen
, para definir o erro no campo de texto.
- No arquivo
GameScreen.kt
, no final da função combinávelGameScreen()
, chamegameViewModel.checkUserGuess()
dentro da expressão lambdaonClick
do botão Submit.
Button(
modifier = modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 8.dp),
onClick = { gameViewModel.checkUserGuess() }
) {
Text(stringResource(R.string.submit))
}
- Na função combinpável
GameScreen()
, atualize a chamada de funçãoGameLayout()
para transmitirgameViewModel.checkUserGuess()
na expressão lambdaonKeyboardDone
.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() }
)
- Na função de composição
GameLayout()
, adicione um parâmetro de função paraBoolean
,isGuessWrong
. Defina o parâmetroisError
doOutlinedTextField
comoisGuessWrong
para mostrar o erro no campo de texto quando o palpite do usuário estiver errado.
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() }
),
)
}
}
- Na função combinável
GameScreen()
, atualize a chamada de funçãoGameLayout()
para transmitirisGuessWrong
.
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() },
isGuessWrong = gameUiState.isGuessedWordWrong,
// ...
)
- Compile e execute o app.
- Insira um palpite errado e clique em Submit. Observe que o campo de texto fica vermelho, indicando o erro.
O texto do campo ainda diz "Enter your word" (Digite uma palavra). Para facilitar o uso, adicione uma mensagem de erro informando que a palavra está errada.
- No arquivo
GameScreen.kt
do elemento combinávelGameLayout()
, atualize o parâmetro do rótulo do campo de texto de acordo com oisGuessWrong
da seguinte maneira:
OutlinedTextField(
// ...
label = {
if (isGuessWrong) {
Text(stringResource(R.string.wrong_guess))
} else {
Text(stringResource(R.string.enter_your_word))
}
},
// ...
)
- No arquivo
strings.xml
, adicione uma string ao rótulo de erro.
<string name="wrong_guess">Wrong Guess!</string>
- Compile e execute seu aplicativo novamente.
- Insira um palpite errado e clique em Submit. Observe o rótulo de erro.
8. Atualizar pontuação e contagem de palavras
Nesta tarefa, você vai atualizar a pontuação e a contagem de palavras enquanto o usuário joga. A pontuação precisa fazer parte de _ uiState
.
- No
GameUiState
, adicione uma variávelscore
e a inicialize em zero.
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
val score: Int = 0
)
- Para atualizar o valor da pontuação, aumente o valor de
score
na funçãocheckUserGuess()
, emGameViewModel
, dentro da condiçãoif
para quando o palpite do usuário estiver correto.
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 {
//...
}
}
- No arquivo
GameViewModel
, adicione outro método com o nomeupdateGameState
para atualizar a pontuação, incrementar a contagem de palavras atual e escolher uma nova palavra no arquivoWordsData.kt
. Adicione umaInt
chamadaupdatedScore
como parâmetro. Atualize as variáveis da interface do estado do jogo desta forma:
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
currentScrambledWord = pickRandomWordAndShuffle(),
score = updatedScore
)
}
}
- Na função
checkUserGuess()
, se o palpite do usuário estiver correto, faça uma chamada paraupdateGameState
com a pontuação atualizada para preparar o jogo para a próxima rodada.
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 {
//...
}
}
A checkUserGuess()
concluída vai ficar assim:
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("")
}
Em seguida, assim como as atualizações da pontuação, vai ser necessário atualizar a contagem de palavras.
- Adicione outra variável para a contagem em
GameUiState
. Chame-a decurrentWordCount
e inicialize em1
.
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
)
- No arquivo
GameViewModel.kt
, na funçãoupdateGameState()
, aumente a contagem de palavras, como mostrado abaixo. A funçãoupdateGameState()
é chamada para preparar o jogo para a próxima rodada.
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
//...
currentWordCount = currentState.currentWordCount.inc(),
)
}
}
Pontuação de aprovação e contagem de palavras
Conclua as etapas a seguir para transmitir dados de pontuação e contagem de palavras de ViewModel
para GameScreen
.
- No arquivo
GameScreen.kt
da função combinávelGameLayout()
, adicione a contagem de palavras como argumento e transmita os argumentos do formatowordCount
para o 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
)
// ...
}
- Atualize a chamada de função
GameLayout()
para incluir a contagem de palavras.
GameLayout(
userGuess = gameViewModel.userGuess,
wordCount = gameUiState.currentWordCount,
//...
)
- Na função combinável
GameScreen()
, atualize a chamada de funçãoGameStatus()
para incluir os parâmetrosscore
. Transmita a pontuação dogameUiState
.
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
- Crie e execute o app.
- Insira o palpite para a palavra e clique em Submit. A pontuação e a contagem de palavras são atualizadas.
- Clique em Skip e observe que nada acontece.
Para implementar a funcionalidade de pular, transmita o callback do evento de pulo para o GameViewModel
.
- No arquivo
GameScreen.kt
, na função de composiçãoGameScreen()
, faça uma chamada paragameViewModel.skipWord()
na expressão lambdaonClick
.
O Android Studio mostra um erro porque você ainda não implementou a função. Para corrigir esse erro, adicione o método skipWord()
na próxima etapa. Quando o usuário pula uma palavra, é necessário atualizar as variáveis e preparar o jogo para a próxima rodada.
OutlinedButton(
onClick = { gameViewModel.skipWord() },
modifier = Modifier.fillMaxWidth()
) {
//...
}
- Em
GameViewModel
, adicione o métodoskipWord()
. - Na função
skipWord()
, faça uma chamada paraupdateGameState()
, transmitindo a pontuação e redefina o palpite do usuário.
fun skipWord() {
updateGameState(_uiState.value.score)
// Reset user guess
updateUserGuess("")
}
- Execute o app e jogue uma partida. Agora você poderá pular palavras.
Você ainda pode jogar com mais de 10 palavras. Na próxima tarefa, você vai lidar com a última rodada do jogo.
9. Processar a última rodada do jogo
Na implementação atual, os usuários podem pular ou reproduzir mais de 10 palavras. Nesta tarefa, você vai adicionar lógica para encerrar o jogo.
Para implementar a lógica de fim de jogo, primeiro é preciso verificar se o usuário atingiu o número máximo de palavras.
- No
GameViewModel
, adicione um blocoif-else
e mova o corpo da função existente para o blocoelse
. - Adicione uma condição
if
para verificar se o tamanho dasusedWords
é 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
)
}
}
}
- No bloco
if
, adicione a sinalizaçãoBoolean
isGameOver
e a defina comotrue
para indicar o fim do jogo. - Atualize a
score
e redefinaisGuessedWordWrong
dentro do blocoif
. O código abaixo mostra como a função vai ficar.
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
)
}
}
}
- No
GameUiState
, adicione a variávelBoolean
isGameOver
e a defina comofalse
.
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
val isGameOver: Boolean = false
)
- Execute o app e jogue uma partida. Não será possível jogar mais de 10 palavras.
Quando o jogo terminar, informe ao usuário e pergunte se ele quer jogar de novo. Você vai implementar esse recurso na próxima tarefa.
Mostrar uma caixa de diálogo de encerramento do jogo
Nesta tarefa, você vai transmitir dados isGameOver
do ViewModel para a GameScreen
e os usará para mostrar uma caixa de diálogo de alerta com opções para encerrar ou reiniciar o jogo.
As caixas de diálogo são pequenas janelas que levam o usuário a tomar uma decisão ou inserir mais informações. Normalmente, uma caixa de diálogo não preenche toda a tela e exige que os usuários realizem uma ação antes de continuar. O Android oferece diferentes tipos de caixas de diálogo. Neste codelab, você vai aprender sobre caixas de diálogo de alerta.
Anatomia de uma caixa de diálogo de alerta
- Contêiner
- Ícone (opcional)
- Título (opcional)
- Texto de suporte
- Divisor (opcional)
- Ações
O arquivo GameScreen.kt
no código inicial já fornece uma função que mostra uma caixa de diálogo de alerta com opções para sair ou reiniciar o jogo.
@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))
}
}
)
}
Nessa função, os parâmetros title
e text
mostram o título e o texto de suporte na caixa de diálogo de alerta. dismissButton
e confirmButton
são os botões de texto. No parâmetro dismissButton
, você exibe o texto Sair e conclui a atividade para encerrar o app. No parâmetro confirmButton
, você reinicia o jogo e mostra o texto Play Again (Jogar novamente).
- No arquivo
GameScreen.kt
da funçãoFinalScoreDialog()
, observe o parâmetro para mostrar a pontuação do jogo na caixa de diálogo de alerta.
@Composable
private fun FinalScoreDialog(
score: Int,
onPlayAgain: () -> Unit,
modifier: Modifier = Modifier
) {
- Na função
FinalScoreDialog()
, observe o uso da expressão lambda do parâmetrotext
para usarscore
como argumento de formato do texto da caixa de diálogo.
text = { Text(stringResource(R.string.you_scored, score)) }
- No arquivo
GameScreen.kt
, no final da função combinávelGameScreen()
, depois do blocoColumn
, adicione uma condiçãoif
para conferirgameUiState.isGameOver
. - No bloco
if
, exiba a caixa de diálogo de alerta. Faça uma chamada paraFinalScoreDialog()
transmitindoscore
egameViewModel.resetGame()
para o callback de eventoonPlayAgain
.
if (gameUiState.isGameOver) {
FinalScoreDialog(
score = gameUiState.score,
onPlayAgain = { gameViewModel.resetGame() }
)
}
O resetGame()
é um callback de evento transmitido do GameScreen
para o ViewModel
.
- No arquivo
GameViewModel.kt
, chame a funçãoresetGame()
, inicialize_uiState
e escolha uma nova palavra.
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- Compile e execute o app.
- Jogue até o final e observe a caixa de diálogo de alerta com as opções Sair ou Jogar novamente. Teste as opções mostradas na caixa de diálogo de alerta.
10. Estado na rotação do dispositivo
Nos codelabs anteriores, você aprendeu sobre as mudanças de configuração no Android. Quando ocorre uma mudança de configuração, o Android reinicia a atividade do zero e executa todos os callbacks de inicialização do ciclo de vida.
O ViewModel
armazena os dados relacionados ao app que não são destruídos quando o framework do Android destrói e recria a atividade. Os objetos ViewModel
são retidos automaticamente e não são destruídos, como a instância de atividade, durante a mudança da configuração. Os dados retidos ficam imediatamente disponíveis após a recomposição.
Nesta tarefa, você confere se o app retém a interface do estado durante uma mudança de configuração.
- Execute o app e jogue algumas palavras. Mude a configuração do dispositivo de retrato para paisagem ou vice-versa.
- Os dados salvos na interface de estado do
ViewModel
são mantidos durante a mudança da configuração.
11. Acessar o código da solução
Para baixar o código do codelab concluído, use estes comandos 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
Se preferir, você pode baixar o repositório como um arquivo ZIP, descompactar e abrir no Android Studio.
Você pode conferir o código da solução deste codelab no GitHub (link em inglês).
12. Conclusão
Parabéns! Você concluiu o codelab. Agora você entende como as diretrizes de arquitetura de apps Android recomendam separar classes com responsabilidades diferentes e usar um modelo para a interface.
Não se esqueça de compartilhar seu trabalho nas redes sociais com a hashtag #AndroidBasics.