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 cách sử dụng thư viện dữ liệu cố định Room, một tầng trừu tượng ở đầu cơ sở dữ liệu SQLite để lưu trữ dữ liệu của ứng dụng. Trong lớp học lập trình này, bạn sẽ bổ sung thêm nhiều tính năng vào ứng dụng Inventory (Kiểm kho) và tìm hiểu cách đọc, hiển thị, cập nhật và xoá dữ liệu khỏi cơ sở dữ liệu SQLite thông qua Room. Bạn sẽ sử dụng LazyColumn
để hiển thị dữ liệu từ cơ sở dữ liệu và tự động cập nhật dữ liệu khi dữ liệu cơ sở trong cơ sở dữ liệu thay đổi.
Điều kiện tiên quyết
- Có thể tạo và tương tác với cơ sở dữ liệu SQLite bằng thư viện Room.
- Có thể tạo thực thể, đối tượng truy cập dữ liệu và lớp cơ sở dữ liệu.
- Có thể sử dụng đối tượng truy cập dữ liệu (DAO) để ánh xạ các hàm Kotlin đến truy vấn SQL.
- Có thể hiển thị các mục danh sách trong
LazyColumn
. - Đã hoàn thành lớp học lập trình trước trong học phần này, Duy trì dữ liệu thông qua Room.
Kiến thức bạn sẽ học được
- Cách đọc và hiển thị thực thể từ cơ sở dữ liệu SQLite.
- Cách cập nhật và xoá thực thể trong cơ sở dữ liệu SQLite bằng cách sử dụng thư viện Room.
Sản phẩm bạn sẽ tạo ra
- Ứng dụng Inventory (Kiểm kho) cho thấy danh sách mặt hàng tồn kho, đồng thời có thể sử dụng Room để cập nhật, chỉnh sửa và xoá các mặt hàng khỏi cơ sở dữ liệu ứng dụng.
Bạn cần có
- Máy tính đã cài đặt Android Studio
2. Tổng quan về ứng dụng khởi đầu
Lớp học lập trình này sử dụng mã giải pháp của ứng dụng Inventory (Kiểm kho) trong lớp học lập trình trước (Duy trì dữ liệu thông qua Room) làm mã khởi đầu. Ứng dụng khởi đầu lưu dữ liệu bằng thư viện duy trì dữ liệu Room. Người dùng có thể sử dụng màn hình Add Item (Thêm mặt hàng) để thêm dữ liệu vào cơ sở dữ liệu ứng dụng.
Trong lớp học lập trình này, bạn mở rộng ứng dụng này để đọc và hiển thị dữ liệu, cũng như cập nhật và xoá thực thể trên cơ sở dữ liệu bằng thư viện Room.
Tải mã khởi đầu cho lớp học lập trình này
Để bắt đầu, hãy tải mã khởi đầu xuống:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git $ cd basic-android-kotlin-compose-training-inventory-app $ git checkout room
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 xem mã khởi đầu cho lớp học lập trình này, hãy xem trên GitHub.
3. Cập nhật trạng thái giao diện người dùng
Trong nhiệm vụ này, bạn thêm LazyColumn
vào ứng dụng để hiển thị dữ liệu lưu trữ trong cơ sở dữ liệu.
Hướng dẫn từng bước về hàm có khả năng kết hợp HomeScreen
- Mở lại tệp
ui/home/HomeScreen.kt
và xem thành phần kết hợpHomeScreen()
.
@Composable
fun HomeScreen(
navigateToItemEntry: () -> Unit,
navigateToItemUpdate: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
topBar = {
// Top app with app title
},
floatingActionButton = {
FloatingActionButton(
// onClick details
) {
Icon(
// Icon details
)
}
},
) { innerPadding ->
// Display List header and List of Items
HomeBody(
itemList = listOf(), // Empty list is being passed in for itemList
onItemClick = navigateToItemUpdate,
modifier = modifier.padding(innerPadding)
.fillMaxSize()
)
}
Hàm có khả năng kết hợp này cho thấy các mục sau:
- Thanh ứng dụng trên cùng có tên ứng dụng
- Nút hành động nổi (FAB) để thêm các mặt hàng mới vào kho hàng
- Hàm có khả năng kết hợp
HomeBody()
Hàm có khả năng kết hợp HomeBody()
cho thấy các mặt hàng tồn kho dựa trên danh sách đã được truyền vào. Trong quá trình triển khai mã khởi đầu, một danh sách trống (listOf()
) sẽ được truyền đến hàm có khả năng kết hợp HomeBody()
. Để truyền danh sách hàng tồn kho đến thành phần kết hợp này, bạn phải truy xuất dữ liệu kho hàng từ kho lưu trữ rồi truyền dữ liệu này vào HomeViewModel
.
Phát ra trạng thái giao diện người dùng trong HomeViewModel
Khi thêm các phương thức vào ItemDao
để lấy các mục, (getItem()
và getAllItems()
) bạn đã chỉ định Flow
làm kiểu dữ liệu trả về. Hãy nhớ rằng Flow
đại diện cho một luồng dữ liệu chung. Khi trả về một Flow
, bạn chỉ cần thể hiện rõ lệnh gọi các phương thức qua DAO một lần cho mỗi vòng đời nhất định. Room sẽ xử lý các nội dung cập nhật cho dữ liệu cơ bản theo cách không đồng bộ.
Việc lấy dữ liệu qua một luồng được gọi là thu thập qua một luồng. Khi thu thập qua một luồng trong lớp giao diện người dùng, bạn cần cân nhắc một số điều sau đây.
- Các sự kiện trong vòng đời như thay đổi cấu hình (ví dụ: xoay thiết bị) sẽ khiến hoạt động được tạo lại. Điều này sẽ dẫn đến quá trình kết hợp lại và thu thập lại qua
Flow
của bạn hết lần này đến lần khác. - Bạn nên lưu các giá trị vào bộ nhớ đệm dưới dạng trạng thái để dữ liệu hiện có không bị mất khi các sự kiện trong vòng đời xảy ra.
- Bạn nên huỷ luồng khi không còn trình quan sát nào, chẳng hạn như sau khi vòng đời của một thành phần kết hợp kết thúc.
Bạn nên hiển thị Flow
qua ViewModel
bằng StateFlow
. Việc sử dụng StateFlow
cho phép lưu và quan sát dữ liệu, bất kể vòng đời của giao diện người dùng. Bạn cần sử dụng toán tử stateIn
để chuyển đổi Flow
thành StateFlow
.
Toán tử stateIn
có 3 tham số được giải thích ở dưới đây:
scope
–viewModelScope
xác định vòng đời củaStateFlow
. KhiviewModelScope
bị huỷ,StateFlow
cũng sẽ bị huỷ.started
– Quy trình chỉ hoạt động khi giao diện người dùng được hiển thị.SharingStarted.WhileSubscribed()
được dùng để hoàn tất việc này. Để định cấu hình độ trễ (tính bằng mili giây) từ lúc trình đăng ký gần đây nhất biến mất đến khi dừng chia sẻ coroutine, hãy truyềnTIMEOUT_MILLIS
vào phương thứcSharingStarted.WhileSubscribed()
.initialValue
– Thiết lập giá trị ban đầu của luồng trạng thái thànhHomeUiState()
.
Sau khi chuyển đổi Flow
thành StateFlow
, bạn có thể thu thập luồng này bằng phương thức collectAsState()
và chuyển đổi dữ liệu của luồng này thành State
có cùng kiểu.
Ở bước này, bạn truy xuất tất cả mục trong cơ sở dữ liệu Room dưới dạng API quan sát được StateFlow
cho trạng thái giao diện người dùng. Khi dữ liệu Room của ứng dụng Inventory (Kiểm kho) thay đổi, giao diện người dùng sẽ tự động cập nhật.
- Mở tệp
ui/home/HomeViewModel.kt
, chứa hằng sốTIMEOUT_MILLIS
và lớp dữ liệuHomeUiState
với danh sách các mặt hàng đóng vai trò tham số hàm khởi tạo.
// No need to copy over, this code is part of starter code
class HomeViewModel : ViewModel() {
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}
}
data class HomeUiState(val itemList: List<Item> = listOf())
- Bên trong lớp
HomeViewModel
, hãy khai báoval
có tên làhomeUiState
thuộc kiểuStateFlow<HomeUiState>
. Bạn sẽ sớm khắc phục được lỗi khởi động.
val homeUiState: StateFlow<HomeUiState>
- Gọi
getAllItemsStream()
trênitemsRepository
rồi gán chohomeUiState
mà bạn vừa khai báo.
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream()
Bây giờ bạn gặp lỗi – Unresolved reference: itemsRepository (Tham chiếu chưa được giải quyết: itemsRepository). Để giải quyết lỗi Tham chiếu chưa được giải quyết, bạn cần truyền đối tượng ItemsRepository
vào HomeViewModel
.
- Thêm một tham số hàm khởi tạo thuộc kiểu
ItemsRepository
vào lớpHomeViewModel
.
import com.example.inventory.data.ItemsRepository
class HomeViewModel(itemsRepository: ItemsRepository): ViewModel() {
- Ở tệp
ui/AppViewModelProvider.kt
, trong trình khởi tạo củaHomeViewModel
, hãy truyền đối tượngItemsRepository
như minh hoạ.
initializer {
HomeViewModel(inventoryApplication().container.itemsRepository)
}
- Quay lại tệp
HomeViewModel.kt
. Hãy lưu ý đến lỗi kiểu dữ liệu không khớp (type mismatch). Để giải quyết vấn đề này, hãy thêm bản đồ chuyển đổi như minh hoạ dưới đây.
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream().map { HomeUiState(it) }
Android Studio vẫn hiện lỗi kiểu dữ liệu không khớp (type mismatch) Lỗi này là do homeUiState
thuộc kiểu StateFlow
và getAllItemsStream()
trả về Flow
.
- Sử dụng toán tử
stateIn
để chuyển đổiFlow
thànhStateFlow
.StateFlow
là API có thể quan sát dành cho trạng thái giao diện người dùng, cho phép giao diện người dùng tự cập nhật.
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
val homeUiState: StateFlow<HomeUiState> =
itemsRepository.getAllItemsStream().map { HomeUiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
initialValue = HomeUiState()
)
- Tạo bản dựng ứng dụng để đảm bảo không có lỗi trong mã. Bạn sẽ không thấy xuất hiện bất cứ thay đổi nào.
4. Hiển thị dữ liệu Kho hàng
Trong nhiệm vụ này, bạn thu thập và cập nhật trạng thái giao diện người dùng trong HomeScreen
.
- Ở tệp
HomeScreen.kt
, trong hàm có khả năng kết hợpHomeScreen
, hãy thêm một tham số hàm mới thuộc kiểuHomeViewModel
và khởi tạo tham số đó.
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.inventory.ui.AppViewModelProvider
@Composable
fun HomeScreen(
navigateToItemEntry: () -> Unit,
navigateToItemUpdate: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
)
- Trong hàm có khả năng kết hợp
HomeScreen
, hãy thêm mộtval
có tên làhomeUiState
để thu thập trạng thái giao diện người dùng quaHomeViewModel
. Bạn sử dụngcollectAsState
()
để thu thập các giá trị quaStateFlow
này và thể hiện giá trị mới nhất của hàm này quaState
.
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
val homeUiState by viewModel.homeUiState.collectAsState()
- Cập nhật lệnh gọi hàm
HomeBody()
rồi truyềnhomeUiState.itemList
vào tham sốitemList
.
HomeBody(
itemList = homeUiState.itemList,
onItemClick = navigateToItemUpdate,
modifier = modifier.padding(innerPadding)
)
- Chạy ứng dụng. Lưu ý rằng danh sách hàng tồn kho sẽ xuất hiện nếu bạn đã lưu các mặt hàng trong cơ sở dữ liệu ứng dụng. Nếu danh sách này còn trống, hãy thêm một số mặt hàng tồn kho vào cơ sở dữ liệu ứng dụng.
5. Kiểm thử cơ sở dữ liệu
Chúng ta đã thảo luận về tầm quan trọng của việc kiểm thử đoạn mã trong các lớp học lập trình trước. Trong nhiệm vụ này, bạn thêm một số chương trình kiểm thử đơn vị để kiểm thử các truy vấn DAO, rồi thêm nhiều chương trình kiểm thử hơn trong quá trình tham gia lớp học lập trình này.
Bạn nên sử dụng phương pháp viết chương trình kiểm thử JUnit chạy trên thiết bị Android để kiểm thử việc triển khai cơ sở dữ liệu của mình. Vì các chương trình kiểm thử này không yêu cầu tạo hoạt động nên sẽ có tốc độ thực thi nhanh hơn so với chương trình kiểm thử giao diện người dùng.
- Trong tệp
build.gradle.kts (Module :app)
, hãy lưu ý các phần phụ thuộc sau cho Espresso và JUnit.
// Testing
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
- Chuyển sang chế độ xem Project (Dự án) rồi nhấp chuột phải vào src > New > Directory (nguồn > Mới > Thư mục) để tạo nhóm tài nguyên thử nghiệm cho chương trình thử nghiệm.
- Chọn androidTest/kotlin trong cửa sổ bật lên New Directory (Thư mục mới).
- Tạo một lớp Kotlin tên là
ItemDaoTest.kt
. - Chú giải lớp
ItemDaoTest
này bằng@RunWith(AndroidJUnit4::class)
. Hiện tại, lớp (class) của bạn có dạng như mã ví dụ sau:
package com.example.inventory
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ItemDaoTest {
}
- Bên trong lớp này, hãy thêm các biến
var
riêng tư thuộc kiểuItemDao
vàInventoryDatabase
.
import com.example.inventory.data.InventoryDatabase
import com.example.inventory.data.ItemDao
private lateinit var itemDao: ItemDao
private lateinit var inventoryDatabase: InventoryDatabase
- Thêm một hàm để tạo cơ sở dữ liệu và chú thích cơ sở dữ liệu đó bằng
@Before
để có thể chạy trước mỗi lần kiểm thử. - Bên trong phương thức này, hãy khởi tạo
itemDao
.
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import org.junit.Before
@Before
fun createDb() {
val context: Context = ApplicationProvider.getApplicationContext()
// Using an in-memory database because the information stored here disappears when the
// process is killed.
inventoryDatabase = Room.inMemoryDatabaseBuilder(context, InventoryDatabase::class.java)
// Allowing main thread queries, just for testing.
.allowMainThreadQueries()
.build()
itemDao = inventoryDatabase.itemDao()
}
Trong hàm này, bạn sử dụng một cơ sở dữ liệu trong bộ nhớ và đừng lưu trữ cơ sở dữ liệu này trên ổ đĩa. Để thực hiện việc này, bạn hãy sử dụng hàm inMemoryDatabaseBuilder(). Thực hiện việc này là vì bạn không cần phải duy trì thông tin, mà cần xoá khi quá trình bị dừng. Bạn đang chạy các truy vấn DAO trong luồng chính bằng .allowMainThreadQueries()
chỉ để kiểm thử.
- Thêm một hàm khác để đóng cơ sở dữ liệu. Chú giải hàm này bằng
@After
để đóng cơ sở dữ liệu rồi chạy sau mỗi lần kiểm thử.
import org.junit.After
import java.io.IOException
@After
@Throws(IOException::class)
fun closeDb() {
inventoryDatabase.close()
}
- Khai báo các mặt hàng trong lớp
ItemDaoTest
để cơ sở dữ liệu sử dụng, như trong đoạn mã ví dụ sau:
import com.example.inventory.data.Item
private var item1 = Item(1, "Apples", 10.0, 20)
private var item2 = Item(2, "Bananas", 15.0, 97)
- Thêm các hàm số hiệu dụng để thêm một mặt hàng, rồi hai mặt hàng vào cơ sở dữ liệu. Sau đó, bạn sử dụng các hàm này trong chương trình kiểm thử. Hãy đánh dấu các hàm này là
suspend
để chúng có thể chạy trong coroutine.
private suspend fun addOneItemToDb() {
itemDao.insert(item1)
}
private suspend fun addTwoItemsToDb() {
itemDao.insert(item1)
itemDao.insert(item2)
}
- Viết chương trình kiểm thử để chèn một mặt hàng riêng biệt vào cơ sở dữ liệu,
insert()
. Đặt tên cho chương trình kiểm thử làdaoInsert_insertsItemIntoDB
và chú thích bằng@Test
.
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
@Test
@Throws(Exception::class)
fun daoInsert_insertsItemIntoDB() = runBlocking {
addOneItemToDb()
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], item1)
}
Trong chương trình kiểm thử này, bạn sử dụng hàm hiệu dụng addOneItemToDb()
để thêm một mặt hàng vào cơ sở dữ liệu. Sau đó, bạn đọc mặt hàng đầu tiên trong cơ sở dữ liệu. Với assertEquals()
, bạn so sánh giá trị dự kiến với giá trị thực tế. Bạn chạy chương trình kiểm thử trong một coroutine mới bằng runBlocking{}
. Do cách thiết lập này, bạn đánh dấu các hàm hiệu dụng là suspend
.
- Chạy chương trình kiểm thử và đảm bảo có được kết quả kiểm thử đạt.
- Viết một chương trình kiểm thử khác cho
getAllItems()
qua cơ sở dữ liệu. Đặt tên cho chương trình kiểm thử đó làdaoGetAllItems_returnsAllItemsFromDB
.
@Test
@Throws(Exception::class)
fun daoGetAllItems_returnsAllItemsFromDB() = runBlocking {
addTwoItemsToDb()
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], item1)
assertEquals(allItems[1], item2)
}
Trong chương trình kiểm thử trên, bạn thêm hai mặt hàng vào cơ sở dữ liệu bên trong coroutine. Sau đó, bạn đọc hai nội dung và so sánh với các giá trị dự kiến.
6. Hiện thông tin về mặt hàng
Trong nhiệm vụ này, bạn đọc và hiển thị thông tin về thực thể trên màn hình Item Details (Thông tin về mặt hàng). Bạn sử dụng trạng thái của giao diện người dùng về mặt hàng (ví dụ: tên, giá và số lượng) trong cơ sở dữ liệu của ứng dụng Inventory (Kiểm kho) rồi hiện những thông tin này trên màn hình Item Details (Thông tin chi tiết về mặt hàng) bằng thành phần kết hợp ItemDetailsScreen
. Hàm có khả năng kết hợp ItemDetailsScreen
được viết sẵn cho bạn và chứa 3 thành phần kết hợp Văn bản cho thấy thông tin chi tiết về mặt hàng.
ui/item/ItemDetailsScreen.kt
Màn hình này là một phần của mã khởi đầu và thể hiện thông tin về các mặt hàng mà bạn sẽ thấy trong một lớp học lập trình sau. Trong lớp học lập trình này, bạn chưa làm việc trên màn hình này. ItemDetailsViewModel.kt
là ViewModel
tương ứng cho màn hình này.
- Trong hàm có khả năng kết hợp
HomeScreen
, hãy lưu ý lệnh gọi hàmHomeBody()
.navigateToItemUpdate
đang được truyền đến tham sốonItemClick
. Tham số này sẽ được gọi khi bạn nhấp vào một mặt hàng nào đó trong danh sách.
// No need to copy over
HomeBody(
itemList = homeUiState.itemList,
onItemClick = navigateToItemUpdate,
modifier = modifier
.padding(innerPadding)
.fillMaxSize()
)
- Mở
ui/navigation/InventoryNavGraph.kt
và chú ý đến tham sốnavigateToItemUpdate
trong thành phần kết hợpHomeScreen
. Tham số này chỉ định màn hình thông tin chi tiết về mặt hàng làm đích đến cho hoạt động điều hướng.
// No need to copy over
HomeScreen(
navigateToItemEntry = { navController.navigate(ItemEntryDestination.route) },
navigateToItemUpdate = {
navController.navigate("${ItemDetailsDestination.route}/${it}")
}
Phần này của chức năng onItemClick
đã được triển khai cho bạn. Khi bạn nhấp vào mặt hàng trong danh sách, ứng dụng sẽ chuyển đến màn hình chi tiết của mặt hàng đó.
- Nhấp vào mặt hàng trong danh sách hàng tồn kho để xem màn hình thông tin về mặt hàng có các trường trống.
Để điền thông tin về mặt hàng vào các trường văn bản, bạn cần thu thập trạng thái giao diện người dùng trong ItemDetailsScreen()
.
- Trong
UI/Item/ItemDetailsScreen.kt
, hãy thêm một tham số mới vào thành phần kết hợpItemDetailsScreen
thuộc kiểuItemDetailsViewModel
và sử dụng phương thức ban đầu để khởi tạo tham số đó.
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.inventory.ui.AppViewModelProvider
@Composable
fun ItemDetailsScreen(
navigateToEditItem: (Int) -> Unit,
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ItemDetailsViewModel = viewModel(factory = AppViewModelProvider.Factory)
)
- Bên trong thành phần kết hợp
ItemDetailsScreen()
, hãy tạo mộtval
có tên làuiState
để thu thập trạng thái giao diện người dùng. Sử dụngcollectAsState()
để thu thậpStateFlow
uiState
rồi biểu thị giá trị mới nhất của nó quaState
. Android Studio sẽ hiện lỗi tham chiếu chưa được giải quyết (unresolved reference).
import androidx.compose.runtime.collectAsState
val uiState = viewModel.uiState.collectAsState()
- Để khắc phục lỗi này, hãy tạo một
val
có tên làuiState
thuộc loạiStateFlow<ItemDetailsUiState>
trong lớpItemDetailsViewModel
. - Truy xuất dữ liệu qua kho lưu trữ mặt hàng rồi ánh xạ dữ liệu đó vào
ItemDetailsUiState
bằng hàm mở rộngtoItemDetails()
. Hàm mở rộngItem.toItemDetails()
được viết sẵn cho bạn trong mã khởi đầu.
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
val uiState: StateFlow<ItemDetailsUiState> =
itemsRepository.getItemStream(itemId)
.filterNotNull()
.map {
ItemDetailsUiState(itemDetails = it.toItemDetails())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
initialValue = ItemDetailsUiState()
)
- Truyền
ItemsRepository
vàoItemDetailsViewModel
để giải quyết lỗiUnresolved reference: itemsRepository
.
class ItemDetailsViewModel(
savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
) : ViewModel() {
- Trong
ui/AppViewModelProvider.kt
, hãy cập nhật trình khởi tạo choItemDetailsViewModel
như trong đoạn mã sau:
initializer {
ItemDetailsViewModel(
this.createSavedStateHandle(),
inventoryApplication().container.itemsRepository
)
}
- Quay lại
ItemDetailsScreen.kt
, bạn nhận thấy lỗi trong thành phần kết hợpItemDetailsScreen()
đã được giải quyết. - Trong thành phần kết hợp
ItemDetailsScreen()
, hãy cập nhật lệnh gọi hàmItemDetailsBody()
và truyềnuiState.value
vào đối sốitemUiState
.
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { },
onDelete = { },
modifier = modifier.padding(innerPadding)
)
- Quan sát quá trình triển khai
ItemDetailsBody()
vàItemInputForm()
. Bạn đang truyềnitem
được chọn hiện tại từItemDetailsBody()
đếnItemDetails()
.
// No need to copy over
@Composable
private fun ItemDetailsBody(
itemUiState: ItemUiState,
onSellItem: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
//...
) {
var deleteConfirmationRequired by rememberSaveable { mutableStateOf(false) }
ItemDetails(
item = itemDetailsUiState.itemDetails.toItem(), modifier = Modifier.fillMaxWidth()
)
//...
}
- Chạy ứng dụng. Khi bạn nhấp vào một phần tử danh sách trên màn hình Inventory (Kho hàng), màn hình Item Details (Thông tin về mặt hàng) sẽ xuất hiện.
- Lưu ý rằng màn hình còn không trống nữa. Màn hình cho biết thông tin về thực thể được truy xuất qua cơ sở dữ liệu kho hàng.
- Nhấn vào nút Sell (Bán). Không có gì xảy ra cả!
Trong phần tiếp theo, bạn triển khai chức năng của nút Sell (Bán).
7. Triển khai màn hình Item details (Thông tin về mặt hàng)
ui/item/ItemEditScreen.kt
Màn hình Item edit (Chỉnh sửa mặt hàng) được cung cấp cho bạn trong mã khởi đầu.
Bố cục này chứa các thành phần kết hợp dạng trường văn bản để chỉnh sửa chi tiết của mọi mặt hàng mới trong kho hàng.
Mã nguồn của ứng dụng này chưa hoàn thiện chức năng. Ví dụ: trong màn hình Item Details (Thông tin chi tiết về mặt hàng), khi bạn nhấn vào nút Sell (Bán), Quantity in Stock (Số lượng hàng tồn kho) sẽ không giảm. Khi bạn nhấn vào nút Delete (Xoá), ứng dụng sẽ hiện một hộp thoại xác nhận để nhắc bạn. Tuy nhiên, khi bạn chọn nút Yes (Có), ứng dụng sẽ không thực sự xoá mặt hàng đó.
Cuối cùng, nút hành động nổi sẽ mở màn hình Edit Item (Chỉnh sửa mặt hàng) đang để trống.
Trong phần này, bạn triển khai các chức năng của nút Sell (Bán), Delete (Xoá) và các nút hành động nổi.
8. Triển khai chức năng bán mặt hàng
Trong phần này, bạn mở rộng các tính năng của ứng dụng để triển khai chức năng bán. Bước cập nhật này bao gồm những nhiệm vụ sau:
- Thêm chương trình kiểm thử cho hàm DAO để cập nhật một thực thể.
- Thêm một hàm trong
ItemDetailsViewModel
để giảm số lượng mặt hàng và cập nhật thực thể tương ứng trong cơ sở dữ liệu ứng dụng. - Tắt nút Sell (Bán) nếu số lượng là 0.
- Trong
ItemDaoTest.kt
, hãy thêm một hàm tên làdaoUpdateItems_updatesItemsInDB()
; hàm này không có tham số. Chú giải bằng@Test
và@Throws(Exception::class)
.
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB()
- Định nghĩa hàm này và tạo một khối
runBlocking
. Hãy gọiaddTwoItemsToDb()
bên trong đó.
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
addTwoItemsToDb()
}
- Cập nhật 2 thực thể này với các giá trị khác nhau, gọi
itemDao.update
.
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
- Truy xuất các thực thể bằng
itemDao.getAllItems()
. So sánh các thực thể đó với thực thể đã cập nhật và xác nhận.
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
- Hãy đảm bảo rằng hàm hoàn chỉnh có dạng như sau:
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
addTwoItemsToDb()
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
}
- Chạy chương trình kiểm thử và đảm bảo có được kết quả kiểm thử đạt.
Thêm một hàm trong ViewModel
- Trong
ItemDetailsViewModel.kt
, bên trong lớpItemDetailsViewModel
, hãy thêm một hàm có tên làreduceQuantityByOne()
; hàm này không có tham số.
fun reduceQuantityByOne() {
}
- Bên trong hàm này, hãy khởi động một coroutine bằng
viewModelScope.launch{}
.
import kotlinx.coroutines.launch
import androidx.lifecycle.viewModelScope
viewModelScope.launch {
}
- Bên trong khối
launch
, hãy tạo mộtval
có tên làcurrentItem
rồi thiết lập thànhuiState.value.toItem()
.
val currentItem = uiState.value.toItem()
uiState.value
thuộc kiểu ItemUiState
. Bạn chuyển đổi nó thành kiểu thực thể Item
có hàm mở rộng toItem
()
.
- Thêm câu lệnh
if
để kiểm tra xemquality
có lớn hơn0
hay không. - Gọi
updateItem()
trênitemsRepository
rồi truyềncurrentItem
đã cập nhật vào. Cập nhật giá trịquantity
bằngcopy()
để hàm này có dạng như sau:
fun reduceQuantityByOne() {
viewModelScope.launch {
val currentItem = uiState.value.itemDetails.toItem()
if (currentItem.quantity > 0) {
itemsRepository.updateItem(currentItem.copy(quantity = currentItem.quantity - 1))
}
}
}
- Quay lại
ItemDetailsScreen.kt
. - Trong thành phần kết hợp
ItemDetailsScreen
, hãy chuyển đến lệnh gọi hàmItemDetailsBody()
. - Trong hàm lambda
onSellItem
, hãy gọiviewModel.reduceQuantityByOne()
.
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { viewModel.reduceQuantityByOne() },
onDelete = { },
modifier = modifier.padding(innerPadding)
)
- Chạy ứng dụng.
- Trên màn hình Inventory (Kho hàng), hãy nhấp vào một phần tử trong danh sách. Khi màn hình Item Details (Thông tin về mặt hàng) xuất hiện, hãy nhấn vào nút Sell (Bán) để thấy rằng giá trị của số lượng mặt hàng giảm đi 1.
- Trên màn hình Item Details (Thông tin về mặt hàng), hãy liên tục nhấn vào nút Sell (Bán) cho đến khi số lượng trở thành 0.
Sau khi số lượng trở về 0, hãy nhấn vào nút Sell (Bán) một lần nữa. Bạn không nhìn thấy thay đổi nào vì hàm reduceQuantityByOne()
sẽ kiểm tra xem số lượng có lớn hơn 0 hay không trước khi cập nhật số lượng.
Để cung cấp cho người dùng phản hồi tốt hơn, bạn nên vô hiệu hoá nút Sell (Bán) khi không có mặt hàng để bán.
- Trong lớp
ItemDetailsViewModel
, hãy đặt giá trịoutOfStock
dựa trênit
.quantity
trong phép biến đổimap
.
val uiState: StateFlow<ItemDetailsUiState> =
itemsRepository.getItemStream(itemId)
.filterNotNull()
.map {
ItemDetailsUiState(outOfStock = it.quantity <= 0, itemDetails = it.toItemDetails())
}.stateIn(
//...
)
- Chạy ứng dụng của bạn. Lưu ý rằng ứng dụng sẽ tắt nút Sell (Bán) khi số lượng hàng tồn kho bằng 0.
Chúc mừng bạn đã triển khai thành công tính năng Sell (Bán) mặt hàng cho ứng dụng của mình.
Xoá thực thể mặt hàng
Cũng giống như nhiệm vụ trước, bạn phải mở rộng tính năng của ứng dụng hơn nữa bằng cách triển khai tính năng xoá. Tính năng này dễ triển khai hơn nhiều so với tính năng bán. Quá trình này bao gồm những việc sau:
- Thêm chương trình kiểm thử cho truy vấn xoá DAO.
- Thêm một hàm trong
ItemDetailsViewModel
để xoá một thực thể khỏi cơ sở dữ liệu - Cập nhật thành phần kết hợp
ItemDetailsBody
.
Thêm chương trình kiểm thử DAO
- Trong
ItemDaoTest.kt
, hãy thêm một chương trình kiểm thử có têndaoDeleteItems_deletesAllItemsFromDB()
.
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB()
- Chạy một coroutine bằng
runBlocking {}
.
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
}
- Thêm hai mặt hàng vào cơ sở dữ liệu và gọi
itemDao.delete()
trên hai mặt hàng đó để xoá khỏi cơ sở dữ liệu.
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
- Truy xuất các thực thể qua cơ sở dữ liệu và kiểm tra để đảm bảo rằng danh sách này trống. Chương trình kiểm thử hoàn chỉnh sẽ có dạng như sau:
import org.junit.Assert.assertTrue
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
val allItems = itemDao.getAllItems().first()
assertTrue(allItems.isEmpty())
}
Thêm hàm xoá trong ItemDetailsViewModel
- Trong
ItemDetailsViewModel
, hãy thêm một hàm mới có tên làdeleteItem()
. Hàm này không chứa tham số và không trả về giá trị nào. - Bên trong hàm
deleteItem()
, hãy thêm một lệnh gọi hàmitemsRepository.deleteItem()
và truyền vàouiState.value.
toItem
()
.
suspend fun deleteItem() {
itemsRepository.deleteItem(uiState.value.itemDetails.toItem())
}
Trong hàm này, bạn chuyển đổi uiState
từ kiểu itemDetails
thành kiểu thực thể Item
bằng cách sử dụng hàm mở rộng toItem
()
.
- Trong thành phần kết hợp
ui/item/ItemDetailsScreen
, hãy thêm mộtval
có tên làcoroutineScope
rồi thiết lập thànhrememberCoroutineScope()
. Phương pháp này trả về một phạm vi coroutine liên kết với quá trình tổng hợp mà nó được gọi (thành phần kết hợpItemDetailsScreen
).
import androidx.compose.runtime.rememberCoroutineScope
val coroutineScope = rememberCoroutineScope()
- Di chuyển đến hàm
ItemDetailsBody()
. - Chạy một coroutine với
coroutineScope
là bên trong hàm lambdaonDelete
. - Trong khối
launch
, hãy gọi phương thứcdeleteItem()
trênviewModel
.
import kotlinx.coroutines.launch
ItemDetailsBody(
itemUiState = uiState.value,
onSellItem = { viewModel.reduceQuantityByOne() },
onDelete = {
coroutineScope.launch {
viewModel.deleteItem()
}
modifier = modifier.padding(innerPadding)
)
- Sau khi xoá mặt hàng, hãy quay lại màn hình thông tin kho hàng.
- Gọi
navigateBack()
sau lệnh gọi hàmdeleteItem()
.
onDelete = {
coroutineScope.launch {
viewModel.deleteItem()
navigateBack()
}
- Vẫn trong tệp
ItemDetailsScreen.kt
, hãy di chuyển đến hàmItemDetailsBody()
.
Mã khởi đầu cũng cung cấp hàm này cho bạn. Thành phần kết hợp này cho thấy hộp thoại cảnh báo để lấy xác nhận của người dùng trước khi xoá mặt hàng và gọi hàm deleteItem()
khi bạn nhấn vào Yes (Có).
// No need to copy over
@Composable
private fun ItemDetailsBody(
itemUiState: ItemUiState,
onSellItem: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
/*...*/
) {
//...
if (deleteConfirmationRequired) {
DeleteConfirmationDialog(
onDeleteConfirm = {
deleteConfirmationRequired = false
onDelete()
},
//...
)
}
}
}
Khi bạn nhấn vào No (Không), ứng dụng sẽ đóng hộp thoại cảnh báo. Hàm showConfirmationDialog()
sẽ đưa ra cảnh báo sau:
- Chạy ứng dụng.
- Chọn một phần tử trong danh sách trên màn hình Inventory (Kiểm kho).
- Trên màn hình Item Details (Thông tin chi tiết về mặt hàng), hãy nhấn vào Delete (Xoá).
- Nhấn vào Yes (Có) trong hộp thoại cảnh báo, và ứng dụng sẽ quay lại màn hình Inventory (Kiểm kho).
- Lưu ý rằng thực thể bạn đã xoá không còn nằm trong cơ sở dữ liệu của ứng dụng.
Chúc mừng bạn đã triển khai thành công tính năng xoá!
Chỉnh sửa thực thể mặt hàng
Tương tự như các phần trước, trong phần này, bạn thêm một tính năng nâng cao khác cho ứng dụng để chỉnh sửa thực thể của mặt hàng.
Sau đây là hướng dẫn ngắn gọn về các bước để chỉnh sửa thực thể trong cơ sở dữ liệu ứng dụng:
- Thêm một chương trình kiểm thử vào truy vấn để lấy DAO của mặt hàng.
- Điền thông tin về thực thể vào các trường văn bản và màn hình Edit Item (Chỉnh sửa mặt hàng).
- Cập nhật thực thể trong cơ sở dữ liệu bằng cách sử dụng Room.
Thêm chương trình kiểm thử DAO
- Trong
ItemDaoTest.kt
, hãy thêm một chương trình kiểm thử có têndaoGetItem_returnsItemFromDB()
.
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB()
- Định nghĩa hàm này. Bên trong coroutine, hãy thêm một mặt hàng vào cơ sở dữ liệu.
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
addOneItemToDb()
}
- Truy xuất thực thể qua cơ sở dữ liệu bằng cách sử dụng hàm
itemDao.getItem()
và thiết lập thực thể đó thànhval
có tên làitem
.
val item = itemDao.getItem(1)
- So sánh giá trị thực tế với giá trị được truy xuất và xác nhận bằng
assertEquals()
. Chương trình kiểm thử đã hoàn tất của bạn có dạng như sau:
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
addOneItemToDb()
val item = itemDao.getItem(1)
assertEquals(item.first(), item1)
}
- Chạy chương trình kiểm thử và đảm bảo có được kết quả kiểm thử đạt.
Điền các trường văn bản
Nếu bạn chạy ứng dụng, hãy chuyển đến màn hình Item Details (Thông tin chi tiết về mặt hàng), sau đó nhấp vào nút hành động nổi. Bạn có thể nhận thấy rằng tiêu đề màn hình nay là Edit Item (Chỉnh sửa mặt hàng). Tuy nhiên, tất cả trường văn bản đều trống. Trong bước này, bạn điền thông tin về thực thể vào các trường văn bản trong màn hình Edit Item (Chỉnh sửa mặt hàng).
- Trong
ItemDetailsScreen.kt
, hãy di chuyển đến thành phần kết hợpItemDetailsScreen
. - Trong
FloatingActionButton()
, hãy thay đổi đối sốonClick
để đưauiState.value.itemDetails.id
(làid
của thực thể đã chọn) vào. Bạn sử dụngid
này để truy xuất thông tin về thực thể.
FloatingActionButton(
onClick = { navigateToEditItem(uiState.value.itemDetails.id) },
modifier = /*...*/
)
- Trong lớp
ItemEditViewModel
, hãy thêm một khốiinit
.
init {
}
- Bên trong khối
init
, hãy khởi chạy một coroutine bằngviewModelScope
.
launch
.
import kotlinx.coroutines.launch
viewModelScope.launch { }
- Bên trong khối
launch
, hãy truy xuất thông tin về thực thể bằngitemsRepository.getItemStream(itemId)
.
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
init {
viewModelScope.launch {
itemUiState = itemsRepository.getItemStream(itemId)
.filterNotNull()
.first()
.toItemUiState(true)
}
}
Trong khối khởi chạy này, bạn có thể thêm một bộ lọc để trả về một luồng chỉ chứa các giá trị khác rỗng. Với toItemUiState()
, bạn chuyển đổi thực thể item
thành ItemUiState
. Bạn truyền giá trị actionEnabled
dưới dạng true
để bật nút Save (Lưu).
Để giải quyết lỗi Unresolved reference: itemsRepository
, bạn cần truyền ItemsRepository
dưới dạng phần phụ thuộc vào view model.
- Hãy thêm một tham số hàm khởi tạo vào lớp
ItemEditViewModel
.
class ItemEditViewModel(
savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
)
- Ở tệp
AppViewModelProvider.kt
, trong trình khởi tạoItemEditViewModel
, hãy thêm đối tượngItemsRepository
làm đối số.
initializer {
ItemEditViewModel(
this.createSavedStateHandle(),
inventoryApplication().container.itemsRepository
)
}
- Chạy ứng dụng.
- Chuyển đến phần Item Details (Thông tin về mặt hàng) rồi nhấn vào nút hành động nổi .
- Hãy lưu ý rằng các trường sẽ được điền bằng thông tin chi tiết về mặt hàng.
- Chỉnh sửa số lượng hàng tồn kho, hoặc các trường khác, rồi nhấn vào nút Save (Lưu).
Không có gì xảy ra cả! Điều này là do bạn không cập nhật thực thể trong cơ sở dữ liệu của ứng dụng. Bạn sẽ khắc phục được vấn đề này trong phần tiếp theo.
Sử dụng Room để cập nhật thực thể
Trong nhiệm vụ cuối cùng này, bạn thêm các đoạn mã cuối cùng để triển khai chức năng cập nhật. Bạn xác định các hàm cần thiết trong ViewModel rồi sử dụng các hàm đó trong ItemEditScreen
.
Lại đến lúc lập trình rồi!
- Trong lớp
ItemEditViewModel
, hãy thêm một hàm tên làupdateUiState()
. Hàm này sẽ chứa đối tượngItemUiState
và không trả về giá trị nào. Hàm này cập nhật các giá trị mới mà người dùng nhập choitemUiState
.
fun updateUiState(itemDetails: ItemDetails) {
itemUiState =
ItemUiState(itemDetails = itemDetails, isEntryValid = validateInput(itemDetails))
}
Trong hàm này, bạn chỉ định itemDetails
đã truyền vào cho itemUiState
và cập nhật giá trị isEntryValid
. Ứng dụng này sẽ bật nút Save (Lưu) nếu itemDetails
là true
. Bạn chỉ thiết lập giá trị này thành true
nếu giá trị đầu vào mà người dùng nhập là hợp lệ.
- Chuyển đến tệp
ItemEditScreen.kt
. - Trong thành phần kết hợp
ItemEditScreen
, hãy di chuyển xuống lệnh gọi hàmItemEntryBody()
. - Thiết lập giá trị đối số
onItemValueChange
cho hàm mớiupdateUiState
.
ItemEntryBody(
itemUiState = viewModel.itemUiState,
onItemValueChange = viewModel::updateUiState,
onSaveClick = { },
modifier = modifier.padding(innerPadding)
)
- Chạy ứng dụng.
- Chuyển đến màn hình Edit Item (Chỉnh sửa mặt hàng).
- Làm trống một trong các giá trị của thực thể để giá trị đó không hợp lệ. Hãy lưu ý cách nút Save (Lưu) tự động tắt.
- Quay lại lớp
ItemEditViewModel
và thêm một hàmsuspend
có tên làupdateItem()
. Hàm này không làm gì cả. Bạn sử dụng hàm này để lưu thực thể cập nhật vào cơ sở dữ liệu Room.
suspend fun updateItem() {
}
- Bên trong hàm
getUpdatedItemEntry()
này, hãy thêm điều kiệnif
để xác thực hoạt động đầu vào của người dùng bằng hàmvalidateInput()
. - Gọi đến hàm
updateItem()
trênitemsRepository
, truyềnitemUiState.itemDetails.
toItem
()
vào. Các thực thể có thể thêm vào cơ sở dữ liệu Room cần phải thuộc kiểuItem
. Khi hoàn chỉnh hàm sẽ có dạng như sau:
suspend fun updateItem() {
if (validateInput(itemUiState.itemDetails)) {
itemsRepository.updateItem(itemUiState.itemDetails.toItem())
}
}
- Quay lại thành phần kết hợp
ItemEditScreen
, bạn cần phạm vi coroutine để gọi hàmupdateItem()
. Tạo một val có tên làcoroutineScope
rồi thiết lập thànhrememberCoroutineScope()
.
import androidx.compose.runtime.rememberCoroutineScope
val coroutineScope = rememberCoroutineScope()
- Trong lệnh gọi hàm
ItemEntryBody()
, hãy cập nhật đối số hàmonSaveClick
để bắt đầu một coroutine trongcoroutineScope
. - Trong khối
launch
, hãy gọiupdateItem()
trênviewModel
rồi điều hướng quay lại.
import kotlinx.coroutines.launch
onSaveClick = {
coroutineScope.launch {
viewModel.updateItem()
navigateBack()
}
},
Lệnh gọi hàm ItemEntryBody()
khi hoàn tất sẽ có dạng như sau:
ItemEntryBody(
itemUiState = viewModel.itemUiState,
onItemValueChange = viewModel::updateUiState,
onSaveClick = {
coroutineScope.launch {
viewModel.updateItem()
navigateBack()
}
},
modifier = modifier.padding(innerPadding)
)
- Chạy ứng dụng rồi thử chỉnh sửa các mặt hàng tồn kho. Giờ đây, bạn có thể chỉnh sửa mọi mặt hàng trong cơ sở dữ liệu của ứng dụng Inventory (Kiểm kho).
Chúc mừng! Bạn đã tạo xong ứng dụng đầu tiên sử dụng Room để quản lý cơ sở dữ liệu!
9. Mã giải pháp
Mã giải pháp cho lớp học lập trình này nằm trong kho lưu trữ và nhánh GitHub dưới đây:
10. Tìm hiểu thêm
Tài liệu dành cho nhà phát triển Android
- Gỡ lỗi về cơ sở dữ liệu bằng Trình kiểm tra cơ sở dữ liệu
- Lưu dữ liệu trong cơ sở dữ liệu cục bộ bằng Room
- Kiểm thử và gỡ lỗi cơ sở dữ liệu | Nhà phát triển Android
Tài liệu tham khảo về Kotlin