ViewModel にデータを保存する

前の Codelab では、アクティビティとフラグメントのライフサイクル、および関連するライフサイクルの構成変更にともなう問題について学習しました。アプリデータを保存するには、インスタンスの状態を保存するのも 1 つの方法ですが、それには固有の制限があります。この Codelab では、Android Jetpack ライブラリを利用して、アプリを設計し、構成変更後もアプリデータを維持する堅牢な方法について学びます。

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

Android アーキテクチャ コンポーネントは、Android Jetpack ライブラリに含まれ、優れたアーキテクチャを持つアプリの設計を支援します。アーキテクチャ コンポーネントは、アプリのアーキテクチャに関する指針であり、推奨されるベスト プラクティスです。

アプリ アーキテクチャは、デザインルールの集まりです。このアーキテクチャは、住宅の設計図と同じように、アプリに構造を与えます。優れたアプリ アーキテクチャは、コードの堅牢性、柔軟性、スケーラビリティ、メンテナンス性を長年にわたって高く維持します。

この Codelab では、アプリデータを保存するアーキテクチャ コンポーネントのひとつである ViewModel の使用方法を学びます。フレームワークが構成変更やその他のイベント中にアクティビティとフラグメントを破棄して再作成しても、保存されているデータは失われません。

前提条件

  • GitHub からソースコードをダウンロードして Android Studio で開く方法
  • Kotlin でアクティビティとフラグメントを使って基本的な Android アプリを作成し実行する方法
  • マテリアル テキスト フィールドと、一般的な UI ウィジェット(TextViewButton など)に関する知識
  • アプリでビュー バインディングを使用する方法
  • アクティビティとフラグメントのライフサイクルの基礎
  • アプリにログ情報を追加し、Android Studio の Logcat を使用してログを読み取る方法

学習内容

作成するアプリの概要

  • スクランブルされた単語を推測する Unscramble ゲームアプリ

必要なもの

ゲームの概要

Unscramble アプリは、1 人でプレイするスクランブラー ゲームです。アプリには、スクランブルされた単語が一度に 1 つ表示されます。プレーヤーは、スクランブルされた単語のすべての文字を使った単語を推測します。単語が正しい場合はスコアが加算されます。何回でも挑戦できます。現在の単語をスキップすることもできます。左上に単語数が表示されます。現在のゲームでプレイした単語の数です。1 ゲームにつき 10 単語です。

8edd6191a40a57e1.png 992bf57f066caf49.png b82a9817b5ec4d11.png

スターター コードをダウンロードする

この Codelab では、この Codelab で学んだ機能を使って拡張するためのスターター コードが提供されています。スターター コードには、以前の Codelab で学んだコードと学んでいないコードの両方が含まれている可能性があります。学んでいないコードの詳細については、この後の Codelab で説明します。

GitHub のスターター コードを使用する場合、フォルダ名は android-basics-kotlin-unscramble-app-starter になります。Android Studio でプロジェクトを開くときは、このフォルダを選択してください。

この Codelab のコードを取得して Android Studio で開くには、以下の手順に沿って操作します。

コードを取得する

  1. 指定された URL をクリックします。プロジェクトの GitHub ページがブラウザで開きます。
  2. プロジェクトの GitHub ページで、[Code] ボタンをクリックすると、ダイアログが表示されます。

Eme2bJP46u-pMpnXVfm-bS2N2dlyq6c0jn1DtQYqBaml7TUhzXDWpYoDI0lGKi4xndE_uJw8sKfwfOZ1fC503xCVZrbh10JKJ4iEHdLDwFfdvnOheNxkokITW1LW6UZTncVJJUZ5Fw

  1. ダイアログで、[Download ZIP] をクリックして、プロジェクトをパソコンに保存します。ダウンロードが完了するまで待ってください。
  2. パソコンに保存したファイルを見つけます([ダウンロード] フォルダなど)。
  3. ZIP ファイルをダブルクリックして展開します。プロジェクト ファイルが入った新しいフォルダが作成されます。

Android Studio でプロジェクトを開く

  1. Android Studio を起動します。
  2. [Welcome to Android Studio] ウィンドウで [Open an existing Android Studio project] をクリックします。

Tdjf5eS2nCikM9KdHgFaZNSbIUCzKXP6WfEaKVE2Oz1XIGZhgTJYlaNtXTHPFU1xC9pPiaD-XOPdIxVxwZAK8onA7eJyCXz2Km24B_8rpEVI_Po5qlcMNN8s4Tkt6kHEXdLQTDW7mg

注: Android Studio がすでに開いている場合は、メニューから [File] > [New] > [Import Project] を選択します。

PaMkVnfCxQqSNB1LxPpC6C6cuVCAc8jWNZCqy5tDVA6IO3NE2fqrfJ6p6ggGpk7jd27ybXaWU7rGNOFi6CvtMyHtWdhNzdAHmndzvEdwshF_SG24Le01z7925JsFa47qa-Q19t3RxQ

  1. [Import Project] ダイアログで、展開したプロジェクト フォルダがある場所([ダウンロード] フォルダなど)に移動します。
  2. そのプロジェクト フォルダをダブルクリックします。
  3. Android Studio でプロジェクトが開かれるまで待ちます。
  4. 実行ボタン j7ptomO2PEQNe8jFt4nKCOw_Oc_Aucgf4l_La8fGLCMLy0t9RN9SkmBFGOFjkEzlX4ce2w2NWq4J30sDaxEe4MaSNuJPpMgHxnsRYoBtIV3-GUpYYcIvRJ2HrqR27XGuTS4F7lKCzg をクリックして、アプリをビルドし、実行します。期待どおりに動作することを確認します。
  5. [Project] ツール ウィンドウでプロジェクト ファイルを表示して、アプリがどのように実装されているかを確認します。

スターター コードの概要

  1. Android Studio でスターター コードのプロジェクトを開きます。
  2. Android デバイスまたはエミュレータでアプリを実行します。
  3. [Submit] ボタンと [Skip] ボタンをタップして、数個の単語をプレイします。ボタンをタップすると、次の単語が表示され、単語数が増えます。
  4. [Submit] ボタンをタップした場合にのみスコアが増えることを確認します。

スターター コードの問題点

ゲームをプレイしていると、次のようなバグに気付くと思います。

  1. [Submit] ボタンをクリックしても、プレーヤーの単語がチェックされません。常に得点となります。
  2. ゲームを終了する方法がありません。10 単語を超えてもプレイできます。
  3. ゲーム画面にスクランブルされた単語、プレーヤーのスコア、単語が表示され、デバイスまたはエミュレータを回転して、画面の向きを変更すると、現在の単語、スコア、単語数が失われ、ゲームが最初からやり直しになります。

アプリの主な問題点

スターター アプリでは、デバイスの画面の向きが変化したときなどの構成変更時に、アプリの状態とデータが保存されません。

この問題は、onSaveInstanceState() コールバックを使用することで解決できます。ただし、onSaveInstanceState() メソッドを使用する場合、バンドルに状態を保存するためのコードや、その状態を取得するロジックを実装する必要があります。また、保存できるデータの量は最小限です。

この問題を解決するには、ここで学習する Android アーキテクチャ コンポーネントを使用します。

スターター コードのチュートリアル

ダウンロードしたスターター コードには、ゲーム画面のレイアウトがあらかじめ用意されています。ここでは、ゲームロジックの実装に焦点を当てます。アーキテクチャ コンポーネントを使用して、推奨されるアプリ アーキテクチャを実装し、上記の問題を解決します。作業の土台とするファイルの一部について簡単に説明します。

game_fragment.xml

  • [Design] ビューで res/layout/game_fragment.xml を開きます。
  • これには、ゲーム画面となるアプリの唯一の画面のレイアウトが含まれています。
  • このレイアウトには、プレーヤーの単語のテキスト フィールドと、スコアと単語数を表示する TextViews が含まれています。ゲームをプレイするための手順とボタン([Submit] と [Skip])も表示されます。

main_activity.xml

1 つのゲーム フラグメントを持つメイン アクティビティのレイアウトを定義します。

res/values フォルダ

このフォルダにあるリソース ファイルについては学習済みです。

  • colors.xml には、アプリで使用するテーマカラーが入っています。
  • strings.xml には、アプリに必要な文字列がすべて入っています。
  • themes フォルダと styles フォルダには、このアプリ用の UI のカスタマイズが入っています。

MainActivity.kt

アクティビティのコンテンツ ビューを main_activity.xml. に設定する、デフォルト テンプレートで生成されたコードが入っています。

ListOfWords.kt

このファイルには、ゲームで使用されている単語のリスト、ゲームごとの最大単語数、正解するごとに加算される点数が入っています。

GameFragment.kt

このフラグメントは、アプリ内の唯一のフラグメントであり、ゲームのアクションのほとんどはここで発生します。

  • 現在のスクランブルされた単語(currentScrambledWord)、単語数(currentWordCount)、スコア(score)の変数が定義されています。
  • binding という名前の game_fragment ビューにアクセスできるバインディング オブジェクトのインスタンスが定義されています。
  • onCreateView() 関数は、バインディング オブジェクトを使用して game_fragment レイアウト XML をインフレートします。
  • onViewCreated() 関数は、ボタンのクリック リスナーをセットアップし、UI を更新します。
  • onSubmitWord() は、[Submit] ボタンのクリック リスナーです。この関数は次のスクランブルされた単語を表示し、テキスト フィールドをクリアして、プレーヤーの単語を検証せずにスコアと単語数を増やします。
  • onSkipWord() は、[Skip] ボタンのクリック リスナーであり、この関数はスコアを除いて onSubmitWord() と同じように UI を更新します。
  • getNextScrambledWord() は、単語リストからランダムに単語を選択し、その文字をシャッフルするヘルパー関数です。
  • restartGame() 関数と exitGame() 関数は、それぞれゲームのやり直しと終了に使用します。これらの関数は後で使用します。
  • resetTextField() は、テキスト フィールドの内容を消去し、エラー ステータスをリセットします。
  • updateNextWordOnScreen() 関数は、新しいスクランブルされた単語を表示します。

アーキテクチャは、各クラスにアプリ内での役割を割り当てるためのガイドラインを提供します。優れた設計のアプリ アーキテクチャでは、アプリの大規模化や機能追加による拡張が可能になります。また、チームの共同作業も容易になります。

最も一般的なアーキテクチャ原則は、関心の分離と、モデルで UI を操作することです。

関心の分離

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

UI をモデルで操作する

もう 1 つの重要な原則は、UI をモデルで操作することです。望ましいのは永続モデルです。モデルとは、アプリのデータ処理を担うコンポーネントです。モデルはアプリの Views オブジェクトやアプリ コンポーネントから独立しているため、アプリのライフサイクルや関連する問題の影響を受けません。

Android アーキテクチャの主なクラスまたはコンポーネントは、UI コントローラ(アクティビティ / フラグメント)である ViewModelLiveDataRoom です。これらのコンポーネントを使用することで、ライフサイクルの複雑さに対処し、ライフサイクルに関する問題を回避できます。LiveDataRoom については、後の Codelab で学習します。

次の図は、アーキテクチャの基本部分を示しています。

53dd5e42f23ffba9.png

UI コントローラ(アクティビティ / フラグメント)

アクティビティとフラグメントは UI コントローラです。UI コントローラは、画面へのビューの表示や、ユーザー イベントやユーザーが操作する UI に関連するその他の内容を捕捉することで、UI を制御します。アプリ内のデータやそのデータに関する意思決定ロジックは、UI コントローラ クラスに入れるべきではありません。

Android システムは、特定のユーザー操作に基づいて、またはシステム状態(メモリ不足など)を理由として、UI コントローラをいつでも破棄できます。デベロッパーはこうしたイベントを制御できないため、アプリのデータや状態を UI コントローラに保存してはなりません。代わりに、データに関する意思決定ロジックを ViewModel に追加するべきです。

たとえば、Unscramble アプリでは、スクランブルされた単語、スコア、単語数がフラグメント(UI コントローラ)に表示されます。次のスクランブルされた単語の作成や、スコアと単語数の計算などの意思決定コードは、ViewModel に入れるべきです。

ViewModel

ViewModel は、ビューに表示されるアプリデータのモデルです。モデルは、アプリのデータ処理を担うコンポーネントです。これにより、アプリがアーキテクチャ原則に従い、モデルで UI を操作することが可能になります。

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

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

まとめ

フラグメント / アクティビティ(UI コントローラ)の役割

ViewModel の役割

アクティビティとフラグメントは、画面へのビューやデータの描画や、ユーザー イベントへの応答を担います。

ViewModel は、UI に必要なすべてのデータの保持と処理を担います。ここでは、ビュー階層(ビュー バインディング オブジェクトなど)にアクセスしたり、アクティビティまたはフラグメントへの参照を保持したりしないでください。

このタスクでは、アプリデータ(スクランブルされた単語、単語数、スコア)を保存する ViewModel をアプリに追加します。

アプリのアーキテクチャは次のようになります。MainActivity には GameFragment が含まれ、GameFragmentGameViewModel のゲームに関する情報にアクセスします。

2094f3414ddff9b9.png

  1. Android Studio の [Android] ウィンドウで、[Gradle Scripts] フォルダにあるファイル build.gradle(Module:Unscramble.app) を開きます。
  2. アプリで ViewModel を使用するために、ViewModel ライブラリの依存関係が dependencies ブロック内にあることを確認します。このステップはすでに完了しています。
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

必ず最新バージョンのライブラリを使用してください。

  1. GameViewModel という名前の新しい Kotlin クラスファイルを作成します。[Android] ウィンドウで ui.game フォルダを右クリックします。[New] > [Kotlin File/Class] を選択します。

74c85ee631d6524c.png

  1. 名前を GameViewModel にして、リストから [Class] を選択します。
  2. ViewModel をサブクラス化するように GameViewModel を変更します。ViewModel は抽象クラスであるため、アプリで使用するには拡張する必要があります。以下の GameViewModel クラス定義をご覧ください。
class GameViewModel : ViewModel() {
}

ViewModel をフラグメントに接続する

ViewModel を UI コントローラ(アクティビティ / フラグメント)に関連付けるために、UI コントローラ内に ViewModel への参照(オブジェクト)を作成します。

このステップでは、対応する UI コントローラ(GameFragment)内に GameViewModel のオブジェクト インスタンスを作成します。

  1. GameFragment クラスの一番上に、GameViewModel タイプのプロパティを追加します。
  2. by viewModels() という Kotlin のプロパティ委譲を使用して GameViewModel を初期化します。詳しくは次のセクションで説明します。
private val viewModel: GameViewModel by viewModels()
  1. Android Studio でプロンプトが表示されたら、androidx.fragment.app.viewModelsimport します。

Kotlin のプロパティ委譲

Kotlin では、可変(var)プロパティに、デフォルトのゲッター関数とセッター関数が自動的に生成されます。セッター関数とゲッター関数は、値を代入するときと、プロパティの値を読み取るときに呼び出されます。

読み取り専用のプロパティ(val)の場合は、可変プロパティとは少し異なります。デフォルトでは、ゲッター関数のみが生成されます。このゲッター関数は、読み取り専用のプロパティの値を読み取るときに呼び出されます。

Kotlin のプロパティ委譲により、ゲッター / セッターの役割を別のクラスに引き継ぐことができます。

このクラス(委譲クラス)は、プロパティのゲッター関数とセッター関数を提供し、プロパティの変更を処理します。

委譲プロパティは、by 句と委譲クラスのインスタンスを使用して定義します。

// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()

アプリで、次のようにデフォルトの GameViewModel コンストラクタを使用してビューモデルを初期化すると、

private val viewModel = GameViewModel()

デバイスで構成変更が発生したとき、viewModel 参照の状態が失われます。たとえば、デバイスを回転させると、アクティビティは破棄されて、再度作成され、初期状態の新しいビューモデル インスタンスとなります。

代わりに、プロパティ委譲のアプローチを使用し、viewModel オブジェクトの役割を viewModels という名前の別のクラスに委譲します。すると、viewModel オブジェクトへのアクセスは、委譲クラス viewModels によって内部的に処理されます。委譲クラスは、最初にアクセスされたときに viewModel オブジェクトを作成し、構成変更後もその値を保持し、リクエストに対してその値を返します。

アプリの UI データを UI コントローラ(Activity クラスまたは Fragment クラス)から分離すると、上記で説明した役割を単一とする原則に従いやすくなります。アクティビティとフラグメントには、ビューとデータを画面に描画する役割があります。一方で、ViewModel は、UI に必要なすべてのデータを保持し、処理する役割を担います。

このタスクでは、データ変数を GameFragment から GameViewModel クラスに移動します。

  1. データ変数 scorecurrentWordCountcurrentScrambledWordGameViewModel クラスに移動します。
class GameViewModel : ViewModel() {

    private var score = 0
    private var currentWordCount = 0
    private var currentScrambledWord = "test"
...
  1. 未解決の参照に関するエラーが発生します。これは、プロパティが ViewModel に対して非公開であり、UI コントローラからアクセスできないためです。このエラーを修正します。

この問題を解決するために、プロパティの可視性修飾子を public にしてはいけません。このデータを他のクラスから変更できるようにすべきではないのです。外部のクラスからこのデータに対して、ビューモデルで指定したゲームルールから外れた想定外の変更が行われる危険性があります。たとえば、外部のクラスが score を負の値に変更する可能性が生じます。

ViewModel 内ではデータを編集できるように、private かつ var にする必要があります。ViewModel の外部からは、データを読み取り可能にし、変更は不可能にする必要があるため、データを public かつ val として公開する必要があります。この動作を実現するため、Kotlin にはバッキング プロパティと呼ばれる機能があります。

バッキング プロパティ

バッキング プロパティを使用すると、そのオブジェクト自体ではなくゲッターから返すことができます。

Kotlin のフレームワークでは、どのプロパティにもゲッターとセッターが生成されることを学習しました。

ゲッター メソッドとセッター メソッドに関しては、両方または片方のメソッドをオーバーライドして、カスタムの動作を提供できます。バッキング プロパティを実装するには、ゲッター メソッドをオーバーライドして、データの読み取り専用バージョンを返すようにします。以下はバッキング プロパティの例です。

// 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 クラス内でのみアクセスと変更ができます。慣例では、private プロパティの前にアンダースコアを付けます。

ViewModel クラスの外部:

  • Kotlin のデフォルトの可視性修飾子は public であるため、count は公開され、UI コントローラなどの他のクラスからアクセス可能です。オーバーライドしようとしているのは get() メソッドだけであるため、このプロパティは不変で読み取り専用です。外部のクラスがこのプロパティにアクセスすると、_count の値が返されますが、その値は変更できません。これにより、ViewModel 内のアプリデータが、外部クラスによる望ましくない変更や安全でない変更から保護される一方で、外部の呼び出し元がその値に安全にアクセスできるようになります。

バッキング プロパティを currentScrambledWord に追加する

  1. GameViewModelcurrentScrambledWord 宣言を変更して、バッキング プロパティを追加します。現在、_currentScrambledWordGameViewModel 内でのみアクセスと変更が可能です。UI コントローラである GameFragment は、読み取り専用のプロパティ currentScrambledWord を使用して、その値を読み取ることができます。
private var _currentScrambledWord = "test"
val currentScrambledWord: String
   get() = _currentScrambledWord
  1. GameFragment のメソッド updateNextWordOnScreen() を更新して、読み取り専用の viewModel プロパティ(currentScrambledWord)を使用するようにします。
private fun updateNextWordOnScreen() {
   binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}
  1. GameFragmentonSubmitWord() メソッド内と onSkipWord() メソッド内のコードを削除します。これらのメソッドは後で実装します。これで、コードをエラーなしでコンパイルできるようになるはずです。

フレームワークは、アクティビティまたはフラグメントのスコープが有効である限り、ViewModel を有効にします。ViewModel は、画面の回転などの構成変更によってそのオーナーが破棄されても破棄されません。次の図のように、オーナーの新しいインスタンスが既存の ViewModel インスタンスに再接続されます。

18e67dc79f89d8a.png

ViewModel のライフサイクルについて

GameViewModelGameFragment にロギングを追加して、ViewModel のライフサイクルについての理解を深めましょう。

  1. GameViewModel.kt に、ログ ステートメントを含む init ブロックを追加します。
class GameViewModel : ViewModel() {
   init {
       Log.d("GameFragment", "GameViewModel created!")
   }

   ...
}

Kotlin には、オブジェクト インスタンスの初期化中に必要となる初期セットアップ コードの場所として、初期化ブロック(init ブロック)が用意されています。初期化ブロックは、init キーワードの後に波かっこ「{}」が続くものです。このコードブロックは、オブジェクト インスタンスが最初に作成され初期化されたときに実行されます。

  1. GameViewModel クラスの onCleared() メソッドをオーバーライドします。ViewModel は、関連付けられたフラグメントの接続が解除されたとき、またはアクティビティが終了したときに破棄されます。ViewModel が破棄される直前には、onCleared() コールバックが呼び出されます。
  2. onCleared() 内にログ ステートメントを追加して、GameViewModel のライフサイクルを追跡します。
override fun onCleared() {
    super.onCleared()
    Log.d("GameFragment", "GameViewModel destroyed!")
}
  1. onCreateView() 内の GameFragment で、バインディング オブジェクトへの参照を取得し、フラグメントの作成を記録するログ ステートメントを追加します。onCreateView() コールバックは、フラグメントが初めて作成されたとき、および構成変更などのイベントのために再作成されるたびにトリガーされます。
override fun onCreateView(
   inflater: LayoutInflater, container: ViewGroup?,
   savedInstanceState: Bundle?
): View {
   binding = GameFragmentBinding.inflate(inflater, container, false)
   Log.d("GameFragment", "GameFragment created/re-created!")
   return binding.root
}
  1. GameFragment で、対応するアクティビティとフラグメントが破棄されたときに呼び出される onDetach() コールバック メソッドをオーバーライドします。
override fun onDetach() {
    super.onDetach()
    Log.d("GameFragment", "GameFragment destroyed!")
}
  1. Android Studio でアプリを実行し、[Logcat] ウィンドウを開き、GameFragment でフィルタします。GameFragmentGameViewModel が作成されているのがわかります。
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
  1. デバイスやエミュレータで自動回転設定を有効にして、画面の向きを数回変更します。GameFragment は毎回破棄されて再作成されますが、GameViewModel は一度だけ作成され、呼び出しごとに再作成または破棄されることはありません。
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
  1. ゲームを終了するか、戻る矢印を使ってアプリから移動します。GameViewModel が破棄され、コールバック onCleared() が呼び出されます。GameFragment が破棄されます。
com.example.android.unscramble D/GameFragment: GameViewModel destroyed!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!

このタスクでは、GameViewModel に、次の単語を取得するヘルパー メソッド、プレーヤーの単語を検証してスコアを増やすヘルパー メソッド、単語数を確認してゲームを終了するヘルパー メソッドを追加します。

遅延初期化

通常、変数を宣言する場合、前もって初期値を指定します。ただし、値を代入する準備ができていない場合は、後で初期化できます。Kotlin でプロパティを後で初期化するには、lateinit というキーワードを使用します。遅延初期化という意味です。プロパティの使用前に必ず初期化する場合は、lateinit でプロパティを宣言できます。メモリは変数が初期化されるまで割り当てられません。初期化前に変数にアクセスしようとすると、アプリがクラッシュします。

次の単語を取得する

GameViewModel クラスに、次の機能を備えた getNextWord() メソッドを作成します。

  • allWordsList からランダムな単語を取得し、currentWord. に代入します。
  • currentWord 内の文字を入れ替えてスクランブルされた単語を作成し、それを currentScrambledWord に代入します。
  • スクランブルされた単語とスクランブルされていない単語が同じ場合に対処します。
  • ゲーム中に同じ単語を 2 回表示しないようにします。

GameViewModel クラスに以下のステップを実装します。

  1. GameViewModel, に、wordsList という新しい MutableList<String> 型のクラス変数を追加し、ゲームで使用する単語のリストを保持して、重複を避けます。
  2. currentWord という別のクラス変数を追加して、プレーヤーがスクランブル解除する単語を保持します。このプロパティは後で初期化するため、lateinit キーワードを使用します。
private var wordsList: MutableList<String> = mutableListOf()
private lateinit var currentWord: String
  1. 戻り値もパラメータもない、getNextWord() という新しい private メソッドを追加します。
  2. allWordsList からランダムな単語を取得して、currentWord に代入します。
private fun getNextWord() {
   currentWord = allWordsList.random()
}
  1. getNextWord() で、currentWord 文字列を文字の配列に変換し、tempWord という新しい val に代入します。単語をスクランブルするために、Kotlin メソッド shuffle() を使用して、この配列内の文字をシャッフルします。
val tempWord = currentWord.toCharArray()
tempWord.shuffle()

ArrayList に似ていますが、初期化時にサイズが固定されます。Array では、サイズの増減はできません(配列のサイズを変更するにはコピーする必要があります)。これに対して、List には add() 関数と remove() 関数があり、サイズを増減できます。

  1. シャッフルした文字の順序が、元の単語と同じになる場合もあります。次のように shuffle への呼び出しの周りに while を追加して、スクランブルされた単語が元の単語と同じである限りループし続けるようにします。
while (tempWord.toString().equals(currentWord, false)) {
    tempWord.shuffle()
}
  1. if-else ブロックを追加して、単語がすでに使用されているかどうかを確認します。wordsListcurrentWord が含まれている場合は、getNextWord() を呼び出します。それ以外の場合は、新たにスクランブルされた単語で _currentScrambledWord の値を更新し、単語数を増やして、wordsList に新しい単語を追加します。
if (wordsList.contains(currentWord)) {
    getNextWord()
} else {
    _currentScrambledWord = String(tempWord)
    ++currentWordCount
    wordsList.add(currentWord)
}
  1. 完成した getNextWord() メソッドを以下に示します。
/*
* Updates currentWord and currentScrambledWord with the next word.
*/
private fun getNextWord() {
   currentWord = allWordsList.random()
   val tempWord = currentWord.toCharArray()
   tempWord.shuffle()

   while (tempWord.toString().equals(currentWord, false)) {
       tempWord.shuffle()
   }
   if (wordsList.contains(currentWord)) {
       getNextWord()
   } else {
       _currentScrambledWord = String(tempWord)
       ++currentWordCount
       wordsList.add(currentWord)
   }
}

currentScrambledWord を遅延初期化する

これで、次のスクランブルされた単語を取得する getNextWord() メソッドが作成されました。初めて GameViewModel が初期化されたときに、これを呼び出します。init ブロックを使用して、現在の単語などのクラス内の lateinit プロパティを初期化します。その結果、画面に表示される最初の単語が「test」ではなくスクランブルされた単語になります。

  1. アプリを実行します。最初の単語は常に「test」なのがわかります。
  2. アプリの開始時にスクランブルされた単語を表示するには、getNextWord() メソッドを呼び出す必要があります。その後、currentScrambledWord が更新されます。GameViewModelinit ブロック内でメソッド getNextWord() を呼び出します。
init {
    Log.d("GameFragment", "GameViewModel created!")
    getNextWord()
}
  1. lateinit 修飾子を _currentScrambledWord プロパティに追加します。初期値が指定されていないため、データ型 String を明示的に指定します。
private lateinit var _currentScrambledWord: String
  1. アプリを実行します。起動すると、スクランブルされた新しい単語が表示されます。うまくできました。

8edd6191a40a57e1.png

ヘルパー メソッドを追加する

次に、ViewModel 内のデータを処理して変更するヘルパー メソッドを追加します。このメソッドは後のタスクで使用します。

  1. GameViewModel クラスに、nextWord(). という別のメソッドを追加します。リストから次の単語を取得し、単語数が MAX_NO_OF_WORDS より小さい場合には true を返します。
/*
* Returns true if the current word count is less than MAX_NO_OF_WORDS.
* Updates the next word.
*/
fun nextWord(): Boolean {
    return if (currentWordCount < MAX_NO_OF_WORDS) {
        getNextWord()
        true
    } else false
}

スターター コードでは、10 単語をプレイしてもゲームが終了しません。単語が 10 個を超えたらゲームを終わりにして、最終スコアのダイアログを表示するようにします。また、もう一度プレイするかゲームを終了するかを選択できるようにもします。

c418686382513213.png

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

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

a5ecc09450ae44dc.png

  1. アラート ダイアログ
  2. タイトル(省略可)
  3. メッセージ
  4. テキストボタン

最終スコアのダイアログを実装する

マテリアル デザイン コンポーネント ライブラリの MaterialAlertDialog を使用すると、マテリアル ガイドラインを遵守したダイアログをアプリに追加できます。ダイアログは UI に関連しているため、GameFragment が最終スコアのダイアログを作成して表示する役割を担います。

  1. まず、バッキング プロパティを score 変数に追加します。GameViewModel で、score 変数の宣言を次のように変更します。
private var _score = 0
val score: Int
   get() = _score
  1. GameFragment に、showFinalScoreDialog() という非公開関数を追加します。MaterialAlertDialog を作成するために、MaterialAlertDialogBuilder クラスを使ってダイアログの構成要素を段階的に構築します。MaterialAlertDialogBuilder コンストラクタを呼び出して、フラグメントの requireContext() メソッドを使用して内容を渡します。requireContext() メソッドは null 以外の Context を返します。
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
}

Context は、その名前が示すように、アプリケーション、アクティビティ、フラグメントのコンテキストまたは現在の状態を表します。これには、アクティビティ、フラグメント、またはアプリケーションに関する情報が含まれています。通常、リソース、データベース、その他のシステム サービスへのアクセスに使用されます。このステップでは、フラグメントのコンテキストを渡してアラート ダイアログを作成します。

Android Studio でプロンプトが表示されたら、com.google.android.material.dialog.MaterialAlertDialogBuilderimport します。

  1. アラート ダイアログにタイトルを設定するコードを追加します。strings.xml の文字列リソースを使用します。
MaterialAlertDialogBuilder(requireContext())
   .setTitle(getString(R.string.congratulations))
  1. 最終スコアを示すメッセージを設定します。先ほど追加したスコア変数(viewModel.score)の読み取り専用バージョンを使用します。
   .setMessage(getString(R.string.you_scored, viewModel.score))
  1. [戻る] キーが押されてもアラート ダイアログがキャンセルされないように、setCancelable() メソッドを使用して false を渡します。
    .setCancelable(false)
  1. メソッド setNegativeButton()setPositiveButton() を使用して、2 つのテキストボタン [EXIT] と [PLAY AGAIN] を追加します。exitGame()restartGame() をそれぞれのラムダから呼び出します。
    .setNegativeButton(getString(R.string.exit)) { _, _ ->
        exitGame()
    }
    .setPositiveButton(getString(R.string.play_again)) { _, _ ->
        restartGame()
    }

この構文はまだ説明していないかもしれませんが、setNegativeButton(getString(R.string.exit), { _, _ -> exitGame()}) の略となっています。ここで、setNegativeButton() メソッドは、String と、ラムダとして記述できる関数 DialogInterface.OnClickListener() の 2 つのパラメータを取ります。渡す引数の最後が関数である場合は、ラムダ式をかっこの外側に置くことができます。これは後置ラムダ構文と呼ばれます。どちらのコード記述方法も(ラムダをかっこ内に書いても、かっこ外に書いても)許容されます。setPositiveButton 関数についても同様です。

  1. 最後に、アラート ダイアログを作成してから表示する show() を追加します。
      .show()
  1. 参考のため showFinalScoreDialog() メソッド全体を次に示します。
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
       .setTitle(getString(R.string.congratulations))
       .setMessage(getString(R.string.you_scored, viewModel.score))
       .setCancelable(false)
       .setNegativeButton(getString(R.string.exit)) { _, _ ->
           exitGame()
       }
       .setPositiveButton(getString(R.string.play_again)) { _, _ ->
           restartGame()
       }
       .show()
}

このタスクでは、ViewModel と追加したアラート ダイアログを使って、[Submit] ボタンのクリック リスナー用のゲームロジックを実装します。

スクランブルされた単語を表示する

  1. まだ行っていない場合は、GameFragment で、[Submit] ボタンがタップされたときに呼び出される onSubmitWord() ないのコードを削除します。
  2. viewModel.nextWord() メソッドの戻り値のチェックを追加します。true の場合、別の単語があるので、updateNextWordOnScreen() を使用して、画面上のスクランブルされた単語を更新します。それ以外の場合は、ゲームが終了し、最終スコアとともにアラート ダイアログが表示されます。
private fun onSubmitWord() {
    if (viewModel.nextWord()) {
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. アプリを実行しましょう。単語数個分をプレイします。[Skip] ボタンが実装されていないため、単語のスキップはできません。
  2. テキスト フィールドは更新されないため、プレーヤーが前の単語を手動で削除する必要があります。アラート ダイアログの最終スコアは常に 0 です。これらのバグは、次のステップで修正します。

a4c660e212ce2c31.png 12a42987a0edd2c4.png

プレーヤーの単語を検証するヘルパー メソッドを追加する

  1. GameViewModel に、パラメータも戻り値もない increaseScore() という新しい非公開メソッドを追加します。score 変数を SCORE_INCREASE の分だけ増やします。
private fun increaseScore() {
   _score += SCORE_INCREASE
}
  1. GameViewModelisUserWordCorrect() というヘルパー メソッドを追加します。このメソッドは Boolean を返し、プレーヤーの単語である String をパラメータとして受け取ります。
  2. isUserWordCorrect() で、プレーヤーの単語を検証し、推測が正しい場合はスコアを増やします。これにより、アラート ダイアログの最終スコアが更新されます。
fun isUserWordCorrect(playerWord: String): Boolean {
   if (playerWord.equals(currentWord, true)) {
       increaseScore()
       return true
   }
   return false
}

テキスト フィールドを更新する

テキスト フィールドにエラーを表示する

マテリアル テキスト フィールドの場合、TextInputLayout にはエラー メッセージを表示する機能が組み込まれています。たとえば、次のテキスト フィールドでは、ラベルの色の変更、エラーアイコンの表示、エラー メッセージの表示などが行われています。

18069f0e6b2fddbc.png

テキスト フィールドにエラーを表示するには、エラー メッセージの設定をコード内で動的に行うか、レイアウト ファイル内で静的に行います。コード内でエラーの設定と解除を行うコードは、次のとおりです。

// Set error text
passwordLayout.error = getString(R.string.error)

// Clear error text
passwordLayout.error = null

このスターター コードにあるヘルパー メソッド setErrorTextField(error: Boolean) はすでに、テキスト フィールドに対するエラーの設定と解除に使用できるように定義されています。テキスト フィールドにエラーを表示するかどうかに応じて、入力パラメータに true または false を指定してこのメソッドを呼び出します。

スターター コード内のコード スニペット

private fun setErrorTextField(error: Boolean) {
   if (error) {
       binding.textField.isErrorEnabled = true
       binding.textField.error = getString(R.string.try_again)
   } else {
       binding.textField.isErrorEnabled = false
       binding.textInputEditText.text = null
   }
}

このタスクでは、onSubmitWord() メソッドを実装します。単語が送信されたら、元の単語とユーザーの推測を照合します。単語が正しい場合は、次の単語に進みます(ゲームが終了した場合はダイアログを表示します)。単語が間違っている場合は、テキスト フィールドにエラーを表示し、現在の単語はそのままにします。

  1. GameFragment,onSubmitWord() の先頭で、playerWord という名前の val を作成します。そこに、binding 変数のテキスト フィールドから抽出したプレーヤーの単語を保存します。
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    ...
}
  1. onSubmitWord()playerWord の宣言の下で、プレーヤーの単語を検証します。プレーヤーの単語を確認する if ステートメントを追加して、isUserWordCorrect() メソッドを使用して playerWord を渡します。
  2. if ブロック内でテキスト フィールドをリセットし、setErrorTextField を呼び出して false を渡します。
  3. 既存のコードを if ブロック内に移動します。
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }
}
  1. ユーザーの単語が間違っている場合は、テキスト フィールドにエラー メッセージを表示します。さらに、上の if ブロックに else ブロックを追加し、setErrorTextField() を呼び出して true に渡します。完成した onSubmitWord() メソッドは次のようになります。
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    } else {
        setErrorTextField(true)
    }
}
  1. アプリを実行して、数語プレイします。プレーヤーの単語が正しい場合は、[Submit] ボタンをクリックするとその単語がクリアされ、正しくない場合は「Try again!」というメッセージが表示されます。なお、[Skip] ボタンはまだ使用できません。この実装は次のタスクで追加します。

a10c7d77aa26b9db.png

このタスクでは、[Skip] ボタンのクリックを処理する onSkipWord() の実装を追加します。

  1. onSubmitWord() と同様に、nextWord() メソッドに条件を追加します。true の場合は、画面に単語を表示し、テキスト フィールドをリセットします。false の場合は、このゲームに残りの単語がない場合、最終スコアをアラート ダイアログに表示します。
/*
* Skips the current word without changing the score.
*/
private fun onSkipWord() {
    if (viewModel.nextWord()) {
        setErrorTextField(false)
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. アプリを実行して、ゲームをプレイします。[Skip] ボタンと [Submit] ボタンが意図したとおりに動作しています。うまくできました。

このタスクでは、GameFragment にロギングを追加して、構成変更後もアプリデータが ViewModel に維持されていることを確認します。GameFragmentcurrentWordCount にアクセスするには、バッキング プロパティを使用して読み取り専用バージョンを公開する必要があります。

  1. GameViewModel で、変数 currentWordCount を右クリックし、[Refactor] > [Rename...] を選択します。新しい名前の先頭にアンダースコアを付けます(_currentWordCount)。
  2. バッキング フィールドを追加します。
private var _currentWordCount = 0
val currentWordCount: Int
   get() = _currentWordCount
  1. GameFragmentonCreateView() 内で、return ステートメントの上にアプリデータ、単語、スコア、単語数を出力するログを追加します。
Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
       "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
  1. Android Studio で [Logcat] を開き、GameFragment でフィルタします。アプリを実行して、数語プレイします。デバイスの向きを変更します。フラグメント(UI コントローラ)が破棄されて再作成されます。ログを確認します。スコアと単語数が増えたことがわかります。
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
com.example.android.unscramble D/GameFragment: Word: oimfnru Score: 0 WordCount: 1
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: ofx Score: 80 WordCount: 5
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9
com.example.android.unscramble D/GameFragment: GameFragment destroyed!
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: Word: nvoiil Score: 160 WordCount: 9

向きを変更した後もアプリデータが ViewModel に維持されています。後の Codelab では、LiveData とデータ バインディングを使用して、UI のスコア値と単語数を更新します。

  1. アプリを再度実行し、すべての単語をプレイします。[Congratulations!] アラート ダイアログで [PLAY AGAIN] をクリックします。単語数が MAX_NO_OF_WORDS に達したため、もう一度ゲームをプレイすることはできません。最初からゲームをプレイするには、単語数を 0 にリセットする必要があります。
  2. アプリデータをリセットするために、GameViewModelreinitializeData() というメソッドを追加します。スコアと単語数を 0 に設定します。単語リストをクリアして getNextWord() メソッドを呼び出します。
/*
* Re-initializes the game data to restart the game.
*/
fun reinitializeData() {
   _score = 0
   _currentWordCount = 0
   wordsList.clear()
   getNextWord()
}
  1. GameFragment のメソッド restartGame() の先頭で、新しく作成したメソッド reinitializeData() を呼び出します。
private fun restartGame() {
   viewModel.reinitializeData()
   setErrorTextField(false)
   updateNextWordOnScreen()
}
  1. アプリを再度実行します。ゲームをプレイします。[Congratulations!] ダイアログが表示されたら、[Play Again] をクリックします。ゲームをもう一度プレイできるようになりました。

最終的なアプリは次のように表示されます。このゲームでは、プレーヤーがスクランブル解除するランダムな単語を 10 個表示します。単語を [Skip] するか、推測して、[Submit] をタップできます。正解するとスコアが増えます。回答が間違っていると、テキスト フィールドにエラー状態が表示されます。単語が新しくなるたびに、単語数も増えます。

なお、画面に表示されるスコアと文字数は、まだ更新されません。しかし、この情報はビューモデルに格納されていて、デバイスの回転などの構成変更後も維持されます。後の Codelab で、画面上のスコアと単語数を更新します。

f332979d6f63d0e5.png 2803d4855f5d401f.png

最後の 10 個目の単語でゲームが終了し、最終スコアとゲームを終了するかもう一度プレイするかを選ぶ選択肢があるアラート ダイアログが表示されます。

d8e0111f5f160ead.png

これで完成です。最初の ViewModel を作成してデータを保存しました。

GameFragment.kt

import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.example.android.unscramble.R
import com.example.android.unscramble.databinding.GameFragmentBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder

/**
 * Fragment where the game is played, contains the game logic.
 */
class GameFragment : Fragment() {

    private val viewModel: GameViewModel by viewModels()

    // Binding object instance with access to the views in the game_fragment.xml layout
    private lateinit var binding: GameFragmentBinding

    // Create a ViewModel the first time the fragment is created.
    // If the fragment is re-created, it receives the same GameViewModel instance created by the
    // first fragment

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout XML file and return a binding object instance
        binding = GameFragmentBinding.inflate(inflater, container, false)
        Log.d("GameFragment", "GameFragment created/re-created!")
        Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
                "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Setup a click listener for the Submit and Skip buttons.
        binding.submit.setOnClickListener { onSubmitWord() }
        binding.skip.setOnClickListener { onSkipWord() }
        // Update the UI
        updateNextWordOnScreen()
        binding.score.text = getString(R.string.score, 0)
        binding.wordCount.text = getString(
            R.string.word_count, 0, MAX_NO_OF_WORDS)
    }

    /*
    * Checks the user's word, and updates the score accordingly.
    * Displays the next scrambled word.
    * After the last word, the user is shown a Dialog with the final score.
    */
    private fun onSubmitWord() {
        val playerWord = binding.textInputEditText.text.toString()

        if (viewModel.isUserWordCorrect(playerWord)) {
            setErrorTextField(false)
            if (viewModel.nextWord()) {
                updateNextWordOnScreen()
            } else {
                showFinalScoreDialog()
            }
        } else {
            setErrorTextField(true)
        }
    }

    /*
    * Skips the current word without changing the score.
    */
    private fun onSkipWord() {
        if (viewModel.nextWord()) {
            setErrorTextField(false)
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }

    /*
     * Gets a random word for the list of words and shuffles the letters in it.
     */
    private fun getNextScrambledWord(): String {
        val tempWord = allWordsList.random().toCharArray()
        tempWord.shuffle()
        return String(tempWord)
    }

    /*
    * Creates and shows an AlertDialog with the final score.
    */
    private fun showFinalScoreDialog() {
        MaterialAlertDialogBuilder(requireContext())
            .setTitle(getString(R.string.congratulations))
            .setMessage(getString(R.string.you_scored, viewModel.score))
            .setCancelable(false)
            .setNegativeButton(getString(R.string.exit)) { _, _ ->
                exitGame()
            }
            .setPositiveButton(getString(R.string.play_again)) { _, _ ->
                restartGame()
            }
            .show()
    }

    /*
     * Re-initializes the data in the ViewModel and updates the views with the new data, to
     * restart the game.
     */
    private fun restartGame() {
        viewModel.reinitializeData()
        setErrorTextField(false)
        updateNextWordOnScreen()
    }

    /*
     * Exits the game.
     */
    private fun exitGame() {
        activity?.finish()
    }

    override fun onDetach() {
        super.onDetach()
        Log.d("GameFragment", "GameFragment destroyed!")
    }

    /*
    * Sets and resets the text field error status.
    */
    private fun setErrorTextField(error: Boolean) {
        if (error) {
            binding.textField.isErrorEnabled = true
            binding.textField.error = getString(R.string.try_again)
        } else {
            binding.textField.isErrorEnabled = false
            binding.textInputEditText.text = null
        }
    }

    /*
     * Displays the next scrambled word on screen.
     */
    private fun updateNextWordOnScreen() {
        binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
    }
}

GameViewModel.kt

import android.util.Log
import androidx.lifecycle.ViewModel

/**
 * ViewModel containing the app data and methods to process the data
 */
class GameViewModel : ViewModel(){
    private var _score = 0
    val score: Int
        get() = _score

    private var _currentWordCount = 0
    val currentWordCount: Int
        get() = _currentWordCount

    private lateinit var _currentScrambledWord: String
    val currentScrambledWord: String
        get() = _currentScrambledWord

    // List of words used in the game
    private var wordsList: MutableList<String> = mutableListOf()
    private lateinit var currentWord: String

    init {
        Log.d("GameFragment", "GameViewModel created!")
        getNextWord()
    }

    override fun onCleared() {
        super.onCleared()
        Log.d("GameFragment", "GameViewModel destroyed!")
    }

    /*
    * Updates currentWord and currentScrambledWord with the next word.
    */
    private fun getNextWord() {
        currentWord = allWordsList.random()
        val tempWord = currentWord.toCharArray()
        tempWord.shuffle()

        while (tempWord.toString().equals(currentWord, false)) {
            tempWord.shuffle()
        }
        if (wordsList.contains(currentWord)) {
            getNextWord()
        } else {
            _currentScrambledWord = String(tempWord)
            ++_currentWordCount
            wordsList.add(currentWord)
        }
    }

    /*
    * Re-initializes the game data to restart the game.
    */
    fun reinitializeData() {
       _score = 0
       _currentWordCount = 0
       wordsList.clear()
       getNextWord()
    }

    /*
    * Increases the game score if the player's word is correct.
    */
    private fun increaseScore() {
        _score += SCORE_INCREASE
    }

    /*
    * Returns true if the player word is correct.
    * Increases the score accordingly.
    */
    fun isUserWordCorrect(playerWord: String): Boolean {
        if (playerWord.equals(currentWord, true)) {
            increaseScore()
            return true
        }
        return false
    }

    /*
    * Returns true if the current word count is less than MAX_NO_OF_WORDS
    */
    fun nextWord(): Boolean {
        return if (_currentWordCount < MAX_NO_OF_WORDS) {
            getNextWord()
            true
        } else false
    }
}
  • Android アプリのアーキテクチャ ガイドラインでは、異なる役割を持つクラスに分割し、モデルで UI を操作することを推奨しています。
  • UI コントローラは、ActivityFragment などの UI ベースのクラスです。UI コントローラには、UI やオペレーティング システムとのやり取りを処理するロジックのみを含めるべきです。UI に表示するデータのソースにするべきではありません。そのようなデータと関連ロジックは ViewModel に入れてください。
  • ViewModel クラスは、UI 関連のデータを保存し管理します。ViewModel クラスを使用すると、画面の回転などの構成変更後もデータを維持することができます。
  • ViewModel は、推奨される Android アーキテクチャ コンポーネントの 1 つです。