Lưu trữ dữ liệu trong ViewModel

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 hoạt động (activity) và mảnh (fragment) cũng như các vấn đề liên quan đến vòng đời trong khi thay đổi cấu hình. Để lưu dữ liệu ứng dụng, bạn có thể lưu trạng thái của thực thể nhưng phương thức này đi kèm với một số giới hạn riêng. Trong lớp học lập trình này, bạn sẽ tìm hiểu về một cách hiệu quả để thiết kế ứng dụng và lưu giữ dữ liệu ứng dụng trong quá trình thay đổi cấu hình, bằng cách tận dụng thư viện Android Jetpack.

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 tác vụ phức tạp để bạn có thể tập trung vào mã mình quan tâm, chẳng hạn như logic ứng dụng.

Bộ thành phần cấu trúc Android nằm trong thư viện Android Jetpack, giúp bạn thiết kế những ứng dụng có cấu trúc phù hợp. Thành phần cấu trúc (architecture) cung cấp hướng dẫn về cấu trúc ứng dụng. Đây cũng là phương pháp hay nhất được đề xuất.

Cấu trúc ứng dụng là một bộ quy tắc thiết kế. Cũng giống như bản thiết kế của một ngôi nhà, cấu trúc tạo nên ứng dụng. Một cấu trúc ứng dụng phù hợp có thể giúp mã trở nên hiệu quả, linh hoạt, dễ mở rộng và duy trì lâu dài về sau.

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 để lưu trữ dữ liệu ứng dụng. 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 (activity) và mảnh (fragment) trong quá trình thay đổi cấu hình hoặc các sự kiện khác.

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

  • Cách tải mã nguồn xuống qua GitHub rồi mở mã đó trong Android Studio.
  • Cách sử dụng hoạt động và mảnh để tạo cũng như chạy một ứng dụng Android cơ bản trong Kotlin.
  • Kiến thức về trường văn bản Material và các tiện ích phổ biến trên giao diện người dùng như TextViewButton.
  • Cách sử dụng liên kết thành phần hiển thị (view binding) trong ứng dụng.
  • Thông tin cơ bản về vòng đời của hoạt động và mảnh.
  • Cách thêm thông tin ghi nhật ký vào ứng dụng và đọc nhật ký bằng Logcat trong Android Studio.

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

  • Giới thiệu các nội dung cơ bản 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ị.
  • Thuộc tính sao lưu trong Kotlin.
  • Cách sử dụng MaterialAlertDialog trong thư viện Thành phần Material Design.

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.

Bạn cần có

  • Một máy tính đã cài đặt Android Studio.
  • Mã khởi động cho ứng dụng Unscramble.

2. Tổng quan về ứng dụng khởi độ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 này đưa ra một từ được xáo trộn tại một thời điểm và người chơi phải đoán từ đó dựa trên tất cả chữ cái của từ được xáo trộn. Nếu xếp từ chính xác, người chơi giành được điểm. Nếu không, người chơi có thể thử nhiều lần. Ứng dụng cũng có tuỳ chọn để bỏ qua từ hiện tại. Ở góc trên cùng bên trái, ứng dụng cho thấy số từ đã chơi trong phiên trò chơi hiện tại. Có 10 từ mỗi phiên trò chơi.

8edd6191a40a57e1.png 992bf57f066caf49.png b82a9817b5ec4d11.png

Tải đoạn mã khởi đầu xuống

Lớp học lập trình này cung cấp đoạn mã khởi đầu để bạn có thể mở rộng bằng các tính năng được dạy ở đây. Mã khởi động có thể chứa mã mà bạn đã biết và chưa biết từ các lớp học lập trình trước. Bạn sẽ tìm hiểu thêm về mã mới trong các lớp học lập trình về sau.

Nếu bạn sử dụng đoạn mã khởi đầu lấy trên GitHub, hãy lưu ý tên thư mục là android-basics-kotlin-unscramble-app-starter. Hãy chọn thư mục này khi bạn mở dự án trong Android Studio.

  1. Chuyển đến trang kho lưu trữ GitHub được cung cấp cho dự án.
  2. Xác minh rằng tên nhánh khớp với tên nhánh được chỉ định trong lớp học lập trình. Ví dụ: trong ảnh chụp màn hình sau đây, tên nhánh là main.

1e4c0d2c081a8fd2.png

  1. Trên trang GitHub cho dự án này, nhấp vào nút Code (Mã). Thao tác này sẽ khiến một cửa sổ bật lên.

1debcf330fd04c7b.png

  1. Trong cửa sổ bật lên, nhấp vào nút Download ZIP (Tải tệp ZIP xuống) để lưu dự án vào máy tính. Chờ quá trình tải xuống hoàn tất.
  2. Xác định vị trí của tệp trên máy tính (thường nằm trong thư mục Downloads (Tệp đã tải xuống)).
  3. Nhấp đúp vào tệp ZIP để giải nén. Thao tác này sẽ tạo một thư mục mới chứa các tệp dự án.

Mở dự án trong Android Studio

  1. Khởi động Android Studio.
  2. Trong cửa sổ Welcome to Android Studio (Chào mừng bạn đến với Android Studio), hãy nhấp vào Open (Mở).

d8e9dbdeafe9038a.png

Lưu ý: Nếu Android Studio đã mở thì chuyển sang chọn tuỳ chọn File (Tệp) > Open (Mở) trong trình đơn.

8d1fda7396afe8e5.png

  1. Trong trình duyệt tệp, hãy chuyển đến vị trí của thư mục dự án chưa giải nén (thường nằm trong thư mục Downloads (Tệp đã tải xuống)).
  2. Nhấp đúp vào thư mục dự án đó.
  3. Chờ Android Studio mở dự án.
  4. Nhấp vào nút Chạy 8de56cba7583251f.png để tạo bản dựng và chạy ứng dụng. Đảm bảo ứng dụng được xây dựng như mong đợi.

Tổng quan về mã khởi động

  1. Mở dự án bằng đoạn 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. Chơi thử vài từ trong trò chơi, nhấn vào nút Submit (Gửi) và Skip (Bỏ qua). Hãy lưu ý rằng thao tác nhấn vào những nút này sẽ chuyển sang từ tiếp theo và tăng số lượng từ.
  4. Ngoài ra, điểm chỉ tăng khi bạn nhấn vào nút Submit (Gửi).

Vấn đề trong đoạn mã khởi đầu

Khi chơi trò chơi này, bạn có thể đã quan sát thấy các lỗi sau:

  1. Khi bạn nhấp vào nút Submit (Gửi), ứng dụng không kiểm tra từ của người chơi. Người chơi luôn giành được điểm.
  2. Không có cách nào để kết thúc trò chơi. Ứng dụng cho phép bạn chơi nhiều hơn 10 từ.
  3. Màn hình trò chơi cho thấy một từ được xáo trộn, điểm của người chơi và số từ. Thay đổi hướng màn hình bằng cách xoay thiết bị hoặc trình mô phỏng. Nhận thấy rằng từ, điểm và số từ hiện tại bị mất và trò chơi bắt đầu lại từ đầu.

Vấn đề chính trong ứng dụng

Ứng dụng khởi đầu này không lưu và khôi phục trạng thái cũng như dữ liệu của ứng dụng trong quá trình thay đổi cấu hình, chẳng hạn như khi hướng của thiết bị thay đổi.

Bạn có thể giải quyết vấn đề này bằng cách sử dụng lệnh gọi lại onSaveInstanceState(). Tuy nhiên, để sử dụng phương thức onSaveInstanceState(), bạn phải viết thêm mã để lưu trạng thái trong gói, đồng thời phải triển khai logic để truy xuất trạng thái đó. Ngoài ra, có thể lượng dữ liệu được lưu trữ là rất nhỏ.

Bạn có thể giải quyết những vấn đề này bằng cách sử dụng bộ thành phần cấu trúc Android mà bạn tìm hiểu được trong lộ trình này.

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

Đoạn mã khởi đầu mà bạn tải xuống 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ẽ tập trung vào việc triển khai logic trò chơi. Bạn sẽ sử dụng các thành phần cấu trúc để triển khai cấu trúc ứng dụng đề xuất và giải quyết những vấn đề nêu trên. Dưới đây là hướng dẫn ngắn gọn về một số tệp để bạn bắt đầu.

game_fragment.xml

  • Mở res/layout/game_fragment.xml trong khung hiển thị Design (Thiết kế).
  • Mã này chứa bố cục của màn hình duy nhất trong ứng dụng là màn hình trò chơi.
  • Bố cục này chứa một trường văn bản cho từ của người chơi, cùng với TextViews để hiện điểm và số từ. Ngoài ra còn có hướng dẫn và nút (Submit (Gửi) và Skip (Bỏ qua)) để chơi trò chơi.

main_activity.xml

Xác định bố cục hoạt động chính bằng một mảnh trò chơi duy nhất.

thư mục res/values

Bạn đã làm quen với các tệp tài nguyên trong thư mục này.

  • colors.xml chứa màu giao diện (theme) dùng trong ứng dụng
  • strings.xml chứa tất cả chuỗi mà ứng dụng cần
  • Thư mục themesstyles chứa nội dung tuỳ chỉnh giao diện người dùng cho ứng dụng

MainActivity.kt

Chứa mã được tạo mặc định theo mẫu để đặt khung hiển thị nội dung của hoạt động là main_activity.xml.

ListOfWords.kt

Tệp này chứa danh sách các từ được sử dụng trong trò chơi, cũng như 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.

GameFragment.kt

Đây là mảnh duy nhất trong ứng dụng nơi diễn ra hầu hết các thao tác của trò chơi:

  • Các biến được định nghĩa cho từ được xáo trộn hiện tại (currentScrambledWord), số từ (currentWordCount) và điểm (score).
  • Xác định được quá trình liên kết thực thể đối tượng với quyền truy cập thành phần hiển thị game_fragment tên là binding.
  • Hàm onCreateView() tăng cường XML bố cục game_fragment thông qua đối tượng liên kết.
  • Hàm onViewCreated() thiết lập trình nghe lượt nhấp vào nút và cập nhật giao diện người dùng.
  • onSubmitWord() là trình nghe lượt nhấp cho nút Submit (Gửi), chức năng này hiện từ được xáo trộn tiếp theo, xoá trường văn bản, đồng thời tăng điểm và số từ mà không cần xác thực từ của người chơi.
  • onSkipWord() là trình nghe lượt nhấp cho nút Skip (Bỏ qua), chức năng này cập nhật giao diện người dùng tương tự như onSubmitWord() (ngoại trừ điểm).
  • getNextScrambledWord() là chức năng trợ giúp sẽ chọn một từ ngẫu nhiên trong danh sách từ và trộn các chữ cái trong từ đó.
  • Các chức năng restartGame()exitGame() lần lượt được dùng để khởi động lại và kết thúc trò chơi. Bạn sẽ sử dụng các chức năng này sau.
  • setErrorTextField() xoá nội dung trường văn bản và đặt lại trạng thái lỗi.
  • Hàm updateNextWordOnScreen() hiện từ được xáo trộn mới.

3. Tìm hiểu về cấu trúc ứng dụng

Cấu trúc đưa ra hướng dẫn giúp bạn phân bổ trách nhiệm trong ứ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 tỷ lệ ứng dụng và mở rộng ứng dụng qua các tính năng bổ sung sau này. Điều này cũng giúp hoạt động cộng tác trong nhóm trở nên dễ dàng hơn.

Nguyên tắc cấu trúc phổ biến nhất là: tách biệt vấn đề và đ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ế phân tách các vấn đề nêu rõ rằng bạn phải chia ứng dụng thành các lớp, 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

Một nguyên tắc quan trọng khác là 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ô 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 Views và các thành phần ứng dụng trong ứng dụng, nên không bị ảnh hưởng bởi vòng đời của ứng dụng và các vấn đề liên quan.

Các lớp hoặc thành phần chính trong cấu trúc Android là Đơn vị điều khiển giao diện người dùng (hoạt động/mảnh), ViewModel, LiveDataRoom. Các thành phần này xử lý một số hoạt động phức tạp của vòng đời và giúp bạn tránh các vấn đề liên quan đến vòng đời. Bạn sẽ tìm hiểu về LiveDataRoom trong các lớp học lập trình sau.

Sơ đồ này thể hiện một phần cơ bản của cấu trúc:

597074ed0d08947b.png

Bộ điều khiển giao diện người dùng (Hoạt động/Mảnh)

Hoạt động và mảnh là các đơn vị điều khiển giao diện người dùng. Đơn vị điều khiển giao diện người dùng kiểm soát giao diện người dùng bằng cách vẽ các thành phần hiển thị trên màn hình, ghi lại các sự kiện người dùng và mọi hoạt động khác liên quan đến giao diện người dùng mà người dùng tương tác. Dữ liệu trong ứng dụng hoặc logic quyết định bất kỳ về dữ liệu đó không được nằm trong các lớp đơn vị điều khiển giao diện người dùng.

Hệ thống Android có thể huỷ bỏ đơn vị điều khiển giao diện người dùng bất cứ lúc nào dựa trên một số hoạt động tương tác cụ thể của người dùng hoặc do các điều kiện liên quan đến hệ thống như bộ nhớ thấp. Vì các sự kiện này không thuộc quyền kiểm soát của bạn nên bạn không nên lưu trữ dữ liệu hoặc trạng thái ứng dụng trong đơn vị điều khiển giao diện người dùng. Thay vào đó, bạn nên thêm logic quyết định về dữ liệu vào ViewModel.

Ví dụ: trong ứng dụng Unscramble, từ, điểm và số từ được xáo trộn sẽ xuất hiện trong một mảnh (đơn vị điều khiển giao diện người dùng). Mã quyết định (chẳng hạn như xác định từ được xáo trộn tiếp theo, các phép tính điểm và số từ) phải có trong ViewModel.

ViewModel

ViewModel là mô hình dữ liệu ứng dụng xuất hiện trong thành phần hiển thị. Mô hình là thành phần chịu trách nhiệm xử lý dữ liệu cho ứng dụng. Chúng cho phép ứng dụng 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ỷ khi hoạt động hoặc mảnh bị khung Android huỷ và tạo lại. Các đối tượng ViewModel tự động được giữ lại (chúng không bị huỷ như thực thể hoạt động hoặc mảnh) trong quá trình thay đổi cấu hình. Nhờ vậy, dữ liệu mà chúng giữ lại có sẵn ngay cho thực thể hoạt động hoặc mảnh tiếp theo.

Để 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 đó.

Tóm tắt:

Trách nhiệm của mảnh/hoạt động (đơn vị điều khiển giao diện người dùng)

Trách nhiệm ViewModel

Hoạt động và mảnh chịu trách nhiệm vẽ thành phần hiển thị và dữ liệu ra màn hình và phản hồi sự kiện người dùng.

ViewModel chịu trách nhiệm giữ và xử lý tất cả dữ liệu cần thiết cho giao diện người dùng. Thuộc tính này tuyệt đối không được truy cập vào hệ phân cấp khung hiển thị (chẳng hạn như đối tượng liên kết khung hiển thị) hoặc giữ một tham chiếu đến hoạt động hoặc mảnh.

4. Thêm ViewModel

Trong nhiệm vụ này, bạn thêm ViewModel vào ứng dụng để lưu trữ dữ liệu ứng dụng (từ được xáo trộn, số từ và điểm).

Ứng dụng sẽ được cấu trúc theo cách sau. MainActivity chứa GameFragmentGameFragment sẽ truy cập thông tin về trò chơi qua GameViewModel.

2b29a13dde3481c3.png

  1. Trong cửa sổ Android của Android Studio thuộc thư mục Gradle Scripts (Tập lệnh Gradle), hãy mở tệp build.gradle(Module:Unscramble.app).
  2. Để sử dụng ViewModel trong ứng dụng, hãy xác minh rằng bạn có phần phụ thuộc thư viện ViewModel bên trong khối dependencies. Bước này đã được thực hiện cho bạn. Tuỳ thuộc vào phiên bản thư viện mới nhất, số phiên bản thư viện trong mã đã tạo có thể khác nhau.
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'

Bạn nên luôn dùng phiên bản mới nhất của thư viện thay cho phiên bản được đề cập trong lớp học lập trình.

  1. Tạo tệp lớp Kotlin mới tên là GameViewModel. Trong cửa sổ Android, hãy nhấp chuột phải vào thư mục ui.game. Chọn New > Kotlin File/Class (Mới > Tệp/Lớp Kotlin).

d48361a4f73d4acb.png

  1. Đặt tên cho GameViewModel rồi chọn Class (Lớp) trong danh sách.
  2. Thay đổi GameViewModel thành lớp con trên ViewModel. ViewModel là lớp trừu tượng, do đó bạn cần mở rộng lớp này để sử dụng trong ứng dụng. Hãy xem định nghĩa về lớp GameViewModel ở phần bên dưới.
class GameViewModel : ViewModel() {
}

Đính kèm ViewModel vào mảnh

Để liên kết ViewModel với đơn vị điều khiển giao diện người dùng (hoạt động/mảnh), hãy tạo thuộc tính tham chiếu (đối tượng) cho ViewModel bên trong đơn vị điều khiển giao diện người dùng.

Trong bước này, bạn tạo một thực thể đối tượng của GameViewModel bên trong đơn vị điều khiển giao diện người dùng tương ứng, tức là GameFragment.

  1. Ở đầu lớp GameFragment, hãy thêm một thuộc tính thuộc loại GameViewModel.
  2. Khởi tạo GameViewModel bằng cách sử dụng tính năng uỷ quyền thuộc tính by viewModels() của Kotlin. Bạn sẽ tìm hiểu thêm về tính năng này trong phần tiếp theo.
private val viewModel: GameViewModel by viewModels()
  1. Nếu Android Studio nhắc, hãy nhập androidx.fragment.app.viewModels.

Tính năng uỷ quyền thuộc tính của Kotlin

Trong Kotlin, mỗi thuộc tính có thể biến đổi (var) đều có các hàm getter và setter mặc định được tạo tự động cho mỗi thuộc tính. Hàm setter và getter được gọi khi bạn gán giá trị hoặc đọc giá trị của thuộc tính.

Đối với thuộc tính chỉ đọc (val), các hàm này hơi khác với thuộc tính có thể biến đổi. Chỉ có hàm getter được tạo theo mặc định. Hàm getter này được gọi khi bạn đọc giá trị của thuộc tính chỉ đọc.

Tính năng uỷ quyền thuộc tính trong Kotlin giúp bạn chuyển giao trách nhiệm của hàm getter-setter cho một lớp khác.

Lớp này (được gọi là lớp uỷ quyền (delegate class)) cung cấp các hàm getter và setter của thuộc tính đó và xử lý các thay đổi trên thuộc tính đó.

Từng thuộc tính uỷ quyền được định nghĩa bằng mệnh đề by và một thực thể của lớp uỷ quyền:

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

Trong ứng dụng, nếu bạn khởi tạo mô hình hiển thị bằng hàm khởi tạo GameViewModel mặc định, như minh hoạ dưới đây:

private val viewModel = GameViewModel()

Sau đó, ứng dụng sẽ xoá trạng thái của thuộc tính tham chiếu viewModel khi thiết bị trải qua quá trình thay đổi cấu hình. Ví dụ: nếu bạn xoay thiết bị thì hoạt động sẽ bị huỷ và được tạo lại, nên bạn sẽ có thực thể mô hình hiển thị mới nhưng vẫn ở trạng thái ban đầu.

Thay vào đó, hãy sử dụng tính năng uỷ quyền thuộc tính và uỷ quyền trách nhiệm của đối tượng viewModel cho một lớp riêng tên là viewModels. Tức là khi bạn truy cập vào đối tượng viewModel, đối tượng này sẽ được lớp uỷ quyền viewModels xử lý trong nội bộ. Lớp uỷ quyền tạo đối tượng viewModel cho bạn trong lần truy cập đầu tiên, đồng thời giữ lại giá trị của đối tượng này trong quá trình thay đổi cấu hình và trả về giá trị khi có yêu cầu.

5. Di chuyển dữ liệu sang ViewModel

Hoạt động tách dữ liệu giao diện người dùng của ứng dụng khỏi đơn vị điều khiển giao diện người dùng (các lớp Activity/Fragment) cho phép bạn tuân thủ tốt hơn nguyên tắc trách nhiệm duy nhất mà chúng ta đã thảo luận ở trên. Hoạt động và mảnh chịu trách nhiệm vẽ thành phần hiển thị và dữ liệu ra màn hình, trong khi ViewModel chịu trách nhiệm giữ và xử lý tất cả dữ liệu cần thiết cho giao diện người dùng.

Trong nhiệm vụ này, bạn di chuyển các biến dữ liệu từ lớp GameFragment sang lớp GameViewModel.

  1. Di chuyển các biến dữ liệu score, currentWordCount, currentScrambledWord sang lớp GameViewModel.
class GameViewModel : ViewModel() {

    private var score = 0
    private var currentWordCount = 0
    private var currentScrambledWord = "test"
...
  1. Lưu ý lỗi về các thuộc tính tham chiếu chưa được giải quyết. Điều này là do các thuộc tính ở chế độ riêng tư đối với ViewModel và đơn vị điều khiển giao diện người dùng không thể truy cập được. Bạn sẽ khắc phục những lỗi này ở phần tiếp theo.

Để giải quyết vấn đề này, bạn không thể tạo các phương thức chỉ định truy cập cho thuộc tính public – không nền dùng các lớp khác chỉnh sửa dữ liệu. Điều này rất rủi ro vì lớp bên ngoài có thể thay đổi dữ liệu theo những cách ngoài ý muốn, không tuân theo quy tắc trò chơi được chỉ định trong mô hình hiển thị. Ví dụ: lớp bên ngoài có thể thay đổi score thành giá trị âm.

Bên trong ViewModel, dữ liệu phải có thể chỉnh sửa được, vậy nên dữ liệu phải là privatevar. Bên ngoài ViewModel, dữ liệu phải có thể đọc được nhưng không thể chỉnh sửa được, vậy nên dữ liệu phải xuất hiện dưới dạng publicval. Để đạt được hành vi này, Kotlin có một tính năng gọi là thuộc tính sao lưu.

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.

Bạn đã biết rằng đối với mọi thuộc tính, 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 đè 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 bạn. Để 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ụ minh hoạ thuộc tính sao lưu:

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

Hãy xem xét một ví dụ, trong ứng dụng của bạn, 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. Theo quy ước, cần sử dụng dấu gạch dưới cho tiền tố của thuộc tính private.

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ư đơn vị điều khiển giao diện người dùng. Vì chỉ có phương thức get() bị ghi đè nên thuộc tính này không thể biến đổi và chỉ có thể đọc. Khi một lớp bên ngoài truy cập thuộc tính này, thuộc tính này trả về giá trị _count và bạn không thể sửa đổi được giá trị đó. Điề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 giá trị đó một cách an toàn.

Thêm thuộc tính sao lưu vào currentScrambledWord

  1. Trong GameViewModel, hãy thay đổi phần khai báo currentScrambledWord để thêm thuộc tính sao lưu. Hiện tại, bạn chỉ có thể truy cập và chỉnh sửa _currentScrambledWord trong GameViewModel. Đơn vị điều khiển giao diện người dùng GameFragment có thể đọc giá trị đó bằng cách sử dụng thuộc tính chỉ có thể đọc là currentScrambledWord.
private var _currentScrambledWord = "test"
val currentScrambledWord: String
   get() = _currentScrambledWord
  1. Trong GameFragment, hãy cập nhật phương thức updateNextWordOnScreen() để sử dụng thuộc tính viewModel chỉ có thể đọc là currentScrambledWord.
private fun updateNextWordOnScreen() {
   binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}
  1. Trong GameFragment, hãy xoá mã này trong các phương thức onSubmitWord()onSkipWord(). Bạn sẽ triển khai các phương thức này sau. Giờ đây, bạn đã có thể biên dịch mã này mà không gặp lỗi nào.

6. Vòng đời của ViewModel

Khung này giúp ViewModel vẫn hoạt động, miễn là phạm vi của hoạt động hoặc mảnh còn hoạt động. ViewModel không bị huỷ nếu mô hình sở hữu thuộc tính này bị huỷ trong quá trình thay đổi cấu hình, chẳng hạn như tính năng xoay màn hình. Thực thể mới của mô hình sở hữu kết nối lại với thực thể ViewModel hiện có, như minh hoạ trong sơ đồ sau:

91227008b74bf4bb.png

Tìm hiểu vòng đời của ViewModel

Hãy thêm thông tin đăng nhập trong GameViewModelGameFragment để giúp bạn hiểu rõ hơn về vòng đời của ViewModel.

  1. Trong GameViewModel.kt, hãy thêm khối init bằng câu lệnh nhật ký.
class GameViewModel : ViewModel() {
   init {
       Log.d("GameFragment", "GameViewModel created!")
   }

   ...
}

Kotlin cung cấp một khối khởi tạo (còn gọi là khối init) làm nơi chứa mã thiết lập ban đầu cần thiết trong quá trình khởi tạo thực thể đối tượng. Các khối khởi tạo có tiền tố là từ khoá init, theo sau là dấu ngoặc nhọn {}. Khối mã này được chạy khi thực thể đối tượng được tạo và khởi chạy lần đầu tiên.

  1. Trong lớp GameViewModel, hãy ghi đè phương thức onCleared(). ViewModel bị huỷ khi mảnh được liên kết bị tách rời hoặc khi hoạt động kết thúc. Ngay trước khi ViewModel bị huỷ, hệ thống sẽ thực hiện lệnh gọi lại onCleared().
  2. Thêm câu lệnh nhật ký vào trong onCleared() để theo dõi vòng đời của GameViewModel.
override fun onCleared() {
    super.onCleared()
    Log.d("GameFragment", "GameViewModel destroyed!")
}
  1. Trong GameFragment bên trong onCreateView(), sau khi bạn tham chiếu đến đối tượng liên kết, hãy thêm câu lệnh nhật ký để ghi lại quá trình tạo mảnh. Lệnh gọi lại onCreateView() sẽ được kích hoạt khi mảnh được tạo lần đầu tiên và cũng được kích hoạt mỗi khi được tạo lại cho mọi sự kiện (chẳng hạn như thay đổi cấu hình).
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. Trong GameFragment, hãy ghi đè phương thức gọi lại onDetach(). Phương thức này sẽ được gọi khi hoạt động và mảnh tương ứng bị huỷ.
override fun onDetach() {
    super.onDetach()
    Log.d("GameFragment", "GameFragment destroyed!")
}
  1. Chạy ứng dụng này trong Android Studio, mở cửa sổ Logcat và lọc dữ liệu trên GameFragment. Hãy lưu ý rằng GameFragmentGameViewModel đã được tạo.
com.example.android.unscramble D/GameFragment: GameFragment created/re-created!
com.example.android.unscramble D/GameFragment: GameViewModel created!
  1. Bật chế độ tự động xoay trên thiết bị hoặc trình mô phỏng rồi thay đổi hướng màn hình một vài lần. GameFragment bị huỷ và tạo lại mỗi lần nhưng GameViewModel chỉ được tạo một lần và sẽ không được tạo lại hoặc bị huỷ cho mỗi lần gọi.
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. Thoát khỏi trò chơi hoặc thoát khỏi ứng dụng bằng cách sử dụng mũi tên quay lại. GameViewModel bị huỷ và hệ thống thực hiện lệnh gọi lại onCleared(). GameFragment bị huỷ.
com.example.android.unscramble D/GameFragment: GameViewModel destroyed!
com.example.android.unscramble D/GameFragment: GameFragment destroyed!

7. Điền ViewModel

Trong nhiệm vụ này, bạn tiếp tục điền GameViewModel bằng phương thức trợ giúp để nhận từ tiếp theo, xác thực từ của người chơi để tăng điểm và kiểm tra số từ để kết thúc trò chơi.

Khởi tạo trễ

Thông thường khi khai báo một biến, bạn sẽ cung cấp giá trị ban đầu cho biến đó. Tuy nhiên, nếu chưa sẵn sàng gán giá trị thì bạn có thể khởi tạo giá trị đó sau. Để khởi tạo trễ một thuộc tính trong Kotlin, bạn sử dụng từ khoá lateinit, tức là khởi tạo trễ. Nếu đảm bảo việc khởi tạo thuộc tính trước khi sử dụng thì bạn có thể khai báo thuộc tính này bằng lateinit. Bộ nhớ không được cấp cho biến cho đến khi biến được khởi tạo. Nếu bạn cố gắng truy cập vào biến trước khi khởi tạo biến đó, ứng dụng sẽ gặp sự cố.

Chuyển sang từ tiếp theo

Hãy tạo phương thức getNextWord() trong lớp GameViewModel bằng các chức năng sau:

  • Lấy một từ ngẫu nhiên trên allWordsList và chỉ định từ đó cho currentWord.
  • Tạo một từ được xáo trộn bằng cách xáo trộn các chữ cái trong currentWord và gán từ đó cho currentScrambledWord
  • Xử lý trường hợp có từ được xáo trộn giống với từ không được xáo trộn.
  • Đảm bảo bạn không đưa ra cùng một từ hai lần trong suốt trò chơi.

Thực hiện các bước sau trong lớp GameViewModel:

  1. Trong GameViewModel,, hãy thêm một biến mới của lớp thuộc loại MutableList<String> tên là wordsList để giữ danh sách các từ mà bạn sử dụng trong trò chơi nhằm tránh việc lặp lại.
  2. Thêm một biến lớp khác tên là currentWord để chứa từ mà người chơi đang cố gắng xếp. Hãy sử dụng từ khoá lateinit vì bạn sẽ khởi tạo thuộc tính này sau.
private var wordsList: MutableList<String> = mutableListOf()
private lateinit var currentWord: String
  1. Thêm phương thức private mới tên là getNextWord(), ở trên khối init, trong đó không có tham số nào trả về giá trị rỗng.
  2. Lấy một từ ngẫu nhiên trên allWordsList và chỉ định từ đó cho currentWord
private fun getNextWord() {
   currentWord = allWordsList.random()
}
  1. Trong getNextWord(), hãy chuyển đổi chuỗi currentWord thành mảng ký tự và gán mảng ký tự đó cho val mới có tên là tempWord. Để xáo trộn từ, hãy đảo các ký tự trong mảng này bằng phương thức shuffle() trong Kotlin.
val tempWord = currentWord.toCharArray()
tempWord.shuffle()

Array tương tự như MutableList nhưng có kích thước cố định khi khởi tạo. Array không thể mở rộng hoặc thu nhỏ kích thước (bạn cần sao chép một mảng để đổi kích thước thuộc tính này), khác với MutableList có hàm add()remove() nên có thể tăng và giảm kích thước.

  1. Đôi khi, thứ tự ngẫu nhiên của các ký tự sẽ giống với từ gốc. Thêm vòng lặp while dưới đây xung quanh lệnh gọi để xáo trộn nhằm tiếp tục vòng lặp cho đến khi từ được xáo trộn khác với từ ban đầu.
while (String(tempWord).equals(currentWord, false)) {
    tempWord.shuffle()
}
  1. Thêm khối if-else để kiểm tra xem một từ đã được dùng hay chưa. Nếu wordsList chứa currentWord, hãy gọi getNextWord(). Nếu không, hãy cập nhật giá trị của _currentScrambledWord bằng từ mới được xáo trộn, tăng số từ và thêm từ mới vào wordsList.
if (wordsList.contains(currentWord)) {
    getNextWord()
} else {
    _currentScrambledWord = String(tempWord)
    ++currentWordCount
    wordsList.add(currentWord)
}
  1. Sau đây là phương thức getNextWord() đã hoàn thành để bạn tham khảo.
/*
* Updates currentWord and currentScrambledWord with the next word.
*/
private fun getNextWord() {
   currentWord = allWordsList.random()
   val tempWord = currentWord.toCharArray()
   tempWord.shuffle()

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

Khởi tạo trễ currentScrambledWord

Bây giờ, bạn đã tạo phương thức getNextWord() để lấy từ được xáo trộn tiếp theo. Bạn sẽ thực hiện lệnh gọi tới từ đó khi GameViewModel được khởi tạo lần đầu tiên. Sử dụng khối init để khởi tạo các thuộc tính lateinit trong lớp, chẳng hạn như từ hiện tại. Kết quả là từ đầu tiên xuất hiện trên màn hình sẽ là một từ được xáo trộn thay vì test (kiểm thử).

  1. Chạy ứng dụng. Hãy lưu ý rằng từ đầu tiên luôn là "test" (kiểm thử).
  2. Để một từ được xáo trộn xuất hiện ở phần bắt đầu ứng dụng, bạn cần gọi phương thức getNextWord(), sau đó phương thức này sẽ cập nhật currentScrambledWord. Hãy gọi đến phương thức getNextWord() bên trong khối init của GameViewModel.
init {
    Log.d("GameFragment", "GameViewModel created!")
    getNextWord()
}
  1. Thêm đối tượng sửa đổi lateinit vào thuộc tính _currentScrambledWord. Thêm thông tin đề cập rõ ràng về loại dữ liệu String vì không có giá trị khởi tạo nào được cung cấp.
private lateinit var _currentScrambledWord: String
  1. Chạy ứng dụng. Hãy chú ý một từ được xáo trộn mới sẽ xuất hiện khi mở ứng dụng. Tuyệt vời!

8edd6191a40a57e1.png

Thêm phương thức trợ giúp

Tiếp theo, hãy thêm phương thức trợ giúp để xử lý và sửa đổi dữ liệu bên trong ViewModel. Bạn sẽ sử dụng phương pháp này trong các nhiệm vụ sau.

  1. Trong lớp GameViewModel, hãy thêm một phương thức khác tên là nextWord(). Lấy từ tiếp theo trong danh sách rồi trả về true nếu số từ nhỏ hơn MAX_NO_OF_WORDS.
/*
* 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
}

8. Hộp thoại

Trong đoạn mã khởi đầu, trò chơi không bao giờ kết thúc, ngay cả sau khi người dùng đã giải xong 10 từ. Hãy sửa đổi ứng dụng để sau khi người dùng chơi xong 10 từ, trò chơi sẽ kết thúc và bạn sẽ cho thấy một hộp thoại có điểm số cuối cùng. Bạn cũng sẽ cung cấp cho người dùng tuỳ chọn chơi lại hoặc thoát khỏi trò chơi.

62aa368820ffbe31.png

Đây là lần đầu tiên bạn thêm hộp thoại vào ứng dụng. Hộp thoại là cửa sổ nhỏ (màn hình) nhắc người dùng đưa ra quyết định hoặc nhập thông tin bổ sung. Thường thì 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

f8650ca15e854fe4.png

  1. Hộp thoại cảnh báo
  2. Tiêu đề (không bắt buộc)
  3. Thông báo
  4. Các nút văn bản

Triển khai hộp thoại điểm số cuối cùng

Sử dụng MaterialAlertDialog trong thư viện Thành phần Material Design để thêm hộp thoại vào ứng dụng rồi tuân theo hướng dẫn Material. Vì hộp thoại có liên quan đến giao diện người dùng nên GameFragment sẽ chịu trách nhiệm tạo và hiện hộp thoại điểm cuối cùng.

  1. Trước tiên, hãy thêm thuộc tính sao lưu vào biến score. Trong GameViewModel, hãy thay đổi nội dung khai báo biến score thành nội dung sau đây.
private var _score = 0
val score: Int
   get() = _score
  1. Trong GameFragment, hãy thêm hàm riêng tư tên là showFinalScoreDialog(). Để tạo MaterialAlertDialog, hãy sử dụng lớp MaterialAlertDialogBuilder để thiết lập từng phần của hộp thoại theo từng bước. Gọi hàm khởi tạo MaterialAlertDialogBuilder truyền vào nội dung bằng phương thức requireContext() của mảnh. Phương thức requireContext() trả về một Context khác rỗng.
/*
* Creates and shows an AlertDialog with the final score.
*/
private fun showFinalScoreDialog() {
   MaterialAlertDialogBuilder(requireContext())
}

Như tên gọi, Context đề cập đến ngữ cảnh hoặc trạng thái hiện tại của một ứng dụng, hoạt động hoặc mảnh. Thuộc tính này chứa thông tin liên quan đến hoạt động, mảnh hoặc ứng dụng. Thường thì thuộc tính này dùng để truy cập các tài nguyên, cơ sở dữ liệu và các dịch vụ hệ thống khác. Trong bước này, bạn truyền ngữ cảnh vào mảnh để tạo hộp thoại thông báo.

Nếu Android Studio nhắc, import com.google.android.material.dialog.MaterialAlertDialogBuilder.

  1. Thêm mã để đặt tiêu đề trên hộp thoại thông báo, sử dụng tài nguyên chuỗi qua strings.xml.
MaterialAlertDialogBuilder(requireContext())
   .setTitle(getString(R.string.congratulations))
  1. Thiết lập thông báo để hiện điểm cuối cùng, sử dụng phiên bản chỉ có thể đọc của biến điểm (viewModel.score ) bạn đã thêm trước đó.
   .setMessage(getString(R.string.you_scored, viewModel.score))
  1. Đảm bảo không huỷ được hộp thoại thông báo khi người dùng nhấn phím quay lại, bằng cách sử dụng phương thức setCancelable() và truyền false.
    .setCancelable(false)
  1. Thêm hai nút văn bản EXIT (THOÁT) và PLAY AGAIN (CHƠI LẠI) bằng cách sử dụng các phương thức setNegativeButton()setPositiveButton(). Gọi exitGame()restartGame() lần lượt qua hàm lambda.
    .setNegativeButton(getString(R.string.exit)) { _, _ ->
        exitGame()
    }
    .setPositiveButton(getString(R.string.play_again)) { _, _ ->
        restartGame()
    }

Có thể bạn mới sử dụng cú pháp này, nhưng đây là cách viết tắt của setNegativeButton(getString(R.string.exit), { _, _ -> exitGame()}), trong đó phương thức setNegativeButton() nhận 2 tham số: String và hàm DialogInterface.OnClickListener() có thể được biểu thị dưới dạng lambda. Khi đối số cuối cùng được truyền vào là một hàm, bạn có thể đặt biểu thức lambda bên ngoài dấu ngoặc đơn. Đây gọi là cú pháp trailing lambda. Cả hai cách viết mã (với lambda bên trong hoặc bên ngoài dấu ngoặc đơn) đều được chấp nhận. Điều tương tự cũng áp dụng cho hàm setPositiveButton.

  1. Ở cuối màn hình, hãy thêm show() để tạo rồi hiện hộp thoại thông báo.
      .show()
  1. Sau đây là phương thức showFinalScoreDialog() hoàn chỉnh để bạn tham khảo.
/*
* 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()
}

9. Triển khai OnClickListener cho nút Submit (Gửi)

Trong nhiệm vụ này, bạn sử dụng ViewModel và hộp thoại cảnh báo bạn đã thêm để triển khai logic trò chơi cho trình xử lý lượt nhấp vào nút Submit (Gửi).

Hiện từ được xáo trộn

  1. Nếu bạn chưa thực hiện thì trong GameFragment, hãy xoá mã bên trong onSubmitWord() được gọi khi người dùng nhấn vào nút Submit (Gửi).
  2. Thêm quy trình kiểm tra giá trị trả về của phương thức viewModel.nextWord(). Nếu true thì tức là đang có một từ khác, hãy cập nhật từ được xáo trộn trên màn hình bằng updateNextWordOnScreen(). Ngược lại, trò chơi sẽ kết thúc, vậy nên hãy hiện hộp thoại thông báo có điểm số cuối cùng.
private fun onSubmitWord() {
    if (viewModel.nextWord()) {
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. Chạy ứng dụng! Chơi thử một số từ. Hãy nhớ rằng bạn chưa triển khai nút Skip (Bỏ qua), vậy nên bạn không thể bỏ qua từ.
  2. Xin lưu ý rằng trường văn bản không được cập nhật, nên người chơi phải tự xoá từ trước đó. Điểm cuối cùng trong hộp thoại thông báo luôn bằng 0. Bạn sẽ sửa các lỗi này trong những bước tiếp theo.

a4c660e212ce2c31.png 12a42987a0edd2c4.png

Thêm phương thức trợ giúp để xác thực từ của người chơi

  1. Trong GameViewModel, hãy thêm một phương thức riêng tư mới tên là increaseScore(), trong đó không có tham số và không có giá trị trả về. Tăng biến score bằng SCORE_INCREASE.
private fun increaseScore() {
   _score += SCORE_INCREASE
}
  1. Trong GameViewModel, hãy thêm một phương thức trợ giúp tên là isUserWordCorrect() để trả về Boolean và lấy String (từ của người chơi) làm tham số.
  2. Trong isUserWordCorrect(), hãy xác thực từ của người chơi và cộng điểm nếu đoán đúng. Thao tác này sẽ cập nhật điểm cuối cùng trong hộp thoại thông báo.
fun isUserWordCorrect(playerWord: String): Boolean {
   if (playerWord.equals(currentWord, true)) {
       increaseScore()
       return true
   }
   return false
}

Cập nhật trường văn bản

Hiện lỗi trong trường văn bản

Đối với trường văn bản Material, TextInputLayout đi kèm với chức năng tích hợp để hiện thông báo lỗi. Ví dụ: trong trường văn bản sau, nhãn sẽ thay đổi màu, biểu tượng lỗi xuất hiện, thông báo lỗi xuất hiện, v.v.

520cc685ae1317ac.png

Để hiện lỗi trong trường văn bản, bạn có thể thiết lập thông báo lỗi theo cách động trong mã hoặc theo cách tĩnh trong tệp bố cục. Sau đây là ví dụ về cách thiết lập và thiết lập lại lỗi:

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

// Clear error text
passwordLayout.error = null

Trong đoạn mã khởi đầu, bạn sẽ thấy phương thức trợ giúp setErrorTextField(error: Boolean) đã được xác định để giúp bạn thiết lập và thiết lập lại lỗi trong trường văn bản. Hãy gọi phương thức này bằng true hoặc false dưới dạng tham số đầu vào, tuỳ theo bạn có muốn hiện lỗi trong trường văn bản hay không.

Đoạn mã trong mã khởi đầu

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

Trong nhiệm vụ này, bạn triển khai phương thức onSubmitWord(). Khi một từ được gửi, hãy xác thực lượt đoán của người dùng bằng cách kiểm tra từ gốc. Nếu từ đó chính xác, hãy chuyển sang từ tiếp theo (hoặc hiện hộp thoại nếu trò chơi đã kết thúc). Nếu từ đó không chính xác, hãy hiện lỗi trên trường văn bản và giữ nguyên từ hiện tại.

  1. Trong GameFragment, ở phần bắt đầu của onSubmitWord(), hãy tạo val tên là playerWord. Lưu trữ từ của người chơi trong đó, bằng cách trích xuất từ qua trường văn bản trong biến binding.
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()
    ...
}
  1. Trong onSubmitWord(), bên dưới phần khai báo playerWord, hãy xác thực từ của người chơi. Thêm câu lệnh if để kiểm tra từ của người chơi bằng phương thức isUserWordCorrect(), truyền vào playerWord.
  2. Bên trong khối if, hãy thiết lập lại trường văn bản, gọi setErrorTextField trong false.
  3. Di chuyển mã hiện có bên trong khối if.
private fun onSubmitWord() {
    val playerWord = binding.textInputEditText.text.toString()

    if (viewModel.isUserWordCorrect(playerWord)) {
        setErrorTextField(false)
        if (viewModel.nextWord()) {
            updateNextWordOnScreen()
        } else {
            showFinalScoreDialog()
        }
    }
}
  1. Nếu từ của người dùng không chính xác, hãy hiện thông báo lỗi trong trường văn bản. Thêm một khối else vào khối if ở trên và gọi setErrorTextField() truyền vào true. Phương thức onSubmitWord() hoàn tất sẽ có dạng như sau:
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. Chạy ứng dụng. Chơi thử một số từ. Nếu từ của người chơi là chính xác, từ đó sẽ được xoá khi nhấp vào nút Submit (Gửi). Nếu không, thông báo với nội dung "Try again!" (Hãy thử lại!) sẽ xuất hiện. Hãy lu ý rằng nút Skip (Bỏ qua) vẫn chưa hoạt động. Bạn sẽ thêm cách triển khai này trong nhiệm vụ tiếp theo.

a10c7d77aa26b9db.png

10. Triển khai nút Skip (Bỏ qua)

Trong nhiệm vụ này, bạn thêm cách triển khai cho onSkipWord() để xử lý khi người dùng nhấp vào nút Skip (Bỏ qua).

  1. Tương tự như onSubmitWord(), hãy thêm một điều kiện vào phương thức onSkipWord(). Nếu true, hãy hiện từ đó trên màn hình và thiết lập lại trường văn bản. Nếu false và không còn từ nào ở vòng này, hãy hiện hộp thoại thông báo có điểm cuối cùng.
/*
* Skips the current word without changing the score.
*/
private fun onSkipWord() {
    if (viewModel.nextWord()) {
        setErrorTextField(false)
        updateNextWordOnScreen()
    } else {
        showFinalScoreDialog()
    }
}
  1. Chạy ứng dụng. Chơi trò chơi. Lưu ý rằng nút Skip (Bỏ qua) và Submit (Gửi) đang hoạt động như dự kiến. Tuyệt vời!

11. Xác minh ViewModel lưu giữ dữ liệu

Đối với nhiệm vụ này, hãy thêm tính năng ghi nhật ký vào GameFragment để ghi nhận rằng dữ liệu ứng dụng được lưu giữ trong ViewModel khi thay đổi cấu hình. Để truy cập currentWordCount trong GameFragment, bạn cần hiện phiên bản chỉ có thể đọc bằng cách sử dụng thuộc tính sao lưu.

  1. Trong GameViewModel, hãy nhấp chuột phải vào biến currentWordCount, chọn Refactor (Tái cấu trúc) > Rename… (Đổi tên…) . Đặt tiền tố cho tên mới bằng dấu gạch dưới, _currentWordCount.
  2. Thêm trường sao lưu.
private var _currentWordCount = 0
val currentWordCount: Int
   get() = _currentWordCount
  1. Trong GameFragment bên trong onCreateView(), phía trên câu lệnh trả về, hãy thêm một nhật ký khác để in dữ liệu ứng dụng, từ, điểm và số từ.
Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
       "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
  1. Trong Android Studio, hãy mở Logcat, lọc theo GameFragment. Chạy ứng dụng và chơi thử một số từ. Thay đổi hướng của thiết bị. Mảnh (đơn vị điều khiển giao diện người dùng) đã bị huỷ rồi tạo lại. Quan sát nhật ký. Giờ đây, bạn có thể thấy điểm và số từ tăng lên!
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

Hãy lưu ý rằng dữ liệu ứng dụng được lưu giữ trong ViewModel trong khi thay đổi hướng. Bạn sẽ cập nhật giá trị điểm và số từ trong giao diện người dùng bằng cách sử dụng LiveData và liên kết dữ liệu trong các lớp học lập trình sau này.

12. Cập nhật logic khởi động lại trò chơi

  1. Chạy lại ứng dụng, chơi hết tất cả các từ. Trong hộp thoại thông báo Congratulations! (Xin chúc mừng!), hãy nhấp vào PLAY AGAIN (CHƠI LẠI). Ứng dụng sẽ không cho phép bạn chơi lại vì số từ hiện đã đạt đến giá trị MAX_NO_OF_WORDS. Bạn cần thiết lập lại số từ thành 0 để chơi lại trò chơi từ đầu.
  2. Để thiết lập lại dữ liệu ứng dụng, trong GameViewModel, hãy thêm một phương thức tên là reinitializeData(). Thiết lập điểm và số từ thành 0. Xoá danh sách từ và gọi phương thức getNextWord().
/*
* Re-initializes the game data to restart the game.
*/
fun reinitializeData() {
   _score = 0
   _currentWordCount = 0
   wordsList.clear()
   getNextWord()
}
  1. Trong GameFragment, ở đầu phương thức restartGame(), hãy gọi đến phương thức mới tạo reinitializeData().
private fun restartGame() {
   viewModel.reinitializeData()
   setErrorTextField(false)
   updateNextWordOnScreen()
}
  1. Chạy lại ứng dụng. Chơi trò chơi. Khi bạn đến hộp thoại chúc mừng, hãy nhấp vào Play Again (Chơi lại). Giờ đây, bạn đã có thể chơi lại trò chơi thành công!

Cuối cùng, ứng dụng sẽ có dạng như dưới đây. Trò chơi hiện 10 được xáo trộn ngẫu nhiên để người chơi xếp lại theo thứ tự đúng. Bạn có thể Skip (Bỏ qua) từ đó hoặc đoán từ rồi nhấn vào Submit (Gửi). Nếu bạn đoán chính xác thì điểm sẽ tăng lên. Nếu bạn đoán không chính xác thì trạng thái lỗi trong trường văn bản sẽ xuất hiện. Với mỗi từ mới, số từ cũng sẽ tăng lên.

Xin lưu ý rằng điểm và số từ xuất hiện trên màn hình vẫn chưa được cập nhật. Tuy nhiên, thông tin vẫn được lưu trữ trong mô hình hiển thị và được lưu giữ trong khi thay đổi cấu hình (chẳng hạn như xoay thiết bị). Bạn sẽ cập nhật điểm và số từ trên màn hình trong các lớp học lập trình sau này.

f332979d6f63d0e5.png 2803d4855f5d401f.png

Khi kết thúc 10 từ, trò chơi sẽ kết thúc và hộp thoại thông báo sẽ bật lên, trong đó có điểm cuối cùng cũng như tuỳ chọn thoát khỏi trò chơi hoặc chơi lại.

d8e0111f5f160ead.png

Xin chúc mừng! Bạn đã tạo ViewModel đầu tiên và đã lưu dữ liệu!

13. Mã giải pháp

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 (String(tempWord).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
    }
}

14. Tóm tắt

  • Theo hướng dẫn về cấu trúc ứng dụng Android, bạn nên 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.
  • Đơn vị điều khiển giao diện người dùng là lớp dựa trên giao diện người dùng (UI) như Activity hoặc Fragment. Đơn vị điều khiển giao diện người dùng chỉ được chứa logic xử lý các thao tác trên giao diện người dùng và hệ điều hành; chúng không được là nguồn dữ liệu xuất hiện trên giao diện người dùng. Hãy đặt dữ liệu đó và mọi logic có liên quan vào ViewModel.
  • Lớp ViewModel lưu trữ và quản lý dữ liệu liên quan đến giao diện người dùng. Lớp ViewModel duy trì dữ liệu sau các thay đổi về cấu hình, chẳng hạn như xoay màn hình.
  • ViewModel là một trong những bộ thành phần cấu trúc Android đề xuất.

15. Tìm hiểu thêm