1. 始める前に
以前の Codelab では、アクティビティのライフサイクルと、関連するライフサイクルの構成変更にともなう問題について学習しました。構成変更が発生したときには、rememberSaveable
を使用する、またはインスタンスの状態を保存するなどの方法で、アプリのデータを保存できます。ただし、これらの方法で問題が発生する場合があります。ほとんどの場合には rememberSaveable
を使用できますが、その場合は、コンポーザブルの中や周辺のロジックを維持することになります。アプリが大きくなったときには、データとロジックをコンポーザブルから分離する必要があります。この Codelab では、Android Jetpack ライブラリ、ViewModel
、Android アプリのアーキテクチャ ガイドラインを利用して、アプリを設計し、構成変更後もアプリデータを維持する堅牢な方法について学びます。
Android Jetpack ライブラリは、優れた Android アプリの開発を支援するライブラリ集です。このライブラリ集を使用すると、ベスト プラクティスに沿って開発を進めながら、ボイラープレート コードを作成する手間を省き、複雑なタスクを簡素化できるので、アプリのロジックなどのコードの重要な部分に集中できます。
「アプリ アーキテクチャ」は、アプリの設計ルールの集まりです。アーキテクチャは、住宅の設計図とほぼ同じで、アプリに構造を与えます。優れたアプリ アーキテクチャを採用すれば、コードの堅牢性、柔軟性、スケーラビリティ、テスト性、保守性を長年にわたって維持できます。アプリ アーキテクチャ ガイドで、アプリ アーキテクチャに関する推奨事項と推奨されるベスト プラクティスを紹介しています。
この Codelab では、Android Jetpack ライブラリのアーキテクチャ コンポーネントの一つである ViewModel
を利用して、アプリデータを保存する方法について学びます。フレームワークが構成変更やその他のイベント中にアクティビティを破棄して再作成しても、保存されているデータは失われません。ただし、プロセスの終了が原因でアクティビティが破棄された場合、データは失われます。ViewModel
は迅速にアクティビティを再作成することによってデータをキャッシュに保存しているだけなのです。
前提条件
- 関数、ラムダ、ステートレス コンポーザブルなど、Kotlin に関する知識
- Jetpack Compose でレイアウトを作成する方法に関する基本的な知識
- マテリアル デザインに関する基本的な知識
学習内容
- Android アプリ アーキテクチャの概要
- アプリで
ViewModel
クラスを使用する方法 ViewModel
を使用してデバイスの構成変更後も UI データを維持する方法
作成するアプリの概要
- スクランブルされた単語を推測する Unscramble ゲームアプリ
必要なもの
- Android Studio の最新バージョン
- スターター コードをダウンロードするためのインターネット接続
2. アプリの概要
ゲームの概要
Unscramble アプリは、1 人でプレイするスクランブラー ゲームです。アプリにはスクランブルされた単語が表示され、プレーヤーは表示されている文字をすべて使用して単語を推測します。単語が正しい場合はスコアが加算されます。何回でも挑戦できます。現在の単語をスキップすることもできます。右上に単語カウントが表示されます。現在のゲームでプレイした単語の数です。スクランブルされた単語の数は、1 ゲームにつき 10 個です。
スターター コードを取得する
まず、スターター コードをダウンロードします。
または、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
スターター コードは Unscramble
GitHub リポジトリで確認できます。
3. スターター アプリの概要
次の手順でスターター コードを確認し、よく理解してください。
- Android Studio でスターター コードのプロジェクトを開きます。
- Android デバイスまたはエミュレータでアプリを実行します。
- [Submit] ボタンと [Skip] ボタンをタップしてアプリを試します。
アプリにバグがあることがわかります。スクランブルされた単語は表示されませんが、「scrambleun」とハードコードされ、ボタンをタップしても何も起こりません。
この Codelab では、Android アプリ アーキテクチャを使用してゲーム機能を実装します。
スターター コードのチュートリアル
スターター コードには、あらかじめデザインされたゲーム画面のレイアウトが用意されています。このパスウェイでは、ゲームロジックを実装します。アーキテクチャ コンポーネントを使用して、推奨されるアプリ アーキテクチャを実装し、上記の問題を解決します。作業の土台とするファイルの一部について簡単に説明します。
WordsData.kt
このファイルには、ゲームで使用されている単語のリストに加え、ゲームごとの最大単語数と正解するごとに加算される点数の定数が入っています。
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
このファイルには、主にテンプレートから生成されたコードが含まれています。setContent{}
ブロック内の GameScreen
コンポーザブルが表示されます。
GameScreen.kt
すべての UI コンポーザブルが GameScreen.kt
ファイルで定義されています。以下のセクションで、いくつかのコンポーズ可能な関数について順を追って説明します。
GameStatus
GameStatus
は、ゲームスコアを画面下部に表示するコンポーズ可能な関数です。このコンポーズ可能な関数では、Card
にテキスト コンポーザブルが含まれています。現時点では、スコアは 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
は、メインのゲーム機能(スクランブルされた単語、ゲームの手順、ユーザーの推測が入力されるテキスト フィールド)を表示するコンポーズ可能な関数です。
以下の GameLayout
のコードには、スクランブルされた単語のテキスト、手順のテキスト、ユーザーが単語を入力するテキスト フィールド OutlinedTextField
の 3 つの子要素が Card
内で 1 つの列になって含まれています。現時点では、スクランブルされた単語は「scrambleun
」とハードコードされています。この Codelab の後半では、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 = { }
)
)
}
}
}
OutlinedTextField
コンポーザブルは、以前の Codelab で作成したアプリの TextField
コンポーザブルに似ています。
テキスト フィールドには次の 2 種類があります。
- 塗りつぶしテキスト フィールド
- 枠線付きテキスト フィールド
枠線付きテキスト フィールドは、塗りつぶしテキスト フィールドに比べて視覚的な強調が控えめになっています。多数のテキスト項目が配置されるフォームなどに表示される場合は、強調が控えめになってレイアウトがシンプルになります。
スターター コードでは、ユーザーが推測を入力しても OutlinedTextField
が更新されません。この機能は、この Codelab で更新します。
GameScreen
GameScreen
コンポーザブルには、GameStatus
と GameLayout
のコンポーズ可能な関数、ゲームタイトル、単語カウント、[Submit] ボタンと [Skip] ボタンのコンポーザブルが含まれています。
@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))
}
}
スターター コードでは、ボタンのクリック イベントが実装されていません。これらのイベントは、この Codelab で実装します。
FinalScoreDialog
FinalScoreDialog
コンポーザブルは、ダイアログ(ユーザーにプロンプトを表示する小さなウィンドウ)を表示します。ここでは、[Play Again] か [Exit] を選択できます。この Codelab の後半で、このダイアログをゲームの最後に表示するロジックを実装します。
// 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. アプリ アーキテクチャの詳細
アプリ アーキテクチャでは、各クラスにアプリの責任を割り振る際のガイドラインを定めています。優れた設計のアプリ アーキテクチャでは、アプリの大規模化や機能追加による拡張が可能になります。アーキテクチャは、チームのコラボレーションを簡素化することもできます。
最も一般的なアーキテクチャ原則は、関心の分離と、モデル駆動型 UI です。
関心の分離
関心の分離という設計原則では、アプリを機能群に分割し、それぞれの機能群に別々の役割を持たせる必要があるとされています。
モデル駆動型 UI
モデル駆動型 UI の原則では、モデルから(できれば永続モデルから)UI を駆動するべきだとされています。モデルとは、アプリのデータ処理を担うコンポーネントです。モデルはアプリの UI 要素やアプリ コンポーネントから独立しているため、アプリのライフサイクルや関連する問題の影響を受けません。
アプリの推奨アーキテクチャ
前のセクションで説明したアーキテクチャに関する一般的な原則を考慮すると、各アプリに少なくとも 2 つのレイヤが必要です。
- UI レイヤ: アプリデータを画面に表示するレイヤですが、データからは独立しています。
- データレイヤ: アプリデータを格納、取得、公開するレイヤです。
ドメインレイヤという別のレイヤを追加することで、UI レイヤとデータレイヤの間のやり取りを簡素化でき、再利用できます。このレイヤは省略でき、このコースでは扱いません。
UI レイヤ
UI レイヤ(またはプレゼンテーション レイヤ)の役割は、アプリデータを画面に表示することです。ユーザー操作(ボタンの押下など)によるデータの変更があると、UI を更新して変更を反映する必要があります。
UI レイヤは次のコンポーネントで構成されています。
- UI 要素: データを画面にレンダリングするコンポーネント。Jetpack Compose を使用して作成します。
- 状態ホルダー: データの保持、UI への公開、アプリロジックの処理を行うコンポーネント。状態ホルダーの例としては、ViewModel があります。
ViewModel
ViewModel
コンポーネントは、UI が消費する状態を保持し、公開します。アプリデータを ViewModel
が変換したものが UI 状態となります。ViewModel
を使用することで、アプリがモデル駆動形 UI のアーキテクチャ原則に従うようにできます。
ViewModel
には、Android フレームワークによってアクティビティが破棄されて再作成されたときにも破棄されない、アプリ関連のデータが保存されます。ViewModel
オブジェクトは、アクティビティ インスタンスとは異なり、破棄されません。このアプリは、構成変更時に ViewModel
オブジェクトを自動的に保持するため、保持しているデータを再コンポーズの直後にすぐに利用できます。
アプリに ViewModel
を実装するには、アーキテクチャ コンポーネント ライブラリの ViewModel
クラスを拡張して、そのクラス内にアプリデータを保存します。
UI 状態
ユーザーが目にするものが UI であり、ユーザーが目にするべきであるとアプリがみなすものが UI 状態です。UI は、UI 状態を視覚的に表したものです。UI 状態が変更されると、すぐに UI に反映されます。
UI は、画面上の UI 要素と UI 状態を足し合わせたものです。
// Example of UI state definition, do not copy over
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
不変性
上記の例では、UI 状態の定義は不変です。不変オブジェクトを使用することで、複数のソースが同時にアプリの状態を変更しないことを保証できます。この保護機能により UI は解放されて、状態の読み取りと、それに応じて UI 要素を更新するという唯一の役割に集中できます。したがって、UI 自体がそのデータの唯一のソースでない限り、UI 内で UI 状態を直接変更すべきではありません。この原則を破ると、同じ情報について信頼できるソースが複数発生し、データの不整合やわかりにくいバグにつながります。
5. ViewModel を追加する
このタスクでは、ゲームの UI 状態(スクランブルされた単語、単語カウント、スコア)を保存する ViewModel
をアプリに追加します。前のセクションで見たスターター コードの問題を解決するために、ゲームデータを ViewModel
に保存する必要があります。
build.gradle.kts (Module :app)
を開き、dependencies
ブロックまでスクロールして、ViewModel
に次の依存関係を追加します。この依存関係は、ライフサイクル対応ビューモデルを Compose アプリに追加するために使用されます。
dependencies {
// other dependencies
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
ui
パッケージで、GameViewModel
という Kotlin のクラス / ファイルを作成します。これはViewModel
クラスから拡張します。
import androidx.lifecycle.ViewModel
class GameViewModel : ViewModel() {
}
ui
パッケージで、GameUiState
という状態 UI のモデルクラスを追加します。これをデータクラスにして、現在のスクランブルされた単語を表す変数を追加します。
data class GameUiState(
val currentScrambledWord: String = ""
)
StateFlow
StateFlow
は、現在の状態や新しい状態更新の情報を出力するデータ保持用の監視可能な Flow です。その value
プロパティは、現在の状態値を反映します。状態を更新してこの Flow に送信するには、MutableStateFlow
クラスの value プロパティに新しい値を割り当てます。
Android では、StateFlow
は、オブザーバブルな不変状態を維持する必要があるクラスで適切に機能します。
StateFlow
を GameUiState
から公開し、コンポーザブルが UI 状態の更新をリッスンできるようにすることで、構成変更があっても画面状態が正常に維持されるようにできます。
GameViewModel
クラスで、次の _uiState
プロパティを追加します。
import kotlinx.coroutines.flow.MutableStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
バッキング プロパティ
バッキング プロパティを使用すると、そのオブジェクト自体ではなくゲッターから返すことができます。
Kotlin のフレームワークでは、var
プロパティのゲッターとセッターが生成されます。
ゲッター メソッドとセッター メソッドに関しては、両方または片方のメソッドをオーバーライドして、カスタムの動作を提供できます。バッキング プロパティを実装するには、ゲッター メソッドをオーバーライドして、データの読み取り専用バージョンを返すようにします。次の例でバッキング プロパティを示します。
//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
別の例として、アプリデータを ViewModel
に対して非公開にする場合を考えてみましょう。
ViewModel
クラスの内部:
- プロパティ
_count
は、private
かつ可変です。したがって、ViewModel
クラス内でのみアクセスと変更ができます。
ViewModel
クラスの外部:
- Kotlin のデフォルトの可視性修飾子は
public
であるため、count
は公開され、UI コントローラなどの他のクラスからアクセス可能です。val
型にセッターを指定することはできません。不変で読み取り専用であるため、オーバーライドできるのはget()
メソッドのみです。外部のクラスがこのプロパティにアクセスすると、_count
の値が返されますが、その値は変更できません。バッキング プロパティにより、ViewModel
内のアプリデータが、外部クラスによる望ましくない変更や安全でない変更から保護される一方で、外部の呼び出し元がその値に安全にアクセスできるようになります。
GameViewModel.kt
ファイルで、バッキング プロパティを_uiState
という名前のuiState
に追加します。プロパティにuiState
という名前を付け、StateFlow<GameUiState>
型にします。
現在、_uiState
は GameViewModel
内でのみアクセスと変更が可能です。UI は、読み取り専用のプロパティ uiState
を使用して、その値を読み取ることができます。初期化エラーは次のステップで修正できます。
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>
uiState
を_uiState.asStateFlow()
に設定します。
asStateFlow()
により、この可変の状態フローが読み取り専用の状態フローになります。
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()
スクランブルされた単語をランダムに表示する
このタスクでは、WordsData.kt
からランダムな単語を選択して、その単語をスクランブルする、ヘルパー メソッドを追加します。
GameViewModel
で、currentWord
というString
型のプロパティを追加して、現在のスクランブルされた単語を保存します。
private lateinit var currentWord: String
- リストからランダムな単語を選択してシャッフルするヘルパー メソッドを追加します。
pickRandomWordAndShuffle()
という名前を付け、入力パラメータはとらず、String
を返すようにします。
import com.example.unscramble.data.allWords
private fun pickRandomWordAndShuffle(): String {
// Continue picking up a new random word until you get one that hasn't been used before
currentWord = allWords.random()
if (usedWords.contains(currentWord)) {
return pickRandomWordAndShuffle()
} else {
usedWords.add(currentWord)
return shuffleCurrentWord(currentWord)
}
}
Android Studio で未定義の変数と関数のエラーが報告されます。
GameViewModel
で、currentWord
プロパティの後に、以下のプロパティを追加します。このプロパティは、そのゲームで使用済みの単語を格納するための可変の Set となります。
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
- 現在の単語をシャッフルする
shuffleCurrentWord()
という名前の別のヘルパー メソッドを追加します。String
を受け取ってシャッフルされたString
を返すメソッドです。
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)
}
- ゲームを初期化する
resetGame()
という名前のヘルパー関数を追加します。この関数は後でゲームの開始と再開に使用します。この関数では、usedWords
Set 内のすべての単語をクリアし、_uiState
を初期化します。pickRandomWordAndShuffle()
を使用して、currentScrambledWord
に設定する新しい単語を選びます。
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
GameViewModel
にinit
ブロックを追加して、そこからresetGame()
を呼び出します。
init {
resetGame()
}
ここでアプリをビルドしても、UI に変化はありません。データを ViewModel
から GameScreen
のコンポーザブルに渡していません。
6. Compose UI を設計する
Compose では、UI を更新する唯一の方法は、アプリの状態を変更することです。制御できるのは UI の状態です。UI の状態が変化するたびに、Compose は UI ツリーの変化した部分を再作成します。コンポーザブルは、状態を受け取ってイベントを公開できます。たとえば、TextField
/ OutlinedTextField
は値を受け取って、コールバック ハンドラに値の変更をリクエストするコールバック onValueChange
を公開します。
//Example code no need to copy over
var name by remember { mutableStateOf("") }
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
コンポーザブルは状態を受け取ってイベントを公開するので、単方向データフロー パターンは Jetpack Compose に適しています。このセクションでは、Compose で単方向データフロー パターンを実装する方法、イベントと状態ホルダーを実装する方法、Compose で ViewModel
を操作する方法に焦点を当てます。
単方向データフロー
単方向データフロー(UDF)は、状態が下方に流れ、イベントが上方に流れる設計パターンです。単方向データフローに従うことで、UI に状態を表示するコンポーザブルを、アプリ内で状態を保存および変更する部分から分離できます。
単方向データフローを使用するアプリの UI 更新ループは、次のようになります。
- イベント: UI の一部がイベントを生成して上方に渡します(たとえば、ボタンクリックが ViewModel に渡されて処理される場合)。または、アプリの他のレイヤからイベントが渡されます(たとえば、ユーザー セッションの有効期限が切れていることを示す場合)。
- 状態の更新: イベント ハンドラが状態を変更します。
- 状態の表示: 状態ホルダーが状態を下方に渡し、UI が状態を表示します。
アプリ アーキテクチャに UDF パターンを採用すると、次の影響があります。
ViewModel
は、UI が消費する状態を保持し、公開します。- UI 状態は、
ViewModel
によって変換されたアプリデータです。 - UI は、ユーザー イベントを
ViewModel
に通知します。 ViewModel
は、ユーザー アクションを処理し、状態を更新します。- 更新された状態は、UI にフィードバックされてレンダリングされます。
- 以上のプロセスは、状態の変化を引き起こす任意のイベントで繰り返されます。
データを渡す
ViewModel インスタンスを UI に渡します。つまり、GameScreen.kt
ファイルで GameViewModel
から GameScreen()
に渡します。GameScreen()
で、ViewModel のインスタンスを使用して collectAsState()
で uiState
にアクセスします。
collectAsState()
関数は、この StateFlow
から値を収集し、State
を介してその最新の値を提示します。StateFlow.value
が初期値に使用されます。StateFlow
に新しい値が送信されるたびに、返された State
が更新されるので、State.value
を使用するごとに再コンポーズが発生します。
GameScreen
関数で、GameViewModel
型でデフォルト値がviewModel()
の 2 番目の引数を渡します。
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun GameScreen(
gameViewModel: GameViewModel = viewModel()
) {
// ...
}
GameScreen()
関数に、gameUiState
という名前の新しい変数を追加します。by
デリゲートを使用して、uiState
のcollectAsState()
を呼び出します。
この方法で、uiState
値が変更されるたびに、gameUiState
値を使用したコンポーザブルの再コンポーズが行われるようになります。
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@Composable
fun GameScreen(
// ...
) {
val gameUiState by gameViewModel.uiState.collectAsState()
// ...
}
gameUiState.currentScrambledWord
をGameLayout()
コンポーザブルに渡します。引数は後のステップで追加するので、ここではエラーを無視します。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(mediumPadding)
)
- コンポーズ可能な関数
GameLayout()
にもう 1 つのパラメータとしてcurrentScrambledWord
を追加します。
@Composable
fun GameLayout(
currentScrambledWord: String,
modifier: Modifier = Modifier
) {
}
currentScrambledWord
を表示するようにコンポーズ可能な関数GameLayout()
を更新します。この列の最初のテキスト フィールドのtext
パラメータをcurrentScrambledWord
に設定します。
@Composable
fun GameLayout(
// ...
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = currentScrambledWord,
fontSize = 45.sp,
modifier = modifier.align(Alignment.CenterHorizontally)
)
//...
}
}
- アプリをビルドして実行すると、スクランブルされた単語が表示されます。
推測した単語を表示する
GameLayout()
コンポーザブルでは、ユーザーが推測した単語の更新が、GameScreen
から ViewModel
へと上方に流れるイベント コールバックの 1 つになります。データ gameViewModel.userGuess
は ViewModel
から GameScreen
へと下方に流れます。
GameScreen.kt
ファイルのGameLayout()
コンポーザブルで、onValueChange
をonUserGuessChanged
に設定し、onKeyboardDone()
をonDone
キーボード アクションに設定します。エラーは次のステップで修正します。
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() }
),
- コンポーズ可能な関数
GameLayout()
に、引数を 2 つ追加します。onUserGuessChanged
ラムダは、String
引数をとって何も返さず、onKeyboardDone
は、何も受け取らず何も返しません。
@Composable
fun GameLayout(
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
currentScrambledWord: String,
modifier: Modifier = Modifier,
) {
}
GameLayout()
関数呼び出しで、onUserGuessChanged
とonKeyboardDone
にラムダ引数を追加します。
GameLayout(
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
currentScrambledWord = gameUiState.currentScrambledWord,
)
GameViewModel
の updateUserGuess
メソッドの定義は、この後すぐ行います。
GameViewModel.kt
ファイルで、ユーザーが推測した単語String
引数を取るupdateUserGuess()
というメソッドを追加します。この関数内で、渡されたguessedWord
でuserGuess
を更新します。
fun updateUserGuess(guessedWord: String){
userGuess = guessedWord
}
次に、ViewModel で userGuess
を追加します。
GameViewModel.kt
ファイルで、userGuess
という var プロパティを追加します。mutableStateOf()
を使用して、Compose がこの値を監視し、初期値を""
に設定するようにします。
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
var userGuess by mutableStateOf("")
private set
GameScreen.kt
ファイルで、GameLayout()
内にuserGuess
の別のString
パラメータを追加します。OutlinedTextField
のvalue
パラメータをuserGuess
に設定します。
fun GameLayout(
currentScrambledWord: String,
userGuess: String,
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
//...
OutlinedTextField(
value = userGuess,
//..
)
}
}
GameScreen
関数で、GameLayout()
関数の呼び出しにuserGuess
パラメータを追加します。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { },
//...
)
- アプリをビルドして実行します。
- 推測した単語を入力すると、テキスト フィールドにユーザーの推測が表示されます。
7. 推測した単語を照合して、スコアを更新する
このタスクでは、ユーザーが推測した単語を照合してから、ゲームスコアの更新とエラーの表示のどちらかを行うメソッドを実装します。ゲーム ステータスの UI は、後で新しいスコアと新しい単語で更新します。
GameViewModel
で、checkUserGuess()
という別のメソッドを追加します。checkUserGuess()
関数で、ユーザーの推測がcurrentWord
と同じかどうかを確かめるif else
ブロックを追加します。userGuess
を空の文字列にリセットします。
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
} else {
}
// Reset user guess
updateUserGuess("")
}
- ユーザーの推測が間違っている場合は、
isGuessedWordWrong
をtrue
に設定します。MutableStateFlow<T>.
update()
が指定された値を使ってMutableStateFlow.value
を更新します。
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)
}
}
GameUiState
クラスで、isGuessedWordWrong
というBoolean
を追加し、false
に初期化します。
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
)
次に、ユーザーが [Submit] ボタンかキーボードの [done] キーをクリックしたときに、イベント コールバック checkUserGuess()
を GameScreen
から ViewModel
へと上方に渡します。テキスト フィールドにエラーを設定するために、データ gameUiState.isGuessedWordWrong
を ViewModel
から GameScreen
へと下方に渡します。
GameScreen.kt
ファイルのコンポーズ可能な関数GameScreen()
の最後にある、[Submit] ボタンのonClick
ラムダ式内でgameViewModel.checkUserGuess()
を呼び出します。
Button(
modifier = modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 8.dp),
onClick = { gameViewModel.checkUserGuess() }
) {
Text(stringResource(R.string.submit))
}
- コンポーズ可能な関数
GameScreen()
で、GameLayout()
関数呼び出しを更新して、onKeyboardDone
ラムダ式でgameViewModel.checkUserGuess()
を渡します。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() }
)
- コンポーズ可能な関数
GameLayout()
で、Boolean
の関数パラメータisGuessWrong
を追加します。ユーザーの推測が間違っている場合にテキスト フィールドにエラーを表示するために、OutlinedTextField
のisError
パラメータをisGuessWrong
に設定します。
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() }
),
)
}
}
- コンポーズ可能な関数
GameScreen()
で、GameLayout()
関数呼び出しを更新してisGuessWrong
を渡します。
GameLayout(
currentScrambledWord = gameUiState.currentScrambledWord,
userGuess = gameViewModel.userGuess,
onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
onKeyboardDone = { gameViewModel.checkUserGuess() },
isGuessWrong = gameUiState.isGuessedWordWrong,
// ...
)
- アプリをビルドして実行します。
- 間違った推測を入力して [Submit] をクリックします。テキスト フィールドが赤色に変わり、エラーを示していることを確認します。
テキスト フィールドのラベルには「Enter your word」と表示されています。ユーザー フレンドリーにするために、単語が間違っていることを示すエラーテキストを追加する必要があります。
GameScreen.kt
ファイルのGameLayout()
コンポーザブルで、isGuessWrong
に応じてテキスト フィールドのラベル パラメータを次のように更新します。
OutlinedTextField(
// ...
label = {
if (isGuessWrong) {
Text(stringResource(R.string.wrong_guess))
} else {
Text(stringResource(R.string.enter_your_word))
}
},
// ...
)
strings.xml
ファイルで、エラーラベルに文字列を追加します。
<string name="wrong_guess">Wrong Guess!</string>
- 再度アプリをビルドして実行します。
- 間違った推測を入力して [Submit] をクリックします。エラーラベルに注目してください。
8. スコアと単語カウントを更新する
このタスクでは、ユーザーがゲームをプレイする際に、スコアと単語カウントを更新します。スコアは _ uiState
に含まれている必要があります。
GameUiState
で、score
変数を追加して、ゼロに初期化します。
data class GameUiState(
val currentScrambledWord: String = "",
val isGuessedWordWrong: Boolean = false,
val score: Int = 0
)
- スコア値を更新するために、
GameViewModel
のcheckUserGuess()
関数にある、ユーザーの推測が正しかったときのif
条件内でscore
値を増やします。
import com.example.unscramble.data.SCORE_INCREASE
fun checkUserGuess() {
if (userGuess.equals(currentWord, ignoreCase = true)) {
// User's guess is correct, increase the score
val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
} else {
//...
}
}
GameViewModel
で、updateGameState
という別のメソッドを追加して、スコアを更新し、現在の単語カウントを増分して、WordsData.kt
ファイルから新しい単語を選べるようにします。パラメータとしてupdatedScore
という名前のInt
を追加します。ゲーム ステータスの UI 変数を次のように更新します。
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
isGuessedWordWrong = false,
currentScrambledWord = pickRandomWordAndShuffle(),
score = updatedScore
)
}
}
checkUserGuess()
関数で、ユーザーの推測が正しい場合に、更新されたスコアでupdateGameState
を呼び出して、次のラウンドのゲームを準備します。
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 {
//...
}
}
完成した checkUserGuess()
は次のようになります。
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("")
}
次に、スコアの更新と同様に、単語カウントを更新する必要があります。
GameUiState
にカウント用の別の変数を追加します。currentWordCount
という名前にして1
に初期化します。
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
)
GameViewModel.kt
ファイルのupdateGameState()
関数で、次のように単語カウントを増やします。updateGameState()
関数は、次のラウンドのゲームを準備するために呼び出されます。
private fun updateGameState(updatedScore: Int) {
_uiState.update { currentState ->
currentState.copy(
//...
currentWordCount = currentState.currentWordCount.inc(),
)
}
}
スコアと単語カウントを渡す
以下のステップで、スコアと単語カウントのデータを ViewModel
から GameScreen
へと下方に渡します。
GameScreen.kt
ファイルのコンポーズ可能な関数GameLayout()
で、単語カウントを引数として追加し、wordCount
形式の引数をテキスト要素に渡します。
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
)
// ...
}
- 単語カウントを含めるように
GameLayout()
関数呼び出しを更新します。
GameLayout(
userGuess = gameViewModel.userGuess,
wordCount = gameUiState.currentWordCount,
//...
)
- コンポーズ可能な関数
GameScreen()
で、GameStatus()
関数呼び出しを更新して、score
パラメータを追加します。gameUiState
のスコアを渡します。
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
- アプリをビルドして実行します。
- 推測した単語を入力して [Submit] をクリックします。スコアと単語カウントが更新されます。
- [Skip] をクリックしても、何も起こりません。
スキップ機能を実装するには、スキップ イベントのコールバックを GameViewModel
に渡す必要があります。
GameScreen.kt
ファイルにあるコンポーズ可能な関数GameScreen()
のonClick
ラムダ式内でgameViewModel.skipWord()
を呼び出します。
この関数はまだ実装されていないため、Android Studio にエラーが表示されます。このエラーは、次のステップで skipWord()
メソッドを追加して修正します。ユーザーが単語をスキップしたときには、ゲーム変数を更新し、次のラウンドのゲームを準備する必要があります。
OutlinedButton(
onClick = { gameViewModel.skipWord() },
modifier = Modifier.fillMaxWidth()
) {
//...
}
GameViewModel
で、メソッドskipWord()
を追加します。skipWord()
関数内で、updateGameState()
を呼び出して、スコアを渡し、ユーザーの推測をリセットします。
fun skipWord() {
updateGameState(_uiState.value.score)
// Reset user guess
updateUserGuess("")
}
- アプリを実行して、ゲームをプレイします。単語をスキップできるようになっているはずです。
単語が 10 個を超えてもゲームをプレイできます。次のタスクでは、ゲームの最終ラウンドの処理を行います。
9. ゲームの最終ラウンドを処理する
現在の実装では、ユーザーは 10 語を超えてスキップまたはプレイできます。このタスクでは、ゲームを終了するためのロジックを追加します。
ゲーム終了のロジックを実装するには、まずユーザーが単語数の上限に達しているかどうかを確認する必要があります。
GameViewModel
にif-else
ブロックを追加し、既存の関数本体をelse
ブロック内に移動します。usedWords
のサイズがMAX_NO_OF_WORDS
と等しいことを確認するif
条件を追加します。
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
)
}
}
}
if
ブロックで、Boolean
フラグのisGameOver
を追加し、このフラグをゲームの終了を示すtrue
に設定します。if
ブロック内で、score
を更新してisGuessedWordWrong
をリセットします。関数のコードは次のようになるはずです。
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
)
}
}
}
GameUiState
で、Boolean
の変数isGameOver
を追加し、false
に設定します。
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
val isGameOver: Boolean = false
)
- アプリを実行して、ゲームをプレイします。10 単語を超えてはプレイできません。
ゲームが終了したら、ユーザーに知らせて、もう一度プレイしたいかを尋ねるのがよいでしょう。この機能は、次のタスクで実装します。
ゲーム終了ダイアログを表示する
このタスクでは、isGameOver
データを ViewModel から GameScreen
へと下方に渡し、それを使用してゲームの終了と再開の選択肢があるアラート ダイアログを表示します。
ダイアログは、ユーザーによる意思決定や追加情報の入力を求める小さなウィンドウです。通常、ダイアログは画面全体に表示されるのではなく、また続行するにはユーザーが操作を行う必要があります。Android には、さまざまな種類のダイアログが用意されています。この Codelab では、アラート ダイアログについて学習します。
アラート ダイアログの構造
- コンテナ
- アイコン(省略可)
- 見出し(省略可)
- サポート テキスト
- 分割線(省略可)
- アクション
スターター コードの GameScreen.kt
ファイルに、ゲームを終了するか再起動するかを選択できるアラート ダイアログを表示する関数がすでに用意されています。
@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))
}
}
)
}
この関数では、title
パラメータと text
パラメータがアラート ダイアログに見出しとサポート テキストを表示します。dismissButton
と confirmButton
はテキストボタンです。dismissButton
パラメータで、「Exit」というテキストを表示し、アクティビティを終了することでアプリを終了します。confirmButton
パラメータで、ゲームを再開し、「Play Again」というテキストを表示します。
GameScreen.kt
ファイルのFinalScoreDialog()
関数で、アラート ダイアログにゲームのスコアを表示するためのスコアのパラメータに注意してください。
@Composable
private fun FinalScoreDialog(
score: Int,
onPlayAgain: () -> Unit,
modifier: Modifier = Modifier
) {
FinalScoreDialog()
関数で、text
パラメータのラムダ式を使用して、ダイアログ テキストのフォーマット引数としてscore
を使用しています。
text = { Text(stringResource(R.string.you_scored, score)) }
GameScreen.kt
ファイルのコンポーズ可能な関数GameScreen()
の最後で、Column
ブロックの後に、gameUiState.isGameOver
を確認するif
条件を追加します。if
ブロックで、アラート ダイアログを表示します。FinalScoreDialog()
を呼び出して、onPlayAgain
イベント コールバックのscore
とgameViewModel.resetGame()
を渡します。
if (gameUiState.isGameOver) {
FinalScoreDialog(
score = gameUiState.score,
onPlayAgain = { gameViewModel.resetGame() }
)
}
resetGame()
は、GameScreen
から ViewModel
へと上方に渡されるイベント コールバックです。
GameViewModel.kt
ファイルで、resetGame()
関数を再度呼び出し、_uiState
を初期化して、新しい単語を選択します。
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
- アプリをビルドして実行します。
- ゲームを最後までプレイし、[Exit] または [Play Again] の選択肢があるアラート ダイアログを確認します。アラート ダイアログに表示される選択肢を試してください。
10. デバイスの回転の状態
以前の Codelab で、Android での構成変更について学習しました。構成変更があると、Android がアクティビティをゼロから再起動して、ライフサイクルの起動コールバックをすべて実行します。
ViewModel
は、Android フレームワークがアクティビティを破棄して再作成したときにも破棄されないアプリ関連のデータを格納します。ViewModel
オブジェクトは自動的に保持され、構成変更時のアクティビティ インスタンスのように破棄されることはありません。保持されるデータは、再コンポーズ後すぐに利用できるようになります。
このタスクでは、構成変更後もアプリが状態 UI を保持するかどうかを確認します。
- アプリを実行して、いくつかの単語をプレイします。デバイスの構成を縦向きから横向きに、またはその逆に変更します。
ViewModel
の状態 UI に保存されたデータが、構成変更後も保持されていることを確認します。
11. 解答コードを取得する
この Codelab の完成したコードをダウンロードするには、以下の 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
または、リポジトリを ZIP ファイルとしてダウンロードし、Android Studio で開くこともできます。
この Codelab の解答コードを確認する場合は、GitHub で表示します。
12. おわりに
お疲れさまでした。Codelab を完了しました。ここでは、Android アプリのアーキテクチャ ガイドラインでは、異なる役割を持つクラスに分割することと、モデルから UI を駆動することが推奨されていることを学習しました。
作成したら、#AndroidBasics を付けて、ソーシャル メディアで共有しましょう。