ViewModel và Trạng thái trong Compose

1. Trước khi bắt đầu

Trong các lớp học lập trình trước, bạn đã tìm hiểu về vòng đời của các hoạt động và vấn đề liên quan đến vòng đời với các thay đổi về cấu hình. Khi cấu hình thay đổi, bạn có thể lưu dữ liệu của ứng dụng bằng nhiều cách, chẳng hạn như sử dụng rememberSaveable hoặc lưu trạng thái của thực thể. Tuy nhiên, những tuỳ chọn này có thể gây ra sự cố. Trong hầu hết các trường hợp, bạn có thể sử dụng rememberSaveable, nhưng điều đó có nghĩa là giữ logic trong hoặc gần các thành phần kết hợp. Khi các ứng dụng phát triển, bạn nên di chuyển dữ liệu và logic khỏi các thành phần kết hợp. Trong lớp học lập trình này, bạn sẽ tìm hiểu về cách hiệu quả để thiết kế ứng dụng và lưu trữ dữ liệu ứng dụng trong quá trình thay đổi cấu hình bằng việc tận dụng thư viện Android Jetpack, ViewModel và các nguyên tắc về cấu trúc ứng dụng Android.

Thư viện Android Jetpack là tập hợp các thư viện giúp bạn phát triển những ứng dụng Android tuyệt vời một cách dễ dàng hơn. Các thư viện này giúp bạn thực hiện theo những phương pháp hay nhất, tránh phải viết mã nguyên mẫu và đơn giản hoá các nhiệm vụ phức tạp để bạn có thể tập trung vào mã mà bạn quan tâm, chẳng hạn như logic ứng dụng.

Cấu trúc ứng dụng là một bộ quy tắc thiết kế dành cho ứng dụng. Giống như bản thiết kế của một ngôi nhà, cấu trúc sẽ tạo nên kết cấu cho ứng dụng. Một cấu trúc ứng dụng tốt có thể giúp mã hoạt động hiệu quả, linh hoạt, có thể mở rộng, có thể kiểm thử và duy trì trong nhiều năm tới. Hướng dẫn về cấu trúc ứng dụng đưa ra nội dung đề xuất đối với cấu trúc ứng dụng và các phương pháp hay nhất nên áp dụng.

Trong lớp học lập trình này, bạn sẽ tìm hiểu cách sử dụng ViewModel. Đây là một trong những thành phần cấu trúc của thư viện Android Jetpack có thể lưu trữ dữ liệu ứng dụng của bạn. Dữ liệu đã lưu trữ sẽ không bị mất nếu khung này huỷ rồi tạo lại hoạt động trong quá trình thay đổi cấu hình hoặc các sự kiện khác. Tuy nhiên, dữ liệu sẽ bị mất nếu hoạt động bị huỷ bỏ trong tình huống bị buộc tắt. ViewModel chỉ lưu dữ liệu vào bộ nhớ đệm thông qua các hoạt động tái tạo nhanh.

Điều kiện tiên quyết

  • Kiến thức về Kotlin, bao gồm các hàm, hàm lambda và các thành phần kết hợp không có trạng thái
  • Kiến thức cơ bản về cách xây dựng bố cục trong Jetpack Compose
  • Kiến thức cơ bản về Material Design.

Kiến thức bạn sẽ học được

  • Giới thiệu về cấu trúc ứng dụng Android
  • Cách sử dụng lớp ViewModel trong ứng dụng.
  • Cách sử dụng ViewModel để lưu giữ dữ liệu trên giao diện người dùng trong quá trình thay đổi cấu hình thiết bị.

Sản phẩm bạn sẽ tạo ra

  • Ứng dụng trò chơi Unscramble (Xếp từ) mà người dùng có thể đoán các từ được xáo trộn.

Những gì bạn cần

  • Phiên bản mới nhất của Android Studio
  • Kết nối Internet để tải mã khởi đầu xuống.

2. Tổng quan về ứng dụng

Tổng quan về trò chơi

Ứng dụng Unscramble là trò chơi xáo trộn từ một người chơi. Ứng dụng sẽ hiển thị một từ được xáo trộn và người chơi phải đoán từ đó bằng tất cả các chữ cái hiện lên. Người chơi giành được điểm nếu từ chính xác. Nếu không, người chơi có thể thử nhiều lần. Ứng dụng cũng cho phép bạn chọn bỏ qua từ hiện tại. Ở góc trên cùng bên phải, ứng dụng cho thấy số từ bị xáo trộn đã chơi trong ván hiện tại. Có 10 từ bị xáo trộn trong mỗi ván.

Tải mã nguồn ban đầu

Để bắt đầu, hãy tải mã khởi đầu xuống:

Ngoài ra, bạn có thể sao chép kho lưu trữ GitHub cho mã:

$ 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

Bạn có thể duyệt tìm mã khởi đầu trong kho lưu trữ GitHub Unscramble.

3. Tổng quan về ứng dụng khởi động

Để làm quen với mã khởi đầu, bạn hãy hoàn thành các bước sau:

  1. Mở dự án bằng mã khởi đầu trong Android Studio.
  2. Chạy ứng dụng trên thiết bị Android hoặc trên trình mô phỏng.
  3. Nhấn vào nút Submit (Gửi) và Skip (Bỏ qua) để kiểm thử ứng dụng.

Bạn sẽ nhận thấy có lỗi trong ứng dụng. Từ bị xáo trộn không hiển thị nhưng được cố định giá trị trong mã thành "scrambleun" và không có gì xảy ra khi bạn nhấn vào các nút.

Trong lớp học lập trình này, bạn sẽ triển khai chức năng của trò chơi bằng cách dùng cấu trúc ứng dụng Android.

Tìm hiểu mã khởi động

Mã khởi đầu có bố cục màn hình trò chơi được thiết kế sẵn cho bạn. Trong lộ trình này, bạn sẽ triển khai logic trò chơi. Bạn sẽ dùng các thành phần cấu trúc để triển khai cấu trúc ứng dụng được đề xuất và giải quyết những vấn đề nêu trên. Dưới đây là hướng dẫn từng bước ngắn gọn về một số tệp để bạn bắt đầu.

WordsData.kt

Tệp này chứa danh sách các từ được dùng trong trò chơi, hằng số cho số từ tối đa trong mỗi trò chơi và số điểm mà người chơi giành được cho mỗi từ chính xác.

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

Tệp này chủ yếu chứa mã tạo mẫu. Bạn sẽ hiển thị thành phần kết hợp GameScreen trong khối setContent{}.

GameScreen.kt

Mọi thành phần kết hợp giao diện người dùng đều được xác định trong tệp GameScreen.kt. Các phần sau đây cung cấp hướng dẫn từng bước về một số hàm có khả năng kết hợp.

GameStatus

GameStatus là một hàm có khả năng kết hợp cho thấy điểm số trò chơi ở cuối màn hình. Hàm có khả năng kết hợp chứa một thành phần kết hợp văn bản trong Card. Hiện tại, điểm số được mã hoá cứng thành 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 là một hàm có khả năng kết hợp, hiển thị chức năng chính của trò chơi, bao gồm từ bị xáo trộn, hướng dẫn chơi và một trường văn bản nhận các lượt đoán của người dùng.

b6ddb1f07f10df0c.png

Lưu ý rằng đoạn mã GameLayout bên dưới chứa một cột bên trong một Card có 3 phần tử con gồm văn bản từ bị xáo trộn, văn bản hướng dẫn và trường văn bản OutlinedTextField cho người dùng nhập từ. Hiện tại, từ bị xáo trộn được mã hoá cứng thành scrambleun. Trong phần sau của lớp học lập trình này, bạn sẽ triển khai chức năng để hiển thị một từ trong tệp 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 = { }
               )
           )
       }
   }
}

Thành phần kết hợp OutlinedTextField tương tự như thành phần kết hợp TextField từ các ứng dụng trong các lớp học lập trình trước.

Có hai loại trường văn bản:

  • Các trường văn bản được tô màu nền
  • Trường văn bản có đường viền

3df34220c3d177eb.png

Các trường văn bản có đường viền ít nhấn mạnh vào hình ảnh hơn là các trường văn bản được tô màu nền. Khi xuất hiện ở những vị trí như biểu mẫu, nơi nhiều trường văn bản được đặt cùng nhau, việc giảm mức độ nhấn mạnh của các trường này sẽ giúp đơn giản hoá bố cục.

Trong mã khởi đầu, OutlinedTextField không cập nhật khi người dùng nhập vào một dự đoán. Bạn sẽ cập nhật tính năng này trong lớp học lập trình.

GameScreen

Thành phần kết hợp GameScreen chứa các hàm có khả năng kết hợp GameStatusGameLayout, tiêu đề trò chơi, số từ và các thành phần kết hợp dành cho nút GửiBỏ qua.

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

Sự kiện nhấp chuột vào nút sẽ không được triển khai trong mã khởi động. Bạn sẽ triển khai các sự kiện này trong lớp học lập trình.

FinalScoreDialog

Thành phần kết hợp FinalScoreDialog sẽ hiển thị một hộp thoại (là một cửa sổ nhỏ để nhắc người dùng) với các lựa chọn Play Again (Chơi lại) hoặc Exit (Thoát) trò chơi. Trong phần sau của lớp học lập trình này, bạn sẽ triển khai logic để hiển thị hộp thoại này ở cuối trò chơi.

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. Tìm hiểu về cấu trúc ứng dụng

Cấu trúc ứng dụng cung cấp các nguyên tắc giúp bạn phân bổ trách nhiệm của ứng dụng giữa các lớp. Một cấu trúc ứng dụng được thiết kế hợp lý sẽ giúp bạn điều chỉnh quy mô ứng dụng và mở rộng ứng dụng với các tính năng bổ sung. Cấu trúc này cũng có thể đơn giản hoá việc cộng tác trong nhóm.

Nguyên tắc cấu trúc phổ biến nhất là tách biệt vấn đềđiều khiển giao diện người dùng bằng mô hình.

Tách biệt vấn đề

Nguyên tắc thiết kế tách biệt vấn đề nêu rõ rằng bạn phải chia ứng dụng thành các lớp hàm, mỗi lớp có những trách nhiệm riêng.

Điều khiển giao diện người dùng bằng mô hình

Giao diện người dùng điều khiển trong nguyên tắc mô hình cho biết rằng bạn nên điều khiển giao diện người dùng bằng mô hình, tốt hơn hết là một mô hình liên tục. Mô hình là thành phần chịu trách nhiệm xử lý dữ liệu cho ứng dụng. Mô hình độc lập với các phần tử trên giao diện người dùng và các thành phần ứng dụng trong ứng dụng của bạn, vậy nên vòng đời của ứng dụng và những vấn đề liên quan sẽ không ảnh hưởng tới mô hình.

Theo các nguyên tắc cấu trúc phổ biến đã đề cập trong phần trước, mỗi ứng dụng phải có ít nhất 2 lớp:

  • Lớp giao diện người dùng: là một lớp hiển thị dữ liệu ứng dụng trên màn hình nhưng độc lập với dữ liệu.
  • Lớp dữ liệu: là một lớp (layer) lưu trữ, truy xuất và hiển thị dữ liệu ứng dụng.

Bạn có thể thêm một lớp khác có tên là lớp miền để đơn giản hoá cũng như sử dụng lại các lượt tương tác giữa giao diện người dùng và các lớp dữ liệu. Lớp này là lớp không bắt buộc và nằm ngoài phạm vi của khoá học này.

a4da6fa5c1c9fed5.png

Lớp giao diện người dùng

Vai trò của lớp giao diện người dùng hoặc tầng trình diễn là hiển thị dữ liệu ứng dụng trên màn hình. Bất cứ khi nào dữ liệu thay đổi do một hoạt động tương tác của người dùng, chẳng hạn như nhấn vào một nút, giao diện người dùng sẽ cập nhật để phản ánh các thay đổi đó.

Lớp giao diện người dùng gồm các thành phần sau:

  • Các thành phần trên giao diện người dùng: kết xuất (hiển thị) dữ liệu trên màn hình. Bạn tạo các phần tử này bằng cách sử dụng Jetpack Compose.
  • Chủ thể trạng thái: thành phần chứa dữ liệu, hiển thị thông tin dữ liệu này tới giao diện người dùng và xử lý logic của ứng dụng. Một ví dụ về chủ thể trạng thái có thể kể đến là ViewModel.

6eaee5b38ec247ae.png

ViewModel

Thành phần ViewModel giữ và hiển thị trạng thái mà giao diện người dùng sử dụng. Trạng thái giao diện người dùng là dữ liệu ứng dụng do ViewModel chuyển đổi. ViewModel cho phép ứng dụng của bạn tuân theo nguyên tắc cấu trúc điều khiển giao diện người dùng bằng mô hình.

ViewModel lưu trữ dữ liệu liên quan đến ứng dụng không bị huỷ bỏ khi khung Android huỷ và tạo lại hoạt động. Không giống như thực thể hoạt động, các đối tượng ViewModel sẽ không bị huỷ. Ứng dụng sẽ tự động giữ lại các đối tượng ViewModel trong quá trình thay đổi cấu hình. Nhờ vậy, dữ liệu mà các đối tượng này lưu giữ sẽ có sẵn ngay sau khi quá trình kết hợp lại diễn ra.

Để triển khai ViewModel trong ứng dụng, hãy mở rộng lớp ViewModel (lấy từ thư viện thành phần cấu trúc) rồi lưu trữ dữ liệu ứng dụng trong lớp đó.

Trạng thái giao diện người dùng

Giao diện người dùng là nội dung mà người dùng nhìn thấy, còn trạng thái giao diện người dùng là nội dung mà ứng dụng chỉ định để người dùng nhìn thấy. Giao diện người dùng cho biết trạng thái giao diện người dùng một cách trực quan. Mọi thay đổi đối với trạng thái giao diện người dùng đều được phản ánh ngay lập tức trong giao diện người dùng.

9cfedef1750ddd2c.png

Giao diện người dùng là kết quả của việc liên kết các thành phần thuộc giao diện người dùng trên màn hình với trạng thái giao diện người dùng.

// Example of UI state definition, do not copy over

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

Bất biến

Định nghĩa về trạng thái giao diện người dùng trong ví dụ ở trên là bất biến. Các đối tượng bất biến đảm bảo việc nhiều nguồn không làm thay đổi trạng thái ứng dụng ngay lập tức. Biện pháp bảo vệ này giải phóng giao diện người dùng để tập trung vào một vai trò duy nhất: đọc trạng thái và cập nhật các phần tử trên giao diện người dùng cho phù hợp. Do đó, bạn hoàn toàn không nên trực tiếp sửa đổi trạng thái giao diện người dùng trong giao diện người dùng, trừ phi giao diện người dùng đó là nguồn duy nhất của dữ liệu. Việc vi phạm nguyên tắc này sẽ dẫn đến nhiều nguồn thông tin chính xác cho cùng một phần thông tin, dẫn đến những điểm thiếu đồng nhất của dữ liệu và các lỗi nhỏ.

5. Thêm ViewModel

Trong nhiệm vụ này, bạn thêm ViewModel vào ứng dụng để lưu trữ trạng thái giao diện người dùng của trò chơi (từ được xáo trộn, số từ và điểm). Để giải quyết vấn đề trong mã khởi đầu mà bạn nhận thấy ở phần trước, bạn cần lưu dữ liệu trò chơi trong ViewModel.

  1. Mở build.gradle.kts (Module :app), di chuyển đến khối dependencies rồi thêm phần phụ thuộc sau cho ViewModel. Phần phụ thuộc này dùng để thêm ViewModel nhận biết vòng đời vào ứng dụng Compose.
dependencies {
// other dependencies

    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
//...
}
  1. Trong gói ui, hãy tạo một lớp/tệp Kotlin có tên là GameViewModel. Mở rộng lớp này từ lớp ViewModel.
import androidx.lifecycle.ViewModel

class GameViewModel : ViewModel() {
}
  1. Trong gói ui, thêm một lớp mô hình cho giao diện người dùng trạng thái có tên là GameUiState. Biến lớp này thành lớp dữ liệu rồi thêm biến cho từ bị xáo trộn hiện tại.
data class GameUiState(
   val currentScrambledWord: String = ""
)

StateFlow

StateFlow là một luồng có thể quan sát của lớp chứa dữ liệu, phát ra các thông tin cập nhật hiện tại và trạng thái mới. Thuộc tính value của giá trị đó phản ánh giá trị trạng thái hiện tại. Để cập nhật trạng thái và gửi trạng thái này đến luồng, hãy gán một giá trị mới cho thuộc tính giá trị của lớp MutableStateFlow.

Trong Android, StateFlow hoạt động hiệu quả đối với các lớp phải duy trì trạng thái bất biến có thể ghi nhận được.

StateFlow có thể được cung cấp từ GameUiState để các thành phần kết hợp có thể theo dõi thông tin cập nhật trạng thái giao diện người dùng, đồng thời giúp duy trì trạng thái màn hình sau khi cấu hình thay đổi.

Trong lớp GameViewModel, hãy thêm thuộc tính _uiState sau.

import kotlinx.coroutines.flow.MutableStateFlow

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

Thuộc tính sao lưu

Thuộc tính sao lưu cho phép bạn trả về nội dung qua một phương thức getter khác với đối tượng chính xác.

Đối với thuộc tính var, khung Kotlin sẽ tạo phương thức getter và setter.

Đối với phương thức getter và setter, bạn có thể ghi đè lên một hoặc cả hai phương pháp này rồi cung cấp hành vi tuỳ chỉnh của riêng mình. Để triển khai thuộc tính sao lưu, bạn sẽ ghi đè phương thức getter để trả về phiên bản dữ liệu chỉ có thể đọc. Ví dụ sau đây cho thấy một thuộc tính sao lưu:

//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

Một ví dụ khác là giả sử bạn muốn dữ liệu ứng dụng ở chế độ riêng tư trong ViewModel:

Bên trong lớp ViewModel:

  • Thuộc tính _countprivate và có thể biến đổi. Do đó, bạn chỉ có thể truy cập và chỉnh sửa tệp này trong lớp ViewModel.

Bên ngoài lớp ViewModel:

  • Đối tượng sửa đổi chế độ hiển thị mặc định trong Kotlin là public, vậy nên count là công khai và có thể truy cập được qua các lớp khác, chẳng hạn như bộ điều khiển giao diện người dùng. Kiểu val không thể có phương thức setter. Kiểu này không thể thay đổi và chỉ có thể đọc, vì vậy, bạn chỉ có thể ghi đè phương thức get(). Khi một lớp bên ngoài truy cập thuộc tính này, thuộc tính sẽ trả về giá trị của _count và giá trị đó không thể sửa đổi. Thuộc tính sao lưu này giúp bảo vệ dữ liệu ứng dụng trong ViewModel khỏi những thay đổi ngoài ý muốn và không an toàn do các lớp bên ngoài thực hiện, nhưng cho phép phương thức gọi bên ngoài truy cập an toàn vào giá trị của nó.
  1. Trong tệp GameViewModel.kt, hãy thêm thuộc tính sao lưu vào uiState có tên là _uiState. Đặt tên thuộc tính là uiState, thuộc tính này thuộc kiểu StateFlow<GameUiState>.

Hiện tại, bạn chỉ có thể truy cập và chỉnh sửa _uiState trong GameViewModel. Giao diện người dùng có thể đọc giá trị đó bằng cách sử dụng thuộc tính chỉ có thể đọc là uiState. Bạn có thể khắc phục lỗi khởi động trong bước tiếp theo.

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. Đặt uiState thành _uiState.asStateFlow().

asStateFlow() làm cho luồng trạng thái có thể thay đổi này trở thành luồng trạng thái chỉ đọc.

import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()

Hiện từ được xáo trộn một cách ngẫu nhiên

Trong nhiệm vụ này, bạn sẽ thêm các phương thức trợ giúp để chọn một từ ngẫu nhiên trong WordsData.kt và xáo trộn từ đó.

  1. Trong GameViewModel, hãy thêm thuộc tính có tên là currentWord thuộc loại String để lưu từ bị xáo trộn hiện tại.
private lateinit var currentWord: String
  1. Thêm phương thức trợ giúp để chọn một từ ngẫu nhiên trong danh sách và xáo trộn từ đó. Đặt tên cho phương thức đó là pickRandomWordAndShuffle() mà không có tham số đầu vào và trả về một giá trị 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 sẽ gắn cờ thông báo lỗi hàm và biến không xác định.

  1. Trong GameViewModel, hãy thêm thuộc tính dưới đây vào sau thuộc tính currentWord để đóng vai trò như một tập hợp có thể thay đổi để lưu trữ các từ đã dùng trong trò chơi.
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
  1. Thêm một phương thức trợ giúp khác có tên là shuffleCurrentWord() để xáo trộn từ hiện tại. Phương thức này sẽ nhận String và trả về String đã xáo trộn.
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. Thêm một hàm trợ giúp có tên là resetGame() để khởi động trò chơi. Bạn sẽ sử dụng hàm này sau để bắt đầu và khởi động lại trò chơi. Trong hàm này, xoá tất cả các từ trong tập hợp usedWords và bắt đầu _uiState. Chọn một từ mới cho currentScrambledWord bằng pickRandomWordAndShuffle().
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. Thêm một khối init vào GameViewModel và gọi resetGame() qua khối đó.
init {
   resetGame()
}

Khi tạo ứng dụng ngay, bạn vẫn không thấy sự thay đổi nào trong giao diện người dùng. Bạn không truyền dữ liệu từ ViewModel sang các thành phần kết hợp trong GameScreen.

6. Thiết kế giao diện người dùng trong Compose

Trong Compose, cách duy nhất để cập nhật giao diện người dùng là thay đổi trạng thái ứng dụng. Bạn chỉ có thể kiểm soát trạng thái giao diện người dùng. Mỗi khi trạng thái giao diện người dùng thay đổi, Compose sẽ tạo lại các phần đã thay đổi của cây giao diện người dùng. Các thành phần kết hợp có thể chấp nhận trạng thái và hiển thị sự kiện. Ví dụ: TextField/OutlinedTextField chấp nhận một giá trị và hiển thị một lệnh gọi lại onValueChange yêu cầu trình xử lý gọi lại thay đổi giá trị đó.

//Example code no need to copy over

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Vì các thành phần kết hợp chấp nhận trạng thái và hiển thị sự kiện nên mẫu luồng dữ liệu một chiều rất phù hợp với Jetpack Compose. Phần này tập trung vào cách triển khai mẫu luồng dữ liệu một chiều trong Compose, cách triển khai các sự kiện và phần tử giữ trạng thái, cũng như cách làm việc với ViewModel trong Compose.

Luồng dữ liệu một chiều

Luồng dữ liệu một chiều (UDF) là một mẫu thiết kế trong đó trạng thái chạy xuống và các sự kiện chạy lên. Bằng cách làm theo luồng dữ liệu một chiều, bạn có thể phân tách các thành phần có thể kết hợp hiển thị trạng thái trong giao diện người dùng khỏi các phần của ứng dụng lưu trữ rồi thay đổi trạng thái.

Vòng lặp cập nhật giao diện người dùng cho một ứng dụng sử dụng luồng dữ liệu một chiều như sau:

  • Sự kiện: Một phần của giao diện người dùng tạo sự kiện và truyền sự kiện lên trên (chẳng hạn như lượt nhấp vào nút được chuyển đến ViewModel để xử lý) hoặc một sự kiện được truyền từ các lớp khác trong ứng dụng, chẳng hạn như cho biết phiên người dùng đã hết hạn.
  • Trạng thái cập nhật: Trình xử lý sự kiện có thể thay đổi trạng thái.
  • Trạng thái hiển thị: Phần tử giữ trạng thái chuyển xuống trạng thái và giao diện người dùng sẽ hiển thị trạng thái này.

61eb7bcdcff42227.png

Việc sử dụng mẫu UDF cho cấu trúc ứng dụng có các tác động sau:

  • ViewModel giữ và hiển thị trạng thái mà giao diện người dùng sử dụng.
  • Trạng thái giao diện người dùng là dữ liệu ứng dụng do ViewModel chuyển đổi.
  • Giao diện người dùng thông báo cho ViewModel về các sự kiện của người dùng.
  • ViewModel xử lý các thao tác của người dùng và cập nhật trạng thái.
  • Trạng thái đã cập nhật được đưa trở lại giao diện người dùng để hiển thị.
  • Quá trình này lặp lại cho bất kỳ sự kiện nào gây ra hiện tượng đột biến trạng thái.

Truyền dữ liệu

Truyền thực thể của ViewModel cho giao diện người dùng – tức là từ GameViewModel đến GameScreen() trong tệp GameScreen.kt. Trong GameScreen(), hãy sử dụng thực thể ViewModel để truy cập vào uiState bằng collectAsState().

Hàm collectAsState() thu thập các giá trị từ StateFlow này và biểu thị giá trị mới nhất của hàm này qua State. StateFlow.value được dùng làm giá trị ban đầu. Mỗi khi có một giá trị mới được đăng vào StateFlow, State được trả về sẽ cập nhật, dẫn đến việc kết hợp lại mọi hoạt động sử dụng State.value.

  1. Trong hàm GameScreen, hãy truyền một đối số thứ hai thuộc loại GameViewModel với giá trị mặc định của viewModel().
import androidx.lifecycle.viewmodel.compose.viewModel

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

de93b81a92416c23.png

  1. Trong hàm GameScreen(), thêm một biến mới có tên là gameUiState. Sử dụng đối tượng uỷ quyền by và gọi collectAsState() trên uiState.

Cách tiếp cận này đảm bảo rằng mỗi khi có sự thay đổi trong giá trị uiState, quá trình kết hợp lại sẽ diễn ra đối với các thành phần kết hợp sử dụng giá trị gameUiState.

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

@Composable
fun GameScreen(
   // ...
) {
   val gameUiState by gameViewModel.uiState.collectAsState()
   // ...
}
  1. Truyền gameUiState.currentScrambledWord vào thành phần kết hợp GameLayout(). Bạn sẽ thêm đối số ở bước sau, vậy nên giờ hãy bỏ qua lỗi này.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   modifier = Modifier
       .fillMaxWidth()
       .wrapContentHeight()
       .padding(mediumPadding)
)
  1. Thêm currentScrambledWord làm một tham số khác vào hàm có khả năng kết hợp GameLayout().
@Composable
fun GameLayout(
   currentScrambledWord: String,
   modifier: Modifier = Modifier
) {
}
  1. Cập nhật hàm có khả năng kết hợp GameLayout() để hiển thị currentScrambledWord. Đặt tham số text của trường văn bản đầu tiên trong cột thành currentScrambledWord.
@Composable
fun GameLayout(
   // ...
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       Text(
           text = currentScrambledWord,
           fontSize = 45.sp,
           modifier = modifier.align(Alignment.CenterHorizontally)
       )
    //...
    }
}
  1. Chạy và tạo bản dựng ứng dụng. Bạn sẽ thấy từ được xáo trộn.

6d93a8e1ba5dad6f.png

Hiện từ đoán

Trong thành phần kết hợp GameLayout(), việc cập nhật từ đoán của người dùng là một trong các lệnh gọi lại sự kiện chạy từ GameScreen đến ViewModel. Dữ liệu gameViewModel.userGuess sẽ lưu chuyển từ ViewModel xuống GameScreen.

bàn phím gọi lại sự kiện đã hoàn tất thao tác nhấn phím và người dùng đoán các thay đổi được truyền từ giao diện người dùng sang mô hình khung hiển thị

  1. Trong tệp GameScreen.kt, trong thành phần kết hợp GameLayout(), hãy đặt onValueChange thành onUserGuessChangedonKeyboardDone() thành thao tác bàn phím onDone. Bạn sẽ sửa lỗi trong bước tiếp theo.
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. Trong hàm có khả năng kết hợp GameLayout(), thêm 2 đối số khác: hàm lambda onUserGuessChanged nhận đối số String và không trả về giá trị nào, còn onKeyboardDone không nhận và cũng không trả về giá trị nào.
@Composable
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
   ) {
}
  1. Trong lệnh gọi hàm GameLayout(), hãy thêm các đối số lambda cho onUserGuessChangedonKeyboardDone.
GameLayout(
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   currentScrambledWord = gameUiState.currentScrambledWord,
)

Bạn sẽ sớm xác định được phương thức updateUserGuess trong GameViewModel.

  1. Trong tệp GameViewModel.kt, hãy thêm một phương thức có tên là updateUserGuess(). Phương thức này sẽ nhận đối số String là từ đoán của người dùng. Bên trong hàm này, hãy cập nhật userGuess với giá trị được truyền vào là guessedWord.
  fun updateUserGuess(guessedWord: String){
     userGuess = guessedWord
  }

Tiếp theo, bạn sẽ thêm userGuess vào ViewModel.

  1. Trong tệp GameViewModel.kt, hãy thêm thuộc tính biến có tên là userGuess. Hãy sử dụng mutableStateOf() để Compose có thể quan sát giá trị này và đặt giá trị ban đầu thành "".
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

var userGuess by mutableStateOf("")
   private set
  1. Trong tệp GameScreen.kt bên trong GameLayout(), hãy thêm một tham số String khác cho userGuess. Đặt tham số value của OutlinedTextField thành 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. Trong hàm GameScreen, hãy cập nhật lệnh gọi hàm GameLayout() để thêm tham số userGuess.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   //...
)
  1. Tạo và chạy ứng dụng của bạn.
  2. Hãy thử đoán và nhập một từ. Trường văn bản có thể hiển thị từ mà người dùng đoán.

ed10c7f522495a.png

7. Xác minh từ dự đoán và cập nhật điểm

Trong nhiệm vụ này, bạn sẽ triển khai một phương thức để xác minh từ mà người dùng đoán và sau đó cập nhật điểm số trò chơi hoặc hiển thị lỗi. Bạn sẽ cập nhật điểm số mới và từ mới sau trên giao diện người dùng trạng thái trò chơi.

  1. Trong GameViewModel, thêm một phương thức khác có tên là checkUserGuess().
  2. Trong hàm checkUserGuess(), hãy thêm một khối if else để xác minh xem suy đoán của người dùng có giống với currentWord hay không. Đặt lại userGuess thành chuỗi trống.
fun checkUserGuess() {

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
   }
   // Reset user guess
   updateUserGuess("")
}
  1. Nếu người dùng đoán đúng, hãy đặt isGuessedWordWrong thành true. MutableStateFlow<T>. update() cập nhật MutableStateFlow.value bằng cách sử dụng giá trị được chỉ định.
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. Trong lớp GameUiState, hãy thêm Boolean có tên là isGuessedWordWrong rồi khởi động thành false.
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
)

Tiếp theo, bạn truyền lệnh gọi lại sự kiện checkUserGuess() từ GameScreen lên ViewModel khi người dùng nhấp vào nút Gửi hoặc phím Xong (Done) trên bàn phím. Truyền dữ liệu gameUiState.isGuessedWordWrong xuống từ ViewModel sang GameScreen để thiết lập lỗi trong trường văn bản.

7f05d04164aa4646.png

  1. Trong tệp GameScreen.kt, ở cuối hàm có khả năng kết hợp GameScreen(), hãy gọi gameViewModel.checkUserGuess() bên trong biểu thức lambda onClick của nút Submit (Gửi).
Button(
   modifier = modifier
       .fillMaxWidth()
       .weight(1f)
       .padding(start = 8.dp),
   onClick = { gameViewModel.checkUserGuess() }
) {
   Text(stringResource(R.string.submit))
}
  1. Trong hàm có khả năng kết hợp GameScreen(), hãy cập nhật lệnh gọi hàm GameLayout() để truyền gameViewModel.checkUserGuess() trong biểu thức lambda onKeyboardDone.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() }
)
  1. Trong hàm có khả năng kết hợp GameLayout(), thêm tham số hàm cho Boolean, isGuessWrong. Đặt tham số isError của OutlinedTextField thành isGuessWrong để hiển thị lỗi trong trường văn bản nếu người dùng đoán sai.
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. Trong hàm có khả năng kết hợp GameScreen(), hãy cập nhật lệnh gọi hàm GameLayout() để truyền isGuessWrong.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() },
   isGuessWrong = gameUiState.isGuessedWordWrong,
   // ...
)
  1. Tạo và chạy ứng dụng của bạn.
  2. Nhập vào một phỏng đoán sai rồi nhấp vào Submit (Gửi). Quan sát trường văn bản chuyển sang màu đỏ biểu thị lỗi.

a1bc55781d627b38.png

Lưu ý rằng nhãn trường văn bản vẫn hiện dòng chữ "Enter your word" (Nhập từ của bạn). Để thân thiện với người dùng, bạn cần thêm một số văn bản lỗi để cho biết từ đó không chính xác.

  1. Ở tệp GameScreen.kt, trong thành phần kết hợp GameLayout(), hãy cập nhật tham số nhãn của trường văn bản tuỳ thuộc vào isGuessWrong như sau:
OutlinedTextField(
   // ...
   label = {
       if (isGuessWrong) {
           Text(stringResource(R.string.wrong_guess))
       } else {
           Text(stringResource(R.string.enter_your_word))
       }
   },
   // ...
)
  1. Trong tệp strings.xml, hãy thêm một chuỗi vào nhãn lỗi.
<string name="wrong_guess">Wrong Guess!</string>
  1. Xây dựng rồi chạy lại ứng dụng của bạn.
  2. Nhập vào một phỏng đoán sai rồi nhấp vào Submit (Gửi). Hãy lưu ý nhãn lỗi.

8c17eb61e9305d49.png

8. Cập nhật điểm và số từ

Trong nhiệm vụ này, bạn sẽ cập nhật điểm số và số từ khi người dùng chơi trò chơi. Điểm số phải là một phần của _ uiState.

  1. Trong GameUiState, hãy thêm một biến score và khởi tạo biến đó bằng 0.
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
   val score: Int = 0
)
  1. Để cập nhật giá trị điểm, trong GameViewModel, trong hàm checkUserGuess(), bên trong điều kiện if, khi người dùng đoán đúng, hãy tăng giá trị 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. Trong GameViewModel, hãy thêm một phương thức khác tên là updateGameState để cập nhật điểm, tăng số đếm từ hiện tại và chọn một từ mới trong tệp WordsData.kt. Thêm Int có tên là updatedScore dưới dạng tham số. Cập nhật các biến giao diện người dùng của trạng thái trò chơi như sau:
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           isGuessedWordWrong = false,
           currentScrambledWord = pickRandomWordAndShuffle(),
           score = updatedScore
       )
   }
}
  1. Trong hàm checkUserGuess(), nếu người dùng đoán đúng, hãy gọi updateGameState có điểm số đã cập nhật để chuẩn bị trò chơi cho vòng tiếp theo.
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 {
       //...
   }
}

Hàm checkUserGuess() hoàn chỉnh sẽ có dạng như sau:

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

Tiếp theo, tương tự như thông tin cập nhật về điểm số, bạn cũng cần cập nhật số từ.

  1. Thêm một biến khác cho số lượng trong GameUiState. Gọi nó là currentWordCount và khởi chạy nó bằng 1.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
)
  1. Ở tệp GameViewModel.kt, trong hàm updateGameState(), hãy tăng số từ như bên dưới. Hàm updateGameState() được gọi để chuẩn bị trò chơi cho vòng tiếp theo.
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           //...
           currentWordCount = currentState.currentWordCount.inc(),
           )
   }
}

Chuyển điểm và số từ

Hoàn tất các bước sau để chuyển dữ liệu về số từ và điểm số từ ViewModel xuống GameScreen.

546e101980380f80.png

  1. Trong tệp GameScreen.kt, trong hàm có khả năng kết hợp GameLayout(), hãy thêm số từ làm đối số và chuyển các đối số định dạng wordCount vào phần tử văn bản.
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. Cập nhật lệnh gọi hàm GameLayout() để đưa số từ vào.
GameLayout(
   userGuess = gameViewModel.userGuess,
   wordCount = gameUiState.currentWordCount,
   //...
)
  1. Trong hàm có khả năng kết hợp GameScreen(), hãy cập nhật lệnh gọi hàm GameStatus() để đưa vào các tham số score. Chuyển điểm số từ gameUiState.
GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))
  1. Tạo bản dựng và chạy ứng dụng
  2. Nhập từ suy đoán rồi nhấp vào Submit (Gửi). Hãy lưu ý thông tin cập nhật về điểm số và số từ.
  3. Nhấp vào Skip (Bỏ qua) và lưu ý là không có gì xảy ra.

Để triển khai chức năng bỏ qua, bạn cần truyền lệnh gọi lại sự kiện bỏ qua đến GameViewModel.

  1. Ở tệp GameScreen.kt, trong hàm có khả năng kết hợp GameScreen(), thực hiện lệnh gọi đến gameViewModel.skipWord() trong biểu thức lambda onClick.

Android Studio hiển thị lỗi vì bạn chưa triển khai hàm này. Bạn sẽ khắc phục lỗi này trong bước tiếp theo bằng cách thêm phương thức skipWord(). Khi người dùng bỏ qua một từ, bạn cần phải cập nhật biến của trò chơi và chuẩn bị trò chơi cho vòng tiếp theo.

OutlinedButton(
   onClick = { gameViewModel.skipWord() },
   modifier = Modifier.fillMaxWidth()
) {
   //...
}
  1. Trong GameViewModel, thêm phương thức skipWord().
  2. Bên trong hàm skipWord(), hãy gọi đến updateGameState(), truyền điểm và đặt lại thông tin dự đoán của người dùng.
fun skipWord() {
   updateGameState(_uiState.value.score)
   // Reset user guess
   updateUserGuess("")
}
  1. Chạy ứng dụng và chơi trò chơi. Giờ đây bạn đã có thể bỏ qua các từ.

e87bd75ba1269e96.png

Bạn vẫn có thể chơi trò chơi sau hơn 10 từ. Trong nhiệm vụ tiếp theo, bạn sẽ xử lý vòng cuối của trò chơi.

9. Xử lý vòng cuối cùng của trò chơi

Trong cách triển khai hiện tại, người dùng có thể bỏ qua hoặc dùng hơn 10 từ. Trong nhiệm vụ này, bạn sẽ thêm logic để kết thúc trò chơi.

d3fd67d92c5d3c35.png

Để triển khai logic kết thúc trò chơi, trước tiên, bạn cần kiểm tra xem người dùng có đạt đến số từ tối đa không.

  1. Trong GameViewModel, hãy thêm một khối if-else và di chuyển phần thân (nội dung) hàm hiện có bên trong khối else.
  2. Thêm một điều kiện if để kiểm tra kích thước usedWords có bằng với MAX_NO_OF_WORDS không.
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. Bên trong khối if, hãy thêm cờ Boolean vào isGameOver rồi đặt cờ thành true để cho biết thời điểm kết thúc trò chơi.
  2. Cập nhật score và đặt lại isGuessedWordWrong bên trong khối if. Đoạn mã sau sẽ cho thấy hàm của bạn trông như thế nào:
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. Trong GameUiState, hãy thêm biến Boolean isGameOver rồi đặt biến đó thành false.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
   val isGameOver: Boolean = false
)
  1. Chạy ứng dụng và chơi trò chơi. Bạn không thể chơi quá 10 từ.

ac8a12e66111f071.png

Khi trò chơi kết thúc, bạn nên thông báo cho người dùng và hỏi xem họ có muốn chơi lại không. Bạn sẽ triển khai tính năng này trong nhiệm vụ tiếp theo.

Hiện hộp thoại kết thúc trò chơi

Trong nhiệm vụ này, bạn sẽ truyền dữ liệu isGameOver xuống GameScreen từ ViewModel và sử dụng dữ liệu này để hiển thị hộp thoại cảnh báo có các tuỳ chọn kết thúc hoặc khởi động lại trò chơi.

Hộp thoại là một cửa sổ nhỏ nhắc người dùng đưa ra quyết định hoặc nhập thông tin bổ sung. Thông thường, hộp thoại không lấp đầy toàn bộ màn hình và sẽ yêu cầu người dùng thực hiện hành động trước khi có thể tiếp tục. Android cung cấp nhiều loại hộp thoại. Trong lớp học lập trình này, bạn sẽ tìm hiểu về Hộp thoại thông báo.

Thông tin chi tiết về hộp thoại thông báo

eb6edcdd0818b900.png

  1. Vùng chứa (container)
  2. Biểu tượng (không bắt buộc)
  3. Dòng tiêu đề (không bắt buộc)
  4. Văn bản hỗ trợ
  5. Dải phân cách (không bắt buộc)
  6. Thao tác

Tệp GameScreen.kt trong mã khởi động đã cung cấp một hàm hiển thị hộp thoại cảnh báo với các tuỳ chọn thoát hoặc khởi động lại trò chơi.

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

Trong hàm này, các tham số titletext sẽ cho thấy dòng tiêu đề và văn bản hỗ trợ trong hộp thoại cảnh báo. dismissButtonconfirmButton là các nút văn bản. Trong tham số dismissButton, bạn sẽ hiển thị văn bản Exit (Thoát) và kết thúc ứng dụng bằng cách hoàn tất hoạt động. Trong tham số confirmButton, bạn sẽ khởi động lại trò chơi và hiển thị văn bản Play Again (Chơi lại).

a24f59b84a178d9b.png

  1. Trong tệp GameScreen.kt, trong hàm FinalScoreDialog(), hãy chú ý đến tham số cho điểm số để hiển thị điểm số trò chơi trong hộp thoại cảnh báo.
@Composable
private fun FinalScoreDialog(
   score: Int,
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
  1. Trong hàm FinalScoreDialog(), hãy lưu ý đến việc sử dụng biểu thức lambda tham số text để sử dụng score làm đối số định dạng cho văn bản hộp thoại.
text = { Text(stringResource(R.string.you_scored, score)) }
  1. Trong tệp GameScreen.kt, ở cuối hàm có khả năng kết hợp GameScreen(), sau khối Column, hãy thêm một điều kiện if để kiểm tra gameUiState.isGameOver.
  2. Trong khối if, hãy hiện hộp thoại thông báo. Gọi đến FinalScoreDialog() rồi truyền scoregameViewModel.resetGame() vào lệnh gọi lại sự kiện onPlayAgain.
if (gameUiState.isGameOver) {
   FinalScoreDialog(
       score = gameUiState.score,
       onPlayAgain = { gameViewModel.resetGame() }
   )
}

resetGame() là một lệnh gọi lại sự kiện được truyền từ GameScreen đến ViewModel.

  1. Trong tệp GameViewModel.kt, thu hồi hàm resetGame(), khởi động _uiState rồi chọn một từ mới.
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. Tạo và chạy ứng dụng của bạn.
  2. Chơi cho đến khi kết thúc trò chơi và quan sát hộp thoại cảnh báo với các tuỳ chọn để Exit (Thoát) trò chơi hoặc Play Again (Chơi lại). Hãy thử các lựa chọn xuất hiện trên hộp thoại cảnh báo.

c6727347fe0db265.png

10. Trạng thái trong chế độ xoay thiết bị

Trong các lớp học lập trình trước, bạn đã tìm hiểu những thay đổi về cấu hình trong Android. Khi xảy ra thay đổi về cấu hình, Android sẽ khởi động lại hoạt động từ đầu, chạy tất cả các lệnh gọi lại khởi động vòng đời.

ViewModel sẽ lưu trữ dữ liệu liên quan đến ứng dụng không bị huỷ bỏ khi khung Android huỷ và tạo lại hoạt động. Các đối tượng ViewModel tự động được giữ lại và không bị huỷ bỏ như thực thể hoạt động trong quá trình thay đổi cấu hình. Dữ liệu mà các đối tượng này giữ lại sẽ có sẵn ngay sau khi kết hợp lại.

Trong nhiệm vụ này, bạn sẽ kiểm tra xem ứng dụng có giữ lại giao diện người dùng trạng thái trong khi thay đổi cấu hình hay không.

  1. Chạy ứng dụng và chơi một vài từ. Thay đổi cấu hình của thiết bị từ chế độ dọc sang chế độ ngang hoặc ngược lại.
  2. Lưu ý rằng dữ liệu được lưu trong giao diện người dùng của trạng thái ViewModel được giữ lại trong quá trình thay đổi cấu hình.

4a63084643723724.png

4134470d435581dd.png

11. Lấy mã giải pháp

Để tải mã này xuống khi lớp học lập trình đã kết thúc, bạn có thể sử dụng các lệnh git sau:

$ 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

Ngoài ra, bạn có thể tải kho lưu trữ xuống dưới dạng tệp zip rồi giải nén và mở trong Android Studio.

Nếu bạn muốn tham khảo đoạn mã giải pháp cho lớp học lập trình này, hãy xem trên GitHub.

12. Kết luận

Xin chúc mừng! Bạn đã hoàn tất lớp học lập trình. Giờ đây, bạn đã hiểu cách các hướng dẫn về cấu trúc ứng dụng Android đề xuất việc tách các lớp có những trách nhiệm khác nhau và điều khiển giao diện người dùng bằng mô hình.

Đừng quên chia sẻ công việc của bạn trên mạng xã hội với #AndroidBasics!

Tìm hiểu thêm