Compose 中的 ViewModel 和狀態

1. 事前準備

在先前的程式碼研究室中,您已瞭解活動的生命週期以及設定變更的相關生命週期問題。發生設定變更時,您可以透過不同的方式儲存應用程式的資料,例如使用 rememberSaveable 或儲存執行個體狀態。不過,這些選項可能會造成問題。在多數情況下,您可以使用 rememberSaveable,但這可能表示要將邏輯保留在可組合函式之中或附近。隨著應用程式業務拓展,建議您將資料和邏輯移出可組合項。在本程式碼研究室中,您將瞭解如何利用 Android Jetpack 程式庫、ViewModel 和 Android 應用程式架構指南,在設定變更期間設計應用程式及保留應用程式資料。

Android Jetpack 程式庫是一系列的程式庫,可協助您以更輕鬆的方式開發優質 Android 應用程式。這些程式庫可協助您遵循最佳做法、無需編寫樣板程式碼並簡化複雜的工作,讓您可以專心處理應用程式邏輯等的重要程式碼。

「應用程式架構」是一組應用程式的設計規則,就像房屋的藍圖一樣,架構是應用程式的結構。良好的應用程式架構可讓程式碼在未來數年內保持穩定、具有彈性、可擴充、可測試且便於維護。如需應用程式架構和建議最佳做法,請參閱「應用程式架構指南」。

在本程式碼研究室中,您將瞭解如何使用 ViewModel,這是 Android Jetpack 程式庫的架構元件之一,可儲存您的應用程式資料。如果在設定變更或其他事件期間刪除架構並重新建立活動,儲存的資料不會遺失。不過,如果活動因程序終止而刪除,資料就會遺失。ViewModel 只會透過快速活動重新建立來快取資料。

必要條件

  • 對 Kotlin 的瞭解,包括函式、lambda 和無狀態可組合項。
  • 對如何在 Jetpack Compose 中建構版面配置有基本瞭解
  • 對 Material Design 有基本瞭解

課程內容

建構項目

  • Unscramble 遊戲應用程式,可讓使用者猜測打散的字詞

軟硬體需求

  • 最新版 Android Studio
  • 網際網路連線,可下載範例程式碼

2. 應用程式總覽

遊戲總覽

Unscramble 應用程式為字詞重組單人遊戲。應用程式會顯示打散的字詞,玩家必須利用所有顯示的字母正確猜出字詞。只要字詞正確無誤,玩家即可得分。如果猜錯,玩家可以重新再來一次,沒有次數限制。應用程式也有略過目前字詞的選項。應用程式右上角會顯示字詞計數,也就是目前遊戲中已猜過的打散字詞數。每場遊戲有 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」,輕觸按鈕也沒有任何反應。

在本程式碼研究室中,您將使用 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 程式碼在 Card 中有一個資料欄,內含三個子項元素:打散字詞文字、操作說明文字,以及供使用者輸入字詞的文字欄位 OutlinedTextField。目前,打散的字詞是硬式編碼為 scrambleun。稍後在程式碼研究室中,您將實作從 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 可組合函式類似於之前程式碼研究室中的應用程式 TextField 可組合函式。

文字欄位分為兩種類型:

  • 已填寫的文字欄位
  • 有外框的文字欄位

3df34220c3d177eb.png

相對於實心的文字欄位,有外框的文字欄位較不具有視覺強調效果。如果在表單等位置中顯示,而且又同時有多個文字欄位,減少的視覺強調效果有助於簡化版面配置。

在範例程式碼中,當使用者輸入猜測的字詞時,OutlinedTextField 不會更新。您將在程式碼研究室中更新此功能。

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))
    }
}

範例程式碼中並未實作按鈕點選事件。在本程式碼研究室中,您將實作這些事件。

FinalScoreDialog

FinalScoreDialog 可組合項會顯示對話方塊,也就是通知使用者並提供遊戲中「Play Again」或「Exit」選項的小型視窗。稍後在本程式碼研究室中,您將實作在遊戲結束時顯示此對話方塊的邏輯。

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 元素和應用程式元件無關,因此不受應用程式的生命週期和相關的關注點影響。

以上一節提到的一般架構原則為考量,每個應用程式至少應有兩層:

  • UI 層:可在畫面中顯示應用程式資料的層,而且獨立於資料之外。
  • 資料層:可儲存、擷取和公開應用程式資料的層。

您可以新增另一層,並將其命名為網域層,以簡化和重複使用 UI 和資料層之間的互動。此層並非必要,而且也不在本課程的範圍之內。

a4da6fa5c1c9fed5.png

UI 層

UI 層 (或展示層) 是用於在畫面中顯示應用程式資料。只要資料因使用者互動而變更 (例如按下按鈕),UI 就應更新以反映此變更。

UI 層是由以下元件組成:

  • UI 元件:在畫面中轉譯資料的元件。您可以使用 Jetpack Compose 建構這些元素。
  • 狀態持有物件:保留資料、向 UI 公開資料及處理應用程式邏輯的元件。ViewModel 就是狀態容器的例子。

6eaee5b38ec247ae.png

ViewModel

ViewModel 元件會保留並公開 UI 耗用的狀態。UI 狀態是由 ViewModel 轉換的應用程式資料。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

在此工作中,您要將 ViewModel 新增至應用程式,方便儲存遊戲 UI 狀態 (打散的字詞、字詞計數和分數)。如要解決上一節在範例程式碼中發現的問題,就需要在 ViewModel 中儲存遊戲資料。

  1. 開啟 build.gradle.kts (Module :app) 並捲動至 dependencies 區塊,為 ViewModel 新增下列依附元件。這個依附元件是用於在 Compose 應用程式中加入可感知生命週期的 ViewModel。
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 是資料持有物件的可觀察資料流,可發出目前和最新狀態更新。其 value 屬性可反映目前的狀態值。如要更新狀態並將狀態傳送至資料流程,請將新的值指派給 MutableStateFlow 類別的值屬性。

在 Android 中,StateFlow 很適合必須維持可觀察且不可變動狀態的類別。

您可以從 GameUiState 公開 StateFlow,這樣可組合函式就能監聽 UI 狀態更新,在設定變更時延續畫面狀態。

GameViewModel 類別中,新增下列 _uiState 屬性。

import kotlinx.coroutines.flow.MutableStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())

備份屬性

備份屬性可讓您從 getter 傳回確切物件以外的項目。

Kotlin 架構會為每個 var 屬性產生 getter 和 setter。

如為 getter 和 setter 方法,您可以覆寫其中一種或兩種方法,並提供您的自訂行為。如要實作備份屬性,請覆寫 getter 方法以傳回資料的唯讀版本。以下範例說明備份屬性:

//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 類別中:

  • 屬性 _countprivate 且可變動,因此只能在 ViewModel 類別中存取及編輯。

ViewModel 類別外:

  • Kotlin 中的預設瀏覽權限修飾符為 public,因此 count 是公開狀態,且可從 UI 控制器等其他類別存取。val 類型不得含有 setter。此資訊不可變動且處於唯讀狀態,因此您只能覆寫 get() 方法。外部類別存取這個屬性時,系統會傳回 _count 的值,且該值無法修改。此幕後屬性可保護 ViewModel 內的應用程式資料,避免外部類別做出非必要和不安全的變更,但可讓外部呼叫端以安全的方式存取其值。
  1. GameViewModel.kt 檔案中,將幕後屬性新增至名為 _uiStateuiState。將屬性命名為 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>
  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 中,新增類型為 StringcurrentWord 屬性,儲存目前的打散字詞。
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 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 組合中的所有字詞,初始化 _uiState。使用 pickRandomWordAndShuffle()currentScrambledWord 挑選新字詞。
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. init 區塊新增至 GameViewModel,然後從中呼叫 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,也就是從 GameViewModel 傳遞至 GameScreen.kt 檔案中的 GameScreen()。在 GameScreen() 中使用 ViewModel 例項,透過 collectAsState() 存取 uiState

collectAsState() 函式會從此 StateFlow 收集值,然後透過 State 表示最新的值。系統會使用 StateFlow.value 做為初始值。只要可能有新值發布至 StateFlow,傳回的 State 就會更新,導致每次 State.value 使用重新組合。

  1. GameScreen 函式中,傳遞類型為 GameViewModel 且含有預設值 viewModel() 的第二個引數。
import androidx.lifecycle.viewmodel.compose.viewModel

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

de93b81a92416c23.png

  1. GameScreen() 函式中新增名為 gameUiState 的新變數。請使用 byuiState 委派和呼叫 collectAsState()

此方法可確保只要 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. currentScrambledWord 新增為 GameLayout() 可組合函式中的另一個參數。
@Composable
fun GameLayout(
   currentScrambledWord: String,
   modifier: Modifier = Modifier
) {
}
  1. 更新 GameLayout() 可組合函式以顯示 currentScrambledWord。將欄中第一個文字欄位的 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 的事件回呼之一。資料 gameViewModel.userGuess 會從 ViewModel 向下流至 GameScreen

在事件回呼鍵盤按下按鍵後,使用者猜測變更會從 UI 傳遞至檢視模型

  1. 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() }
   ),
  1. GameLayout() 可組合函式中,再新增兩個引數:使用 String 引數且不會傳回任何內容的 onUserGuessChanged lambda,以及不使用也不會傳回任何內容的 onKeyboardDone
@Composable
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
   ) {
}
  1. GameLayout() 函式呼叫中,新增 onUserGuessChangedonKeyboardDone 的 lambda 引數。
GameLayout(
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   currentScrambledWord = gameUiState.currentScrambledWord,
)

您很快就會在 GameViewModel 中定義 updateUserGuess 方法。

  1. GameViewModel.kt 檔案中,新增使用 String 引數 (使用者的猜測字詞) 且名為 updateUserGuess() 的方法。在函式中,使用 guessedWord 中傳遞的內容更新 userGuess
  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() 中,新增另一個 userGuessString 參數。將 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() 函式中新增 if else 區塊,驗證使用者猜測的字詞是否與 currentWord 相同。將 userGuess 重設為空白字串。
fun checkUserGuess() {

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
   }
   // Reset user guess
   updateUserGuess("")
}
  1. 如果使用者猜錯,則將 isGuessedWordWrong 設為 trueMutableStateFlow<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 類別中,新增名為 isGuessedWordWrongBoolean,並將其初始化為 false
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
)

接著,在使用者點選「Submit」按鈕或按下鍵盤中的完成鍵時,從 GameScreen 向上傳遞事件回呼 checkUserGuess()ViewModel。從 ViewModel 向下傳遞資料 gameUiState.isGuessedWordWrongGameScreen,即可設定文字欄位中的錯誤。

7f05d04164aa4646.png

  1. GameScreen.kt 檔案中 GameScreen() 可組合函式的結尾處,從「Submit」按鈕的 onClick lambda 運算式中呼叫 gameViewModel.checkUserGuess()
Button(
   modifier = modifier
       .fillMaxWidth()
       .weight(1f)
       .padding(start = 8.dp),
   onClick = { gameViewModel.checkUserGuess() }
) {
   Text(stringResource(R.string.submit))
}
  1. GameScreen() 可組合函式中,更新 GameLayout() 函式呼叫以在 onKeyboardDone lambda 運算式中傳遞 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 檔案中選擇新字詞。新增名為 updatedScoreInt 做為參數。請更新遊戲狀態 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 lambda 運算式呼叫 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. GameViewModel 中,新增 if-else 區塊,並將現有的函式主體移至 else 區塊。
  2. 新增 if 條件以檢查 usedWords 大小是否等於 MAX_NO_OF_WORDS
import com.example.android.unscramble.data.MAX_NO_OF_WORDS

private fun updateGameState(updatedScore: Int) {
   if (usedWords.size == MAX_NO_OF_WORDS){
       //Last round in the game
   } else{
       // Normal round in the game
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               currentScrambledWord = pickRandomWordAndShuffle(),
               currentWordCount = currentState.currentWordCount.inc(),
               score = updatedScore
           )
       }
   }
}
  1. if 區塊中新增 Boolean 標記 isGameOver,然後將標記設為 true,表示遊戲已結束。
  2. 更新 score,然後重設 if 區塊中的 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 提供不同類型的對話方塊。在本程式碼研究室中,您將瞭解「快訊對話方塊」。

快訊對話方塊圖解

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))
           }
       }
   )
}

在此函式中,titletext 參數會在快訊對話方塊中顯示標題和說明文字。dismissButtonconfirmButton 是文字按鈕。在 dismissButton 參數中,顯示「退出」文字,然後完成活動以終止應用程式。在 confirmButton 參數中,重新啟動遊戲並顯示「Play Again」文字。

a24f59b84a178d9b.png

  1. GameScreen.kt 檔案的 FinalScoreDialog() 函式中,請留意用來在快訊對話方塊中顯示遊戲分數的參數。
@Composable
private fun FinalScoreDialog(
   score: Int,
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
  1. FinalScoreDialog() 函式中,請注意 text 參數 lambda 運算式的用法,使用 score 做為對話方塊文字的格式引數。
text = { Text(stringResource(R.string.you_scored, score)) }
  1. GameScreen.kt 檔案中,於 GameScreen() 可組合函式結尾處的 Column 區塊後方,加入 if 條件以檢查 gameUiState.isGameOver
  2. if 區塊中,顯示快訊對話方塊。呼叫 scoregameViewModel.resetGame() 中傳遞的 FinalScoreDialog() 以取得 onPlayAgain 事件回呼。
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. 進行遊戲直到遊戲結束,然後觀察快訊對話方塊以及「退出」遊戲或「再玩一次」的選項。請嘗試使用快訊對話方塊中顯示的選項。

c6727347fe0db265.png

10. 裝置旋轉狀態

在先前的程式碼研究室中,您已瞭解 Android 中的設定變更。發生設定變更時,Android 會從頭開始重新啟動活動,並執行所有生命週期起始回呼。

ViewModel 會儲存 Android 架構刪除並重新建立活動時沒有刪除的應用程式相關資料。系統會自動保留 ViewModel 物件,且這些物件在設定變更期間不會像活動執行個體一樣遭到刪除。重組後保留的資料會立即提供使用。

在這項工作中,您要檢查應用程式是否在設定變更期間保留狀態 UI。

  1. 執行應用程式並玩幾個字詞。將裝置的設定從直向變更為橫向,或從橫向變更為直向。
  2. 您可以發現,ViewModel 狀態 UI 中儲存的資料在設定變更期間會保留下來。

4a63084643723724.png

4134470d435581dd.png

11. 取得解決方案程式碼

完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用這些 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 中開啟。

如要查看本程式碼研究室的解決方案程式碼,請前往 GitHub 查看。

12. 結語

恭喜!您已完成程式碼研究室。現在您已經瞭解 Android 應用程式架構指南建議將具有不同責任的類別分離,並透過模型使用 UI。

記得使用 #AndroidBasics,在社群媒體分享您的作品!

瞭解詳情