Compose での ViewModel と状態

1. 始める前に

以前の Codelab では、アクティビティのライフサイクルと、関連するライフサイクルの構成変更にともなう問題について学習しました。構成変更が発生したときには、rememberSaveable を使用する、またはインスタンスの状態を保存するなどの方法で、アプリのデータを保存できます。ただし、これらの方法で問題が発生する場合があります。ほとんどの場合には rememberSaveable を使用できますが、その場合は、コンポーザブルの中や周辺のロジックを維持することになります。アプリが大きくなったときには、データとロジックをコンポーザブルから分離する必要があります。この Codelab では、Android Jetpack ライブラリ、ViewModel、Android アプリのアーキテクチャ ガイドラインを利用して、アプリを設計し、構成変更後もアプリデータを維持する堅牢な方法について学びます。

Android Jetpack ライブラリは、優れた Android アプリの開発を支援するライブラリ集です。このライブラリ集を使用すると、ベスト プラクティスに沿って開発を進めながら、ボイラープレート コードを作成する手間を省き、複雑なタスクを簡素化できるので、アプリのロジックなどのコードの重要な部分に集中できます。

「アプリ アーキテクチャ」は、アプリの設計ルールの集まりです。アーキテクチャは、住宅の設計図とほぼ同じで、アプリに構造を与えます。優れたアプリ アーキテクチャを採用すれば、コードの堅牢性、柔軟性、スケーラビリティ、テスト性、保守性を長年にわたって維持できます。アプリ アーキテクチャ ガイドで、アプリ アーキテクチャに関する推奨事項と推奨されるベスト プラクティスを紹介しています。

この Codelab では、Android Jetpack ライブラリのアーキテクチャ コンポーネントの一つである ViewModel を利用して、アプリデータを保存する方法について学びます。フレームワークが構成変更やその他のイベント中にアクティビティを破棄して再作成しても、保存されているデータは失われません。ただし、プロセスの終了が原因でアクティビティが破棄された場合、データは失われます。ViewModel は迅速にアクティビティを再作成することによってデータをキャッシュに保存しているだけなのです。

前提条件

  • 関数、ラムダ、ステートレス コンポーザブルなど、Kotlin に関する知識
  • Jetpack Compose でレイアウトを作成する方法に関する基本的な知識
  • マテリアル デザインに関する基本的な知識

学習内容

作成するアプリの概要

  • スクランブルされた単語を推測する 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. スターター アプリの概要

次の手順でスターター コードを確認し、よく理解してください。

  1. Android Studio でスターター コードのプロジェクトを開きます。
  2. Android デバイスまたはエミュレータでアプリを実行します。
  3. [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 にハードコードされています。

1a7e4472a5638d61.png

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

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

GameLayout

GameLayout は、メインのゲーム機能(スクランブルされた単語、ゲームの手順、ユーザーの推測が入力されるテキスト フィールド)を表示するコンポーズ可能な関数です。

b6ddb1f07f10df0c.png

以下の 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 種類があります。

  • 塗りつぶしテキスト フィールド
  • 枠線付きテキスト フィールド

3df34220c3d177eb.png

枠線付きテキスト フィールドは、塗りつぶしテキスト フィールドに比べて視覚的な強調が控えめになっています。多数のテキスト項目が配置されるフォームなどに表示される場合は、強調が控えめになってレイアウトがシンプルになります。

スターター コードでは、ユーザーが推測を入力しても OutlinedTextField が更新されません。この機能は、この Codelab で更新します。

GameScreen

GameScreen コンポーザブルには、GameStatusGameLayout のコンポーズ可能な関数、ゲームタイトル、単語カウント、[Submit] ボタンと [Skip] ボタンのコンポーザブルが含まれています。

ac79bf1ed6375a27.png

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

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

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

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

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

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

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

スターター コードでは、ボタンのクリック イベントが実装されていません。これらのイベントは、この Codelab で実装します。

FinalScoreDialog

FinalScoreDialog コンポーザブルは、ダイアログ(ユーザーにプロンプトを表示する小さなウィンドウ)を表示します。ここでは、[Play Again] か [Exit] を選択できます。この Codelab の後半で、このダイアログをゲームの最後に表示するロジックを実装します。

dba2d9ea62aaa982.png

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

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

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

4. アプリ アーキテクチャの詳細

アプリ アーキテクチャでは、各クラスにアプリの責任を割り振る際のガイドラインを定めています。優れた設計のアプリ アーキテクチャでは、アプリの大規模化や機能追加による拡張が可能になります。アーキテクチャは、チームのコラボレーションを簡素化することもできます。

最も一般的なアーキテクチャ原則は、関心の分離と、モデル駆動型 UI です。

関心の分離

関心の分離という設計原則では、アプリを機能群に分割し、それぞれの機能群に別々の役割を持たせる必要があるとされています。

モデル駆動型 UI

モデル駆動型 UI の原則では、モデルから(できれば永続モデルから)UI を駆動するべきだとされています。モデルとは、アプリのデータ処理を担うコンポーネントです。モデルはアプリの UI 要素やアプリ コンポーネントから独立しているため、アプリのライフサイクルや関連する問題の影響を受けません。

前のセクションで説明したアーキテクチャに関する一般的な原則を考慮すると、各アプリに少なくとも 2 つのレイヤが必要です。

  • UI レイヤ: アプリデータを画面に表示するレイヤですが、データからは独立しています。
  • データレイヤ: アプリデータを格納、取得、公開するレイヤです。

ドメインレイヤという別のレイヤを追加することで、UI レイヤとデータレイヤの間のやり取りを簡素化でき、再利用できます。このレイヤは省略でき、このコースでは扱いません。

a4da6fa5c1c9fed5.png

UI レイヤ

UI レイヤ(またはプレゼンテーション レイヤ)の役割は、アプリデータを画面に表示することです。ユーザー操作(ボタンの押下など)によるデータの変更があると、UI を更新して変更を反映する必要があります。

UI レイヤは次のコンポーネントで構成されています。

  • UI 要素: データを画面にレンダリングするコンポーネント。Jetpack Compose を使用して作成します。
  • 状態ホルダー: データの保持、UI への公開、アプリロジックの処理を行うコンポーネント。状態ホルダーの例としては、ViewModel があります。

6eaee5b38ec247ae.png

ViewModel

ViewModel コンポーネントは、UI が消費する状態を保持し、公開します。アプリデータを ViewModel が変換したものが UI 状態となります。ViewModel を使用することで、アプリがモデル駆動形 UI のアーキテクチャ原則に従うようにできます。

ViewModel には、Android フレームワークによってアクティビティが破棄されて再作成されたときにも破棄されない、アプリ関連のデータが保存されます。ViewModel オブジェクトは、アクティビティ インスタンスとは異なり、破棄されません。このアプリは、構成変更時に ViewModel オブジェクトを自動的に保持するため、保持しているデータを再コンポーズの直後にすぐに利用できます。

アプリに ViewModel を実装するには、アーキテクチャ コンポーネント ライブラリの ViewModel クラスを拡張して、そのクラス内にアプリデータを保存します。

UI 状態

ユーザーが目にするものが UI であり、ユーザーが目にするべきであるとアプリがみなすものが UI 状態です。UI は、UI 状態を視覚的に表したものです。UI 状態が変更されると、すぐに UI に反映されます。

9cfedef1750ddd2c.png

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 に保存する必要があります。

  1. build.gradle.kts (Module :app) を開き、dependencies ブロックまでスクロールして、ViewModel に次の依存関係を追加します。この依存関係は、ライフサイクル対応ビューモデルを Compose アプリに追加するために使用されます。
dependencies {
// other dependencies

    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
  1. ui パッケージで、GameViewModel という Kotlin のクラス / ファイルを作成します。これは ViewModel クラスから拡張します。
import androidx.lifecycle.ViewModel

class GameViewModel : ViewModel() {
}
  1. ui パッケージで、GameUiState という状態 UI のモデルクラスを追加します。これをデータクラスにして、現在のスクランブルされた単語を表す変数を追加します。
data class GameUiState(
   val currentScrambledWord: String = ""
)

StateFlow

StateFlow は、現在の状態や新しい状態更新の情報を出力するデータ保持用の監視可能な Flow です。その value プロパティは、現在の状態値を反映します。状態を更新してこの Flow に送信するには、MutableStateFlow クラスの value プロパティに新しい値を割り当てます。

Android では、StateFlow は、オブザーバブルな不変状態を維持する必要があるクラスで適切に機能します。

StateFlowGameUiState から公開し、コンポーザブルが 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 内のアプリデータが、外部クラスによる望ましくない変更や安全でない変更から保護される一方で、外部の呼び出し元がその値に安全にアクセスできるようになります。
  1. GameViewModel.kt ファイルで、バッキング プロパティを _uiState という名前の uiState に追加します。プロパティに uiState という名前を付け、StateFlow<GameUiState> 型にします。

現在、_uiStateGameViewModel 内でのみアクセスと変更が可能です。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>
  1. 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 からランダムな単語を選択して、その単語をスクランブルする、ヘルパー メソッドを追加します。

  1. GameViewModel で、currentWord という String 型のプロパティを追加して、現在のスクランブルされた単語を保存します。
private lateinit var currentWord: String
  1. リストからランダムな単語を選択してシャッフルするヘルパー メソッドを追加します。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 で未定義の変数と関数のエラーが報告されます。

  1. GameViewModel で、currentWord プロパティの後に、以下のプロパティを追加します。このプロパティは、そのゲームで使用済みの単語を格納するための可変の Set となります。
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
  1. 現在の単語をシャッフルする 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)
}
  1. ゲームを初期化する resetGame() という名前のヘルパー関数を追加します。この関数は後でゲームの開始と再開に使用します。この関数では、usedWords Set 内のすべての単語をクリアし、_uiState を初期化します。pickRandomWordAndShuffle() を使用して、currentScrambledWord に設定する新しい単語を選びます。
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. GameViewModelinit ブロックを追加して、そこから 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 が状態を表示します。

61eb7bcdcff42227.png

アプリ アーキテクチャに 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 を使用するごとに再コンポーズが発生します。

  1. GameScreen 関数で、GameViewModel 型でデフォルト値が viewModel() の 2 番目の引数を渡します。
import androidx.lifecycle.viewmodel.compose.viewModel

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

de93b81a92416c23.png

  1. GameScreen() 関数に、gameUiState という名前の新しい変数を追加します。by デリゲートを使用して、uiStatecollectAsState() を呼び出します。

この方法で、uiState 値が変更されるたびに、gameUiState 値を使用したコンポーザブルの再コンポーズが行われるようになります。

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

@Composable
fun GameScreen(
   // ...
) {
   val gameUiState by gameViewModel.uiState.collectAsState()
   // ...
}
  1. gameUiState.currentScrambledWordGameLayout() コンポーザブルに渡します。引数は後のステップで追加するので、ここではエラーを無視します。
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   modifier = Modifier
       .fillMaxWidth()
       .wrapContentHeight()
       .padding(mediumPadding)
)
  1. コンポーズ可能な関数 GameLayout() にもう 1 つのパラメータとして currentScrambledWord を追加します。
@Composable
fun GameLayout(
   currentScrambledWord: String,
   modifier: Modifier = Modifier
) {
}
  1. currentScrambledWord を表示するようにコンポーズ可能な関数 GameLayout() を更新します。この列の最初のテキスト フィールドの text パラメータを currentScrambledWord に設定します。
@Composable
fun GameLayout(
   // ...
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       Text(
           text = currentScrambledWord,
           fontSize = 45.sp,
           modifier = modifier.align(Alignment.CenterHorizontally)
       )
    //...
    }
}
  1. アプリをビルドして実行すると、スクランブルされた単語が表示されます。

6d93a8e1ba5dad6f.png

推測した単語を表示する

GameLayout() コンポーザブルでは、ユーザーが推測した単語の更新が、GameScreen から ViewModel へと上方に流れるイベント コールバックの 1 つになります。データ gameViewModel.userGuessViewModel から GameScreen へと下方に流れます。

イベントが、キーボードでキーが押されたことをコールバックし、ユーザーが推測した変更が UI からビューモデルに渡される

  1. GameScreen.kt ファイルの GameLayout() コンポーザブルで、onValueChangeonUserGuessChanged に設定し、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() }
   ),
  1. コンポーズ可能な関数 GameLayout() に、引数を 2 つ追加します。onUserGuessChanged ラムダは、String 引数をとって何も返さず、onKeyboardDone は、何も受け取らず何も返しません。
@Composable
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
   ) {
}
  1. GameLayout() 関数呼び出しで、onUserGuessChangedonKeyboardDone にラムダ引数を追加します。
GameLayout(
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   currentScrambledWord = gameUiState.currentScrambledWord,
)

GameViewModelupdateUserGuess メソッドの定義は、この後すぐ行います。

  1. GameViewModel.kt ファイルで、ユーザーが推測した単語 String 引数を取る updateUserGuess() というメソッドを追加します。この関数内で、渡された guessedWorduserGuess を更新します。
  fun updateUserGuess(guessedWord: String){
     userGuess = guessedWord
  }

次に、ViewModel で userGuess を追加します。

  1. 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
  1. GameScreen.kt ファイルで、GameLayout() 内に userGuess の別の String パラメータを追加します。OutlinedTextFieldvalue パラメータを userGuess に設定します。
fun GameLayout(
   currentScrambledWord: String,
   userGuess: String,
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       //...
       OutlinedTextField(
           value = userGuess,
           //..
       )
   }
}
  1. GameScreen 関数で、GameLayout() 関数の呼び出しに userGuess パラメータを追加します。
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   //...
)
  1. アプリをビルドして実行します。
  2. 推測した単語を入力すると、テキスト フィールドにユーザーの推測が表示されます。

ed10c7f522495a.png

7. 推測した単語を照合して、スコアを更新する

このタスクでは、ユーザーが推測した単語を照合してから、ゲームスコアの更新とエラーの表示のどちらかを行うメソッドを実装します。ゲーム ステータスの UI は、後で新しいスコアと新しい単語で更新します。

  1. GameViewModel で、checkUserGuess() という別のメソッドを追加します。
  2. checkUserGuess() 関数で、ユーザーの推測が currentWord と同じかどうかを確かめる if else ブロックを追加します。userGuess を空の文字列にリセットします。
fun checkUserGuess() {

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
   }
   // Reset user guess
   updateUserGuess("")
}
  1. ユーザーの推測が間違っている場合は、isGuessedWordWrongtrue に設定します。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)
       }
   }
  1. GameUiState クラスで、isGuessedWordWrong という Boolean を追加し、false に初期化します。
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
)

次に、ユーザーが [Submit] ボタンかキーボードの [done] キーをクリックしたときに、イベント コールバック checkUserGuess()GameScreen から ViewModel へと上方に渡します。テキスト フィールドにエラーを設定するために、データ gameUiState.isGuessedWordWrongViewModel から GameScreen へと下方に渡します。

7f05d04164aa4646.png

  1. 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))
}
  1. コンポーズ可能な関数 GameScreen() で、GameLayout() 関数呼び出しを更新して、onKeyboardDone ラムダ式で gameViewModel.checkUserGuess() を渡します。
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() }
)
  1. コンポーズ可能な関数 GameLayout() で、Boolean の関数パラメータ isGuessWrong を追加します。ユーザーの推測が間違っている場合にテキスト フィールドにエラーを表示するために、OutlinedTextFieldisError パラメータを 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() }
           ),
       )
}
}
  1. コンポーズ可能な関数 GameScreen() で、GameLayout() 関数呼び出しを更新して isGuessWrong を渡します。
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() },
   isGuessWrong = gameUiState.isGuessedWordWrong,
   // ...
)
  1. アプリをビルドして実行します。
  2. 間違った推測を入力して [Submit] をクリックします。テキスト フィールドが赤色に変わり、エラーを示していることを確認します。

a1bc55781d627b38.png

テキスト フィールドのラベルには「Enter your word」と表示されています。ユーザー フレンドリーにするために、単語が間違っていることを示すエラーテキストを追加する必要があります。

  1. GameScreen.kt ファイルの GameLayout() コンポーザブルで、isGuessWrong に応じてテキスト フィールドのラベル パラメータを次のように更新します。
OutlinedTextField(
   // ...
   label = {
       if (isGuessWrong) {
           Text(stringResource(R.string.wrong_guess))
       } else {
           Text(stringResource(R.string.enter_your_word))
       }
   },
   // ...
)
  1. strings.xml ファイルで、エラーラベルに文字列を追加します。
<string name="wrong_guess">Wrong Guess!</string>
  1. 再度アプリをビルドして実行します。
  2. 間違った推測を入力して [Submit] をクリックします。エラーラベルに注目してください。

8c17eb61e9305d49.png

8. スコアと単語カウントを更新する

このタスクでは、ユーザーがゲームをプレイする際に、スコアと単語カウントを更新します。スコアは _ uiState に含まれている必要があります。

  1. GameUiState で、score 変数を追加して、ゼロに初期化します。
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
   val score: Int = 0
)
  1. スコア値を更新するために、GameViewModelcheckUserGuess() 関数にある、ユーザーの推測が正しかったときの 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 {
       //...
   }
}
  1. GameViewModel で、updateGameState という別のメソッドを追加して、スコアを更新し、現在の単語カウントを増分して、WordsData.kt ファイルから新しい単語を選べるようにします。パラメータとして updatedScore という名前の Int を追加します。ゲーム ステータスの UI 変数を次のように更新します。
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           isGuessedWordWrong = false,
           currentScrambledWord = pickRandomWordAndShuffle(),
           score = updatedScore
       )
   }
}
  1. 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("")
}

次に、スコアの更新と同様に、単語カウントを更新する必要があります。

  1. GameUiState にカウント用の別の変数を追加します。currentWordCount という名前にして 1 に初期化します。
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
)
  1. GameViewModel.kt ファイルの updateGameState() 関数で、次のように単語カウントを増やします。updateGameState() 関数は、次のラウンドのゲームを準備するために呼び出されます。
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           //...
           currentWordCount = currentState.currentWordCount.inc(),
           )
   }
}

スコアと単語カウントを渡す

以下のステップで、スコアと単語カウントのデータを ViewModel から GameScreen へと下方に渡します。

546e101980380f80.png

  1. 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
           )

// ...

}
  1. 単語カウントを含めるように GameLayout() 関数呼び出しを更新します。
GameLayout(
   userGuess = gameViewModel.userGuess,
   wordCount = gameUiState.currentWordCount,
   //...
)
  1. コンポーズ可能な関数 GameScreen() で、GameStatus() 関数呼び出しを更新して、score パラメータを追加します。gameUiState のスコアを渡します。
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
  1. アプリをビルドして実行します。
  2. 推測した単語を入力して [Submit] をクリックします。スコアと単語カウントが更新されます。
  3. [Skip] をクリックしても、何も起こりません。

スキップ機能を実装するには、スキップ イベントのコールバックを GameViewModel に渡す必要があります。

  1. GameScreen.kt ファイルにあるコンポーズ可能な関数 GameScreen()onClick ラムダ式内で gameViewModel.skipWord() を呼び出します。

この関数はまだ実装されていないため、Android Studio にエラーが表示されます。このエラーは、次のステップで skipWord() メソッドを追加して修正します。ユーザーが単語をスキップしたときには、ゲーム変数を更新し、次のラウンドのゲームを準備する必要があります。

OutlinedButton(
   onClick = { gameViewModel.skipWord() },
   modifier = Modifier.fillMaxWidth()
) {
   //...
}
  1. GameViewModel で、メソッド skipWord() を追加します。
  2. skipWord() 関数内で、updateGameState() を呼び出して、スコアを渡し、ユーザーの推測をリセットします。
fun skipWord() {
   updateGameState(_uiState.value.score)
   // Reset user guess
   updateUserGuess("")
}
  1. アプリを実行して、ゲームをプレイします。単語をスキップできるようになっているはずです。

e87bd75ba1269e96.png

単語が 10 個を超えてもゲームをプレイできます。次のタスクでは、ゲームの最終ラウンドの処理を行います。

9. ゲームの最終ラウンドを処理する

現在の実装では、ユーザーは 10 語を超えてスキップまたはプレイできます。このタスクでは、ゲームを終了するためのロジックを追加します。

d3fd67d92c5d3c35.png

ゲーム終了のロジックを実装するには、まずユーザーが単語数の上限に達しているかどうかを確認する必要があります。

  1. GameViewModelif-else ブロックを追加し、既存の関数本体を else ブロック内に移動します。
  2. 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
           )
       }
   }
}
  1. if ブロックで、Boolean フラグの isGameOver を追加し、このフラグをゲームの終了を示す true に設定します。
  2. 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
           )
       }
   }
}
  1. 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
)
  1. アプリを実行して、ゲームをプレイします。10 単語を超えてはプレイできません。

ac8a12e66111f071.png

ゲームが終了したら、ユーザーに知らせて、もう一度プレイしたいかを尋ねるのがよいでしょう。この機能は、次のタスクで実装します。

ゲーム終了ダイアログを表示する

このタスクでは、isGameOver データを ViewModel から GameScreen へと下方に渡し、それを使用してゲームの終了と再開の選択肢があるアラート ダイアログを表示します。

ダイアログは、ユーザーによる意思決定や追加情報の入力を求める小さなウィンドウです。通常、ダイアログは画面全体に表示されるのではなく、また続行するにはユーザーが操作を行う必要があります。Android には、さまざまな種類のダイアログが用意されています。この Codelab では、アラート ダイアログについて学習します。

アラート ダイアログの構造

eb6edcdd0818b900.png

  1. コンテナ
  2. アイコン(省略可)
  3. 見出し(省略可)
  4. サポート テキスト
  5. 分割線(省略可)
  6. アクション

スターター コードの GameScreen.kt ファイルに、ゲームを終了するか再起動するかを選択できるアラート ダイアログを表示する関数がすでに用意されています。

78d43c7aa01b414d.png

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

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

この関数では、title パラメータと text パラメータがアラート ダイアログに見出しとサポート テキストを表示します。dismissButtonconfirmButton はテキストボタンです。dismissButton パラメータで、「Exit」というテキストを表示し、アクティビティを終了することでアプリを終了します。confirmButton パラメータで、ゲームを再開し、「Play Again」というテキストを表示します。

a24f59b84a178d9b.png

  1. GameScreen.kt ファイルの FinalScoreDialog() 関数で、アラート ダイアログにゲームのスコアを表示するためのスコアのパラメータに注意してください。
@Composable
private fun FinalScoreDialog(
   score: Int,
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
  1. FinalScoreDialog() 関数で、text パラメータのラムダ式を使用して、ダイアログ テキストのフォーマット引数として score を使用しています。
text = { Text(stringResource(R.string.you_scored, score)) }
  1. GameScreen.kt ファイルのコンポーズ可能な関数 GameScreen() の最後で、Column ブロックの後に、gameUiState.isGameOver を確認する if 条件を追加します。
  2. if ブロックで、アラート ダイアログを表示します。FinalScoreDialog() を呼び出して、onPlayAgain イベント コールバックの scoregameViewModel.resetGame() を渡します。
if (gameUiState.isGameOver) {
   FinalScoreDialog(
       score = gameUiState.score,
       onPlayAgain = { gameViewModel.resetGame() }
   )
}

resetGame() は、GameScreen から ViewModel へと上方に渡されるイベント コールバックです。

  1. GameViewModel.kt ファイルで、resetGame() 関数を再度呼び出し、_uiState を初期化して、新しい単語を選択します。
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. アプリをビルドして実行します。
  2. ゲームを最後までプレイし、[Exit] または [Play Again] の選択肢があるアラート ダイアログを確認します。アラート ダイアログに表示される選択肢を試してください。

c6727347fe0db265.png

10. デバイスの回転の状態

以前の Codelab で、Android での構成変更について学習しました。構成変更があると、Android がアクティビティをゼロから再起動して、ライフサイクルの起動コールバックをすべて実行します。

ViewModel は、Android フレームワークがアクティビティを破棄して再作成したときにも破棄されないアプリ関連のデータを格納します。ViewModel オブジェクトは自動的に保持され、構成変更時のアクティビティ インスタンスのように破棄されることはありません。保持されるデータは、再コンポーズ後すぐに利用できるようになります。

このタスクでは、構成変更後もアプリが状態 UI を保持するかどうかを確認します。

  1. アプリを実行して、いくつかの単語をプレイします。デバイスの構成を縦向きから横向きに、またはその逆に変更します。
  2. ViewModel の状態 UI に保存されたデータが、構成変更後も保持されていることを確認します。

4a63084643723724.png

4134470d435581dd.png

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 を付けて、ソーシャル メディアで共有しましょう。

関連リンク