Giới thiệu về Room và luồng (flow)

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

Trong lớp học lập trình trước, bạn đã tìm hiểu kiến thức cơ bản về cơ sở dữ liệu quan hệ, cách đọc và ghi dữ liệu bằng các lệnh SQL: SELECT, INSERT, UPDATE và DELETE. Học cách làm việc với cơ sở dữ liệu quan hệ là một kỹ năng cơ bản mà bạn sẽ cần đến trong suốt hành trình lập trình. Kiến thức về cách hoạt động của cơ sở dữ liệu quan hệ (relational database) cũng cần thiết để triển khai tính năng liên quan đến dữ liệu cố định trong ứng dụng Android. Đó cũng là nội dung bạn sẽ thực hiện trong bài học này.

Một cách dễ dàng để sử dụng cơ sở dữ liệu (database) trong ứng dụng Android là thông qua một thư viện có tên là Room. Room là một thư viện ORM (Object Relational Mapping – Sơ đồ ánh xạ quan hệ đối tượng). Đúng như tên gọi, Room ánh xạ các bảng trong cơ sở dữ liệu quan hệ với các đối tượng dùng được trong mã Kotlin. Trong bài học này, bạn chỉ tập trung vào việc đọc dữ liệu. Bằng cách sử dụng một cơ sở dữ liệu được điền sẵn, bạn sẽ tải dữ liệu từ bảng thời gian đến của xe buýt rồi trình bày dữ liệu trong RecyclerView.

70c597851eba9518.png

Trong quá trình này, bạn sẽ tìm hiểu những kiến thức cơ bản về cách sử dụng Room, bao gồm lớp cơ sở dữ liệu, đối tượng truy cập dữ liệu (DAO), thực thể và mô hình thành phần hiển thị (view model). Bạn cũng sẽ tìm hiểu về lớp ListAdapter (một cách khác để trình bày dữ liệu trong RecyclerView) và flow (một tính năng trong ngôn ngữ Kotlin tương tự như LiveData, cho phép giao diện người dùng của bạn phản hồi các thay đổi trong cơ sở dữ liệu).

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

  • Quen thuộc với lập trình hướng đối tượng và cách sử dụng lớp (class), đối tượng (object) và tính kế thừa (inheritance) trong Kotlin.
  • Có kiến thức cơ bản về cơ sở dữ liệu quan hệ và SQL, được dạy trong lớp học lập trình cơ bản về SQL.
  • Có kinh nghiệm sử dụng coroutine trong Kotlin.

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

Sau khi hoàn thành bài học này, bạn sẽ học được: .

  • Cách biểu diễn các bảng cơ sở dữ liệu dưới dạng đối tượng Kotlin (thực thể).
  • Cách định nghĩa lớp cơ sở dữ liệu để sử dụng Room trong ứng dụng và điền sẵn cơ sở dữ liệu từ một tệp.
  • Cách định nghĩa lớp đối tượng truy cập dữ liệu và sử dụng truy vấn SQL để truy cập cơ sở dữ liệu qua mã Kotlin.
  • Cách định nghĩa mô hình thành phần hiển thị để cho phép giao diện người dùng tương tác với đối tượng truy cập dữ liệu.
  • Cách sử dụng ListAdapter bằng thành phần hiển thị tái sinh (recycler view).
  • Kiến thức cơ bản về flow trong Kotlin và cách sử dụng flow để giúp giao diện người dùng phản hồi trước các thay đổi trong dữ liệu cơ sở.

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

  • Dùng Room để đọc dữ liệu trong cơ sở dữ liệu được điền sẵn và trình bày dữ liệu trong thành phần hiển thị tái sinh trong một ứng dụng đơn giản cung cấp lịch trình xe buýt.

2. Bắt đầu

Ứng dụng bạn sẽ tạo trong lớp học lập trình này có tên là Bus Schedule (Lịch trình xe buýt). Ứng dụng này cung cấp danh sách trạm dừng và thời gian đến của xe buýt theo thứ tự từ sớm nhất đến muộn nhất.

70c597851eba9518.png

Thao tác nhấn vào một hàng trong màn hình đầu tiên sẽ dẫn đến một màn hình mới chỉ cho thấy thời gian đến sắp tới của trạm xe buýt đã chọn.

f477c0942746e584.png

Dữ liệu về trạm xe buýt đến từ cơ sở dữ liệu được điền sẵn bằng ứng dụng. Tuy nhiên, ở trạng thái hiện tại, sẽ không nội dung nào xuất hiện khi ứng dụng chạy lần đầu tiên. Việc bạn cần làm là tích hợp Room để ứng dụng cung cấp cơ sở dữ liệu được điền trước về thời gian đến.

  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 chi nhánh khớp với tên chi 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 (chính).

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 (có thể 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 (có thể 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 Run (Chạy) 8de56cba7583251f.png để tạo và chạy ứng dụng. Đảm bảo rằng ứng dụng được xây dựng như mong đợi.

3. Thêm phần phụ thuộc Room

Giống như với mọi thư viện khác, trước tiên, bạn cần thêm phần phụ thuộc cần thiết để sử dụng được Room trong ứng dụng Bus Schedule (Lịch trình xe buýt). Bạn chỉ cần thực hiện hai thay đổi nhỏ, mỗi thay đổi trong một tệp Gradle.

  1. Trong tệp build.gradle cấp dự án, hãy định nghĩa room_version trong khối ext.
ext {
   kotlin_version = "1.6.20"
   nav_version = "2.4.1"
   room_version = '2.4.2'
}
  1. Trong tệp build.gradle ở cấp ứng dụng, ở cuối danh sách phần phụ thuộc, hãy thêm các phần phụ thuộc sau.
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
  1. Đồng bộ hoá các thay đổi và tạo bản dựng cho dự án để xác minh rằng các phần phụ thuộc được thêm đúng cách.

Trong vài trang tiếp theo, bạn sẽ thấy các thành phần cần thiết để tích hợp Room vào một ứng dụng: mô hình (model), đối tượng truy cập dữ liệu (DAO), lớp thành phần hiển thị (view model) và lớp cơ sở dữ liệu (database class).

4. Tạo thực thể

Khi tìm hiểu về cơ sở dữ liệu quan hệ (relational database) trong lớp học lập trình trước, bạn đã thấy cách tổ chức dữ liệu thành các bảng gồm nhiều cột, mỗi cột đại diện cho một thuộc tính cụ thể của một loại dữ liệu cụ thể. Tương tự như cách các lớp trong Kotlin cung cấp một mẫu cho mỗi đối tượng, một bảng trong cơ sở dữ liệu cũng cung cấp một mẫu cho mỗi mục hoặc hàng trong bảng đó. Không có gì đáng ngạc nhiên khi lớp trong Kotlin có thể dùng để biểu thị từng bảng trong cơ sở dữ liệu.

Khi làm việc với Room, mỗi bảng sẽ do một lớp biểu thị. Trong một thư viện ORM (sơ đồ ánh xạ quan hệ đối tượng), chẳng hạn như Room, các lớp này thường được gọi là lớp mô hình (model class) hoặc thực thể (entity).

Cơ sở dữ liệu cho ứng dụng Bus Schedule (Lịch trình xe buýt) chỉ bao gồm một bảng, một lịch trình, trong đó bao gồm một số thông tin cơ bản về thời gian đến của xe buýt.

  • id: Một số nguyên đưa ra một giá trị nhận dạng duy nhất để làm khoá chính
  • stop_name: Một chuỗi
  • arrival_time: Một số nguyên

Hãy lưu ý rằng loại SQL được dùng trong cơ sở dữ liệu thực ra là INTEGER đối với IntTEXT đối với String. Tuy nhiên, khi xử lý Room, bạn chỉ nên quan tâm đến loại dữ liệu Kotlin khi định nghĩa các lớp mô hình. Hệ thống sẽ tự động xử lý việc ánh xạ các loại dữ liệu trong lớp mô hình của bạn đến các loại dữ liệu dùng trong cơ sở dữ liệu.

Khi một dự án có nhiều tệp, bạn nên cân nhắc sắp xếp các tệp theo nhiều gói để tăng khả năng kiểm soát truy cập cho từng lớp cũng như giúp dễ dàng xác định vị trí của các lớp liên quan. Để tạo một thực thể cho bảng "schedule" (lịch trình), trong gói com.example.busschedule, hãy thêm một gói mới có tên là database (cơ sở dữ liệu). Trong gói đó, hãy thêm một gói mới có tên là schedule (lịch trình) cho thực thể của bạn. Sau đó, trong gói database.schedule, hãy tạo một tệp mới tên là Schedule.kt rồi định nghĩa một lớp dữ liệu tên là Schedule.

data class Schedule(
)

Như trình bày trong bài Kiến thức cơ bản về SQL, bảng dữ liệu phải có một khoá chính để nhận dạng từng hàng. Thuộc tính đầu tiên mà bạn sẽ thêm vào lớp Schedule là một số nguyên biểu thị giá trị nhận dạng riêng biệt. Hãy thêm thuộc tính mới và đánh dấu thuộc tính đó bằng chú thích @PrimaryKey. Thao tác này sẽ yêu cầu Room coi thuộc tính này là khoá chính khi thêm hàng mới.

@PrimaryKey val id: Int

Thêm một cột cho tên trạm xe buýt Cột này phải thuộc loại String. Đối với các cột mới, bạn cần thêm chú giải @ColumnInfo để chỉ định tên cột. Thông thường, tên cột SQL sẽ có các từ được phân tách bằng dấu gạch dưới, thay vì tuân theo quy tắc viết hoa lowerCamelCase mà các thuộc tính trong Kotlin sử dụng. Đối với cột này, chúng ta cũng không muốn nhận giá trị rỗng (null), vì vậy bạn nên đánh dấu cột này bằng chú giải @NonNull.

@NonNull @ColumnInfo(name = "stop_name") val stopName: String,

Thời gian đến được biểu thị bằng số nguyên trong cơ sở dữ liệu. Đây là dấu thời gian Unix có thể chuyển đổi thành ngày hữu dụng. Mặc dù các phiên bản SQL có cung cấp cách chuyển đổi ngày, nhưng bạn sẽ sử dụng các hàm định dạng ngày của Kotlin. Thêm cột @NonNull sau vào lớp mô hình.

@NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int

Cuối cùng, nhằm giúp Room nhận ra được lớp này là lớp có thể dùng để xác định bảng cơ sở dữ liệu, bạn cần thêm chú giải vào chính lớp đó. Thêm @Entity trên một dòng riêng trước tên lớp.

Theo mặc định, Room sử dụng tên lớp này làm tên bảng cơ sở dữ liệu. Do đó, hiện tại tên bảng do lớp xác định sẽ là Schedule (Lịch trình). Nếu muốn, bạn cũng có thể chỉ định @Entity(tableName="schedule"), nhưng vì truy vấn của Room không phân biệt chữ hoa chữ thường, nên bạn có thể bỏ qua việc xác định rõ ràng tên bảng bằng chữ thường ở bước này.

Lúc này, lớp cho thực thể schedule (lịch trình) sẽ có dạng như sau.

@Entity
data class Schedule(
   @PrimaryKey val id: Int,
   @NonNull @ColumnInfo(name = "stop_name") val stopName: String,
   @NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int
)

5. Định nghĩa DAO

Lớp tiếp theo bạn cần thêm để tích hợp Room là DAO. DAO là viết tắt của Đối tượng truy cập dữ liệu (Data Access Object) và là lớp Kotlin cung cấp quyền truy cập vào dữ liệu. Cụ thể, DAO là nơi bạn cung cấp các hàm để đọc và sử dụng dữ liệu. Thao tác gọi một hàm trên DAO tương đương với việc thực hiện một lệnh SQL trên cơ sở dữ liệu. Trên thực tế, các hàm DAO (chẳng hạn như những hàm bạn sẽ định nghĩa trong ứng dụng này) thường chỉ định một lệnh SQL để bạn có thể chỉ định chính xác thao tác bạn muốn thực hiện. Kiến thức về SQL mà bạn có được trong lớp học lập trình trước sẽ rất hữu ích khi định nghĩa DAO.

  1. Thêm một lớp DAO cho thực thể Schedule (Lịch trình). Trong gói database.schedule, hãy tạo tệp mới có tên là ScheduleDao.kt và định nghĩa giao diện có tên là ScheduleDao. Tương tự như lớp Schedule, bạn cần thêm chú giải, lần này là chú giải @Dao để có thể sử dụng giao diện này trong Room.
@Dao
interface ScheduleDao {
}
  1. Có hai màn hình trong ứng dụng và mỗi màn hình sẽ cần một truy vấn riêng. Màn hình đầu tiên cho thấy tất cả trạm xe buýt theo thứ tự tăng dần theo thời gian đến. Trong trường hợp sử dụng này, truy vấn chỉ cần lấy tất cả cột và đưa vào mệnh đề ORDER BY thích hợp. Truy vấn được chỉ định dưới dạng một chuỗi được truyền vào chú giải @Query. Xác định hàm getAll() trả về List (Danh sách) đối tượng Schedule, trong đó có chú giải @Query như dưới đây.
@Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
fun getAll(): List<Schedule>
  1. Đối với truy vấn thứ hai, bạn cũng phải chọn tất cả cột trong bảng lịch trình. Tuy nhiên, bạn chỉ cần những kết quả khớp với tên trạm xe buýt đã chọn. Vì vậy, bạn cần thêm mệnh đề WHERE. Bạn có thể tham chiếu các giá trị Kotlin qua truy vấn bằng cách đặt hai dấu chấm ở trước (:) (ví dụ: :stopName từ tham số của hàm). Giống như trước, kết quả được sắp xếp theo thứ tự tăng dần theo thời gian đến. Định nghĩa hàm getByStopName() lấy String làm tham số stopName rồi trả về List gồm các đối tượng Schedule, kèm chú giải @Query như dưới đây.
@Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
fun getByStopName(stopName: String): List<Schedule>

6. Định nghĩa ViewModel

Đến đây, bạn đã thiết lập xong DAO. Về cơ bản, bạn có mọi thứ cần thiết để bắt đầu truy cập cơ sở dữ liệu qua các mảnh (fragment). Tuy nhiên, cách này chỉ có hiệu quả trên lý thuyết chứ không được coi là phương pháp hay nhất. Lý do là trong các ứng dụng phức tạp, có khả năng sẽ có nhiều màn hình chỉ truy cập vào một phần dữ liệu cụ thể. Mặc dù ScheduleDao tương đối đơn giản, nhưng dễ thấy rằng cách làm này có thể không còn phù hợp khi làm việc với hai màn hình trở lên. Ví dụ: DAO có thể có dạng như sau:

@Dao
interface ScheduleDao {

    @Query(...)
    getForScreenOne() ...

    @Query(...)
    getForScreenTwo() ...

    @Query(...)
    getForScreenThree()

}

Mặc dù mã cho Màn hình 1 có thể truy cập vào getForScreenOne(), nhưng không có lý do để mã này truy cập vào các phương thức khác. Thay vào đó, phương pháp hay nhất là phân tách phần DAO mà bạn hiển thị với thành phần hiển thị thành một lớp riêng biệt được gọi là mô hình thành phần hiển thị (view model). Đây là mẫu kiến trúc phổ biến trong ứng dụng dành cho thiết bị di động. Việc sử dụng mô hình thành phần hiển thị giúp phân tách rõ ràng giữa mã cho giao diện người dùng của ứng dụng và mô hình dữ liệu của ứng dụng. Cách này cũng giúp bạn kiểm thử độc lập từng phần của mã. Bạn sẽ tìm hiểu thêm về chủ đề này khi tiếp tục hành trình phát triển Android.

ee2524be13171538.png

Bằng cách sử dụng mô hình thành phần hiển thị, bạn có thể tận dụng lớp ViewModel. Lớp ViewModel được dùng để lưu trữ dữ liệu liên quan đến giao diện người dùng của ứng dụng. Lớp này cũng nhận biết được vòng đời, có nghĩa là lớp này phản hồi các sự kiện trong vòng đời tương tự như cách phản hồi của một hoạt động (activity) hoặc mảnh (fragment). Nếu các sự kiện trong vòng đời (như tính năng xoay màn hình) khiến một hoạt động hoặc mảnh bị huỷ và phải tạo lại, thì bạn không cần tạo lại ViewModel được liên kết. Bạn không thể làm như vậy khi trực tiếp truy cập vào một lớp DAO, vì vậy, tốt nhất là bạn nên sử dụng lớp con ViewModel để phân tách nhiệm vụ tải dữ liệu khỏi hoạt động hoặc mảnh.

  1. Để tạo lớp cho mô hình thành phần hiển thị, hãy tạo tệp mới tên là BusScheduleViewModel.kt trong một gói mới tên là viewmodels. Định nghĩa một lớp cho mô hình thành phần hiển thị. Lớp này sẽ nhận một tham số thuộc loại ScheduleDao.
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {
  1. Vì mô hình thành phần hiển thị này được dùng cho cả hai màn hình, nên bạn cần thêm một phương thức để tải lịch trình đầy đủ và lịch trình đã lọc theo tên trạm dừng. Bạn có thể thực hiện việc này bằng cách gọi các phương thức tương ứng từ ScheduleDao.
fun fullSchedule(): List<Schedule> = scheduleDao.getAll()

fun scheduleForStopName(name: String): List<Schedule> = scheduleDao.getByStopName(name)

Mặc dù đã xác định mô hình hiển thị, nhưng bạn không thể tạo thực thể BusScheduleViewModel trực tiếp và kỳ vọng rằng mọi thứ sẽ hoạt động. Vì lớp ViewModel BusScheduleViewModel là lớp nhận biết được vòng đời (lifecycle), nên lớp này sẽ được khởi tạo bằng một đối tượng có thể phản hồi các sự kiện trong vòng đời. Nếu bạn tạo thực thể của lớp này trực tiếp trong một mảnh, thì đối tượng mảnh sẽ phải xử lý mọi việc, bao gồm cả quản lý bộ nhớ, tức là vượt quá phạm vi xử lý của mã ứng dụng. Thay vào đó, bạn có thể tạo một lớp được gọi là nhà máy để tạo thực thể của đối tượng mô hình hiển thị.

  1. Để tạo nhà máy, bên dưới lớp mô hình hiển thị, hãy tạo một lớp BusScheduleViewModelFactory mới kế thừa từ ViewModelProvider.Factory.
class BusScheduleViewModelFactory(
   private val scheduleDao: ScheduleDao
) : ViewModelProvider.Factory {
}
  1. Bạn chỉ cần một ít mã nguyên mẫu để tạo mô hình thành phần hiển thị một cách chính xác. Thay vì khởi tạo lớp này trực tiếp, bạn sẽ ghi đè một phương thức tên là create(), phương thức này trả về BusScheduleViewModelFactory kèm theo kiểm tra lỗi. Triển khai create() bên trong lớp BusScheduleViewModelFactory như sau.
override fun <T : ViewModel> create(modelClass: Class<T>): T {
       if (modelClass.isAssignableFrom(BusScheduleViewModel::class.java)) {
           @Suppress("UNCHECKED_CAST")
           return BusScheduleViewModel(scheduleDao) as T
       }
       throw IllegalArgumentException("Unknown ViewModel class")
   }

Bây giờ, bạn có thể tạo thực thể cho đối tượng BusScheduleViewModelFactory bằng BusScheduleViewModelFactory.create() để mô hình thành phần hiển thị có thể biết nhận biết vòng đời mà không cần mảnh trực tiếp xử lý.

7. Tạo lớp cơ sở dữ liệu và điền trước cơ sở dữ liệu

Đến đây, bạn đã định nghĩa xong các mô hình, DAO và mô hình thành phần hiển thị để các mảnh truy cập vào DAO. Bạn còn phải cho Room biết cần làm gì với những lớp này. Đây là lúc cần đến AppDatabase. Một ứng dụng Android sử dụng Room (chẳng hạn như ứng dụng của bạn) sẽ tạo lớp con kế thừa từ lớp RoomDatabase và có một số nhiệm vụ chính. Trong ứng dụng của bạn, AppDatabase cần

  1. Chỉ định thực thể nào được định nghĩa trong cơ sở dữ liệu.
  2. Cấp quyền truy cập vào một phiên bản của mỗi lớp DAO.
  3. Thực hiện các thao tác thiết lập bổ sung, chẳng hạn như điền trước cơ sở dữ liệu.

Có lẽ bạn sẽ thắc mắc tại sao Room không tìm toàn bộ thực thể và đối tượng DAO cho bạn, nhưng rất có thể ứng dụng của bạn có nhiều cơ sở dữ liệu hoặc có nhiều tình huống khiến thư viện không thể đoán được ý định của bạn (với vai trò là nhà phát triển). Lớp AppDatabase cho phép bạn kiểm soát hoàn toàn các mô hình, lớp DAO và mọi thao tác thiết lập cơ sở dữ liệu mà bạn muốn thực hiện.

  1. Để thêm lớp AppDatabase, trong gói database (cơ sở dữ liệu), hãy tạo một tệp mới tên là AppDatabase.kt và định nghĩa lớp trừu tượng mới AppDatabase kế thừa từ RoomDatabase.
abstract class AppDatabase: RoomDatabase() {
}
  1. Lớp cơ sở dữ liệu cho phép các lớp khác dễ dàng truy cập vào các lớp DAO. Thêm một hàm trừu tượng trả về ScheduleDao.
abstract fun scheduleDao(): ScheduleDao
  1. Khi sử dụng lớp AppDatabase, bạn nên đảm bảo rằng chỉ tồn tại một phiên bản của cơ sở dữ liệu nhằm ngăn chặn tình huống tương tranh (race condition) hoặc vấn đề tiềm ẩn khác. Phiên bản này được lưu trữ trong đối tượng đồng hành (companion object). Bạn cũng sẽ cần một phương thức trả về phiên bản hiện có hoặc tạo cơ sở dữ liệu lần đầu tiên. Phương thức này được định nghĩa trong đối tượng đồng hành. Thêm companion object sau đây vào ngay bên dưới hàm scheduleDao().
companion object {
}

Trong companion object, hãy thêm thuộc tính có tên là INSTANCE thuộc loại AppDatabase. Giá trị này ban đầu được đặt là null, vì vậy, loại này được đánh dấu bằng ?. Thuộc tính này cũng được đánh dấu bằng chú giải @Volatile. Mặc dù kiến thức về thời điểm sử dụng thuộc tính volatile (thay đổi được) có vẻ hơi khó đối với bài học này, nhưng bạn nên sử dụng thuộc tính này cho phiên bản AppDatabase để tránh nguy cơ gặp lỗi.

@Volatile
private var INSTANCE: AppDatabase? = null

Bên dưới thuộc tính INSTANCE, hãy định nghĩa một hàm trả về phiên bản AppDatabase

fun getDatabase(context: Context): AppDatabase {
    return INSTANCE ?: synchronized(this) {
        val instance = Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app_database")
            .createFromAsset("database/bus_schedule.db")
            .build()
        INSTANCE = instance

        instance
    }
}

Trong phần triển khai cho getDatabase(), bạn sử dụng toán tử Elvis để trả về phiên bản hiện có của cơ sở dữ liệu (nếu có) hoặc tạo cơ sở dữ liệu lần đầu tiên nếu cần. Trong ứng dụng này, vì dữ liệu đã được điền sẵn nên bạn cũng sẽ gọi createFromAsset() để tải dữ liệu hiện có. Bạn có thể tìm thấy tệp bus_schedule.db trong gói assets.database trong dự án.

  1. Giống như các lớp mô hình và DAO, lớp cơ sở dữ liệu yêu cầu chú giải cung cấp một số thông tin cụ thể. Tất cả loại thực thể (bạn truy cập vào loại đó bằng cách sử dụng ClassName::class) được liệt kê trong một mảng. Cơ sở dữ liệu cũng được cấp số phiên bản. Bạn sẽ thiết lập số này thành 1. Thêm chú giải @Database như sau.
@Database(entities = arrayOf(Schedule::class), version = 1)

Giờ đây, bạn đã tạo được lớp AppDatabase, chỉ còn một bước nữa là có thể sử dụng được lớp đó. Bạn cần cung cấp một lớp con tuỳ chỉnh của lớp Application rồi tạo một thuộc tính lazy, thuộc tính này chứa kết quả của getDatabase().

  1. Trong gói com.example.busschedule, hãy thêm tệp mới tên là BusScheduleApplication.kt rồi tạo lớp BusScheduleApplication kế thừa từ Application.
class BusScheduleApplication : Application() {
}
  1. Thêm thuộc tính cơ sở dữ liệu thuộc loại AppDatabase. Thuộc tính này phải thiết lập theo cách từng phần (lazy) rồi trả về kết quả của lệnh gọi getDatabase() trong lớp AppDatabase của bạn.
class BusScheduleApplication : Application() {
   val database: AppDatabase by lazy { AppDatabase.getDatabase(this) }
  1. Cuối cùng, để đảm bảo lớp BusScheduleApplication được sử dụng (thay vì lớp cơ sở mặc định Application), bạn cần thực hiện một thay đổi nhỏ đối với tệp kê khai. Trong AndroidMainifest.xml, hãy thiết lập thuộc tính android:name thành com.example.busschedule.BusScheduleApplication.
<application
    android:name="com.example.busschedule.BusScheduleApplication"
    ...

Trên đây là kiến thức về cách thiết lập mô hình của ứng dụng. Đến đây, bạn đã sẵn sàng bắt đầu sử dụng dữ liệu qua Room trong giao diện người dùng. Trong các trang tiếp theo, bạn sẽ tạo ListAdapter cho RecyclerView của ứng dụng để trình bày dữ liệu lịch trình xe buýt và phản hồi linh hoạt các thay đổi về dữ liệu.

8. Tạo ListAdapter

Đã đến lúc tận dụng những gì bạn đã vất vả hoàn thành và kết nối mô hình với thành phần hiển thị. Trước đó, khi sử dụng RecyclerView, bạn sẽ sử dụng RecyclerView.Adapter để trình bày một danh sách dữ liệu tĩnh. Mặc dù cách làm này chắc chắn sẽ hiệu quả với một ứng dụng như Bus Schedule, nhưng khi xử lý cơ sở dữ liệu, ứng dụng thường phải xử lý thay đổi đối với dữ liệu theo thời gian thực. Ngay cả khi nội dung của chỉ một mặt hàng thay đổi, toàn bộ thành phần hiển thị tái sinh sẽ được làm mới. Điều này sẽ là không đủ đối với phần lớn ứng dụng đang sử dụng dữ liệu cố định.

ListAdapter là một giải pháp thay thế cho danh sách thay đổi linh động. ListAdapter sử dụng AsyncListDiffer để xác định sự khác biệt giữa danh sách dữ liệu cũ và danh sách dữ liệu mới. Sau đó, thành phần hiển thị tái sinh chỉ được cập nhật dựa trên sự khác biệt giữa hai danh sách này. Kết quả là khung hiển thị tái chế của bạn hoạt động hiệu quả hơn khi xử lý dữ liệu được cập nhật thường xuyên, như bạn thường thấy trong ứng dụng cơ sở dữ liệu.

f59cc2fd4d72c551.png

Vì giao diện người dùng giống nhau cho cả hai màn hình nên bạn chỉ cần tạo một ListAdapter duy nhất dùng được cho cả hai màn hình.

  1. Tạo một tệp mới BusStopAdapter.kt và một lớp BusStopAdapter như minh hoạ. Lớp này mở rộng một ListAdapter tổng quát chứa danh sách đối tượng Schedule và một lớp BusStopViewHolder cho giao diện người dùng. Đối với BusStopViewHolder, bạn cũng truyền một loại DiffCallback mà bạn sẽ định nghĩa trong các bước sau. Bản thân lớp BusStopAdapter cũng nhận tham số onItemClicked(). Hàm này sẽ dùng để xử lý việc điều hướng khi một mục được chọn trên màn hình đầu tiên, nhưng với màn hình thứ hai, bạn sẽ chỉ nhận được một hàm trống.
class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {
}
  1. Tương tự như bộ chuyển đổi khung hiển thị tái chế, bạn cần có ngăn chứa khung hiển thị để có thể truy cập vào khung hiển thị được tạo từ tệp bố cục trong mã. Bố cục cho các ô đã được tạo. Bạn chỉ cần tạo một lớp BusStopViewHolder như minh hoạ rồi triển khai hàm bind() để thiết lập văn bản của stopNameTextView thành tên điểm dừng (stop name) và văn bản của arrivalTimeTextView thành ngày theo đúng định dạng.
class BusStopViewHolder(private var binding: BusStopItemBinding): RecyclerView.ViewHolder(binding.root) {
    @SuppressLint("SimpleDateFormat")
    fun bind(schedule: Schedule) {
        binding.stopNameTextView.text = schedule.stopName
        binding.arrivalTimeTextView.text = SimpleDateFormat(
            "h:mm a").format(Date(schedule.arrivalTime.toLong() * 1000)
        )
    }
}
  1. Ghi đè và triển khai onCreateViewHolder(), tăng cường bố cục, thiết lập onClickListener() để gọi onItemClicked() cho mục ở vị trí hiện tại.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BusStopViewHolder {
   val viewHolder = BusStopViewHolder(
       BusStopItemBinding.inflate(
           LayoutInflater.from( parent.context),
           parent,
           false
       )
   )
   viewHolder.itemView.setOnClickListener {
       val position = viewHolder.adapterPosition
       onItemClicked(getItem(position))
   }
   return viewHolder
}
  1. Ghi đè và triển khai onBindViewHolder() để liên kết thành phần hiển thị ở vị trí đã chỉ định.
override fun onBindViewHolder(holder: BusStopViewHolder, position: Int) {
   holder.bind(getItem(position))
}
  1. Bạn có nhớ lớp DiffCallback mà bạn đã chỉ định cho ListAdapter không? Đây chỉ là một đối tượng giúp ListAdapter xác định các mục không giống nhau trong danh sách mới và danh sách cũ khi cập nhật danh sách. Có hai phương thức: areItemsTheSame() kiểm tra xem đối tượng (hay trong trường hợp của bạn là hàng trong cơ sở dữ liệu) có giống nhau hay không bằng cách chỉ kiểm tra giá trị nhận dạng. areContentsTheSame() kiểm tra xem tất cả thuộc tính (không chỉ giá trị nhận dạng) có giống nhau hay không. Các phương pháp này cho phép ListAdapter xác định những mục đã chèn, cập nhật và xoá, để giao diện người dùng được cập nhật tương ứng.

Thêm đối tượng đồng hành và triển khai DiffCallback như minh hoạ.

companion object {
   private val DiffCallback = object : DiffUtil.ItemCallback<Schedule>() {
       override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
           return oldItem.id == newItem.id
       }

       override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
           return oldItem == newItem
       }
   }
}

Đó là tất cả những gì bạn cần làm để thiết lập lớp chuyển đổi. Bạn sẽ dùng lớp chuyển đổi này ở cả hai màn hình của ứng dụng.

  1. Trước tiên, trong FullScheduleFragment.kt, bạn cần tham chiếu đến mô hình thành phần hiển thị.
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. Sau đó, trong onViewCreated(), hãy thêm mã sau để thiết lập thành phần hiển thị tái sinh và chỉ định trình quản lý bố cục.
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
  1. Sau đó, hãy gán thuộc tính lớp chuyển đổi. Hành động được truyền vào sẽ sử dụng stopName để di chuyển đến màn hình tiếp theo được chọn để lọc danh sách trạm dừng xe buýt.
val busStopAdapter = BusStopAdapter({
   val action = FullScheduleFragmentDirections.actionFullScheduleFragmentToStopScheduleFragment(
       stopName = it.stopName
   )
   view.findNavController().navigate(action)
})
recyclerView.adapter = busStopAdapter
  1. Cuối cùng, để cập nhật thành phần hiển thị danh sách, hãy gọi submitList() với giá trị truyền vào là danh sách trạm xe buýt từ mô hình thành phần hiển thị.
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.fullSchedule())
}
  1. Làm tương tự trong StopScheduleFragment. Trước tiên, hãy tham chiếu đến mô hình thành phần hiển thị (view model).
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. Sau đó, hãy định cấu hình thành phần hiển thị tái sinh trong onViewCreated(). Lần này, bạn chỉ cần truyền vào một khối (hàm) trống bằng {}. Bạn không thực sự muốn ứng dụng thực hiện việc gì khi người dùng nhấn vào các hàng trên màn hình này.
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
val busStopAdapter = BusStopAdapter({})
recyclerView.adapter = busStopAdapter
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.scheduleForStopName(stopName))
}
  1. Thiết lập lớp chuyển đổi xong cũng là lúc bạn hoàn tất việc tích hợp Room vào ứng dụng Bus Schedule. Hãy dành chút thời gian để chạy ứng dụng này và bạn sẽ thấy danh sách thời gian đến (arrival time). Bạn có thể nhấn vào một hàng để di chuyển đến màn hình thông tin chi tiết.

9. Phản hồi các thay đổi về dữ liệu thông qua Flow

Mặc dù thành phần hiển thị danh sách của bạn được thiết lập để xử lý hiệu quả các thay đổi về dữ liệu bất cứ khi nào submitList() được gọi, nhưng ứng dụng của bạn chưa thể xử lý những lần cập nhật linh động. Để tự kiểm tra, hãy thử mở Trình kiểm tra cơ sở dữ liệu (Database Inspector) rồi chạy truy vấn sau để chèn một mục mới vào bảng lịch trình.

INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

Tuy nhiên, bạn sẽ nhận thấy rằng không có điều gì xảy ra trong trình mô phỏng. Người dùng sẽ cho rằng dữ liệu không thay đổi. Bạn sẽ phải chạy lại ứng dụng để xem các thay đổi.

Vấn đề là List<Schedule> chỉ được trả về một lần qua mỗi hàm DAO. Ngay cả khi dữ liệu cơ bản được cập nhật, submitList() sẽ không được gọi để cập nhật giao diện người dùng và người dùng sẽ cho rằng không có gì thay đổi.

Để khắc phục điều này, bạn có thể tận dụng tính năng Kotlin được gọi là flow không đồng bộ (thường gọi ngắn gọn là flow). Tính năng này sẽ cho phép DAO liên tục phát dữ liệu từ cơ sở dữ liệu. Nếu một mục được chèn, cập nhật hoặc xoá, kết quả sẽ được gửi trở lại mảnh. Khi sử dụng hàm collect(),, bạn có thể gọi submitList() bằng cách sử dụng giá trị mới được phát ra từ luồng để ListAdapter có thể cập nhật giao diện người dùng dựa trên dữ liệu mới.

  1. Để sử dụng flow trong ứng dụng Bus Schedule, hãy mở ScheduleDao.kt. Để chuyển đổi các hàm DAO nhằm trả về Flow, bạn chỉ cần thay đổi loại dữ liệu trả về của hàm getAll() thành Flow<List<Schedule>>.
fun getAll(): Flow<List<Schedule>>
  1. Tương tự, hãy cập nhật giá trị trả về của hàm getByStopName().
fun getByStopName(stopName: String): Flow<List<Schedule>>
  1. Các hàm trong mô hình thành phần hiển thị truy cập vào DAO cũng cần được cập nhật. Hãy cập nhật giá trị trả lại thành Flow<List<Schedule>> cho cả fullSchedule()scheduleForStopName().
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {

   fun fullSchedule(): Flow<List<Schedule>> = scheduleDao.getAll()

   fun scheduleForStopName(name: String): Flow<List<Schedule>> = scheduleDao.getByStopName(name)
}
  1. Cuối cùng, trong FullScheduleFragment.kt, busStopAdapter phải được cập nhật khi bạn gọi collect() trên kết quả truy vấn. Vì fullSchedule() là hàm tạm ngưng (suspend function) nên cần được gọi qua coroutine. Thay thế dòng này
busStopAdapter.submitList(viewModel.fullSchedule())

bằng mã này, trong đó sử dụng flow được trả về qua fullSchedule().

lifecycle.coroutineScope.launch {
   viewModel.fullSchedule().collect() {
       busStopAdapter.submitList(it)
   }
}
  1. Làm tương tự trong StopScheduleFragment, nhưng thay thế lệnh gọi đến scheduleForStopName() bằng mã sau.
lifecycle.coroutineScope.launch {
   viewModel.scheduleForStopName(stopName).collect() {
       busStopAdapter.submitList(it)
   }
}
  1. Khi thực hiện xong các thay đổi trên, bạn có thể chạy lại ứng dụng để xác minh rằng các thay đổi về dữ liệu nay được xử lý theo thời gian thực. Khi ứng dụng đang chạy, hãy quay lại Trình kiểm tra cơ sở dữ liệu rồi gửi truy vấn sau đây để chèn thời gian đến mới là trước 8 giờ sáng (8:00 AM).
INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

Mục mới sẽ xuất hiện ở đầu danh sách.

79d6206fc9911fa9.png

Đó là toàn bộ kiến thức về ứng dung Bus Schedule. Chúc mừng bạn đã hoàn thành bài học này. Chắc hẳn giờ đây bạn đã có được nền tảng vững chắc để xử lý Room. Trong lộ trình tiếp theo, bạn sẽ tìm hiểu sâu hơn về Room với một ứng dụng mẫu mới và tìm hiểu cách lưu dữ liệu do người dùng tạo trên một thiết bị.

10. Mã giải pháp

Mã giải pháp cho lớp học lập trình này nằm trong dự án và mô-đun dưới đây.

  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 chi nhánh khớp với tên chi 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 (chính).

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 (có thể 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 (có thể 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 Run (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.

11. Xin chúc mừng

Tóm tắt:

  • Các bảng trong cơ sở dữ liệu SQL được biểu thị trong Room bằng các lớp Kotlin được gọi là thực thể.
  • DAO cung cấp các phương thức tương ứng với các lệnh SQL tương tác với cơ sở dữ liệu.
  • ViewModel là một thành phần có khả năng nhận biết vòng đời, dùng để phân tách dữ liệu của ứng dụng khỏi thành phần hiển thị của ứng dụng.
  • Lớp AppDatabase sẽ cho Room biết cần sử dụng thực thể nào, cung cấp quyền truy cập vào DAO và thực hiện mọi thao tác thiết lập khi tạo cơ sở dữ liệu.
  • ListAdapter là lớp chuyển đổi được dùng với RecyclerView, đây là cách lý tưởng để xử lý các danh sách được cập nhật linh động.
  • Flow là một tính năng của Kotlin, dùng để trả về một luồng dữ liệu và có thể dùng với Room để đảm bảo rằng giao diện người dùng và cơ sở dữ liệu được đồng bộ hoá.

Tìm hiểu thêm