Room을 사용하여 데이터 읽기 및 업데이트

1. 시작하기 전에

이전 Codelab에서는 SQLite 데이터베이스 위의 추상화 레이어인 Room 지속성 라이브러리를 사용하여 앱 데이터를 저장하는 방법을 알아봤습니다. 이 Codelab에서는 Inventory 앱에 기능을 더 추가하고 Room을 사용하여 SQLite 데이터베이스의 데이터를 읽고 표시하고 업데이트하며 삭제하는 방법을 알아봅니다. LazyColumn을 사용하여 데이터베이스의 데이터를 표시하고 데이터베이스의 기본 데이터가 변경될 때 자동으로 데이터를 업데이트합니다.

기본 요건

  • Room 라이브러리를 사용하여 SQLite 데이터베이스를 만들고 상호작용하는 능력
  • 항목, DAO, 데이터베이스 클래스를 만드는 능력
  • 데이터 액세스 객체(DAO)를 사용하여 Kotlin 함수를 SQL 쿼리에 매핑하는 능력
  • LazyColumn에 목록 항목을 표시하는 능력
  • 이 단원의 이전 Codelab인 Room을 사용하여 데이터 유지 완료

학습할 내용

  • SQLite 데이터베이스의 항목을 읽고 표시하는 방법
  • Room 라이브러리를 사용하여 SQLite 데이터베이스의 항목을 업데이트하고 삭제하는 방법

빌드할 항목

  • 인벤토리 항목 목록을 표시하고 Room을 사용하여 앱 데이터베이스의 항목을 업데이트하고 수정하고 삭제할 수 있는 Inventory 앱

필요한 항목

  • Android 스튜디오가 설치된 컴퓨터

2. 시작 앱 개요

이 Codelab에서는 이전 Codelab인 Room을 사용하여 데이터 유지의 Inventory 앱 솔루션 코드를 시작 코드로 사용합니다. 시작 앱은 이미 Room 지속성 라이브러리를 사용하여 데이터를 저장합니다. 사용자는 Add Item 화면을 사용하여 데이터를 앱 데이터베이스에 추가할 수 있습니다.

텍스트 필드가 비어 있는 항목 추가 화면

인벤토리가 비어 있는 휴대전화 화면

이 Codelab에서는 앱을 확장하여 데이터를 읽고 표시하며 Room 라이브러리를 사용하여 데이터베이스의 항목을 업데이트하고 삭제합니다.

이 Codelab의 시작 코드 다운로드

시작하려면 시작 코드를 다운로드하세요.

$ 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

또는 ZIP 파일로 저장소를 다운로드한 다음 압축을 풀고 Android 스튜디오에서 열어도 됩니다.

이 Codelab의 시작 코드는 GitHub에서 확인하세요.

3. UI 상태 업데이트

이 작업에서는 LazyColumn을 앱에 추가하여 데이터베이스에 저장된 데이터를 표시합니다.

인벤토리 항목이 있는 휴대전화 화면

구성 가능한 HomeScreen 함수 둘러보기

  • ui/home/HomeScreen.kt 파일을 열고 HomeScreen() 컴포저블을 확인합니다.
@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()
        )
    }

이 구성 가능한 함수는 다음 항목을 표시합니다.

  • 앱 제목이 있는 상단 앱 바
  • 인벤토리에 새 항목을 추가하기 위한 플로팅 작업 버튼(FAB) adc468afa54b6e70.png
  • 구성 가능한 HomeBody() 함수

구성 가능한 HomeBody() 함수는 전달된 목록을 기반으로 인벤토리 항목을 표시합니다. 시작 코드 구현의 일부로 빈 목록(listOf())이 구성 가능한 HomeBody() 함수에 전달됩니다. 이 컴포저블에 인벤토리 목록을 전달하려면 저장소에서 인벤토리 데이터를 가져와 HomeViewModel에 전달해야 합니다.

HomeViewModel에서 UI 상태 내보내기

항목을 가져오기 위해 ItemDao에 메서드(getItem()getAllItems())를 추가할 때 반환 유형으로 Flow를 지정했습니다. Flow는 일반 데이터 스트림을 나타냅니다. Flow를 반환하면 지정된 수명 주기 동안 DAO에서 메서드를 한 번만 명시적으로 호출하면 됩니다. Room은 기본 데이터의 업데이트를 비동기 방식으로 처리합니다.

흐름에서 데이터를 가져오는 것을 흐름에서 수집이라고 합니다. UI 레이어의 흐름에서 수집할 때는 몇 가지 사항을 고려해야 합니다.

  • 구성 변경과 같은 수명 주기 이벤트(예: 기기 회전)로 인해 활동이 다시 생성됩니다. 따라서 Flow에서 재구성 및 수집이 다시 이루어집니다.
  • 기존 데이터가 수명 주기 이벤트 간에 손실되지 않도록 값을 상태로 캐시하려고 합니다.
  • Flow는 컴포저블의 수명 주기가 종료된 후와 같이 남은 관찰자가 없는 경우 취소해야 합니다.

ViewModel에서 Flow를 노출할 때 권장되는 방법은 StateFlow를 사용하는 것입니다. StateFlow를 사용하면 UI 수명 주기와 관계없이 데이터를 저장하고 관찰할 수 있습니다. FlowStateFlow로 변환하려면 stateIn 연산자를 사용하세요.

stateIn 연산자에는 아래 설명된 세 가지 매개변수가 있습니다.

  • scope: viewModelScopeStateFlow의 수명 주기를 정의합니다. viewModelScope가 취소되면 StateFlow도 취소됩니다.
  • started: 파이프라인은 UI가 표시되는 경우에만 활성화해야 합니다. SharingStarted.WhileSubscribed()를 사용하면 됩니다. 마지막 구독자의 사라짐과 공유 코루틴 중지 사이의 지연(밀리초)을 구성하려면 TIMEOUT_MILLISSharingStarted.WhileSubscribed() 메서드에 전달합니다.
  • initialValue: 상태 흐름의 초깃값을 HomeUiState()로 설정합니다.

FlowStateFlow로 변환한 후에는 collectAsState() 메서드를 사용하여 수집할 수 있으므로 데이터를 동일한 유형의 State로 변환할 수 있습니다.

이 단계에서는 Room 데이터베이스의 모든 항목을 UI 상태의 관찰 가능한 StateFlow API로 검색합니다. Room 인벤토리 데이터가 변경되면 UI가 자동으로 업데이트됩니다.

  1. ui/home/HomeViewModel.kt 파일을 엽니다. 여기에는 항목 목록이 생성자 매개변수로 포함된 HomeUiState 데이터 클래스와 TIMEOUT_MILLIS 상수가 포함되어 있습니다.
// 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())
  1. HomeViewModel 클래스 내에서 StateFlow<HomeUiState> 유형의 homeUiState라는 val을 선언합니다. 초기화 오류는 곧 해결됩니다.
val homeUiState: StateFlow<HomeUiState>
  1. itemsRepository에서 getAllItemsStream()을 호출하여 방금 선언한 homeUiState에 할당합니다.
val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream()

이제 '해결되지 않은 참조: itemsRepository'라는 오류가 표시됩니다. 해결되지 않은 참조 오류를 해결하려면 ItemsRepository 객체를 HomeViewModel에 전달해야 합니다.

  1. ItemsRepository 유형의 생성자 매개변수를 HomeViewModel 클래스에 추가합니다.
import com.example.inventory.data.ItemsRepository

class HomeViewModel(itemsRepository: ItemsRepository): ViewModel() {
  1. ui/AppViewModelProvider.kt 파일의 HomeViewModel 이니셜라이저에서 다음과 같이 ItemsRepository 객체를 전달합니다.
initializer {
    HomeViewModel(inventoryApplication().container.itemsRepository)
}
  1. HomeViewModel.kt 파일로 돌아갑니다. 유형 불일치 오류가 있습니다. 이 문제를 해결하려면 다음과 같이 변환 맵을 추가하세요.
val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream().map { HomeUiState(it) }

Android 스튜디오에 여전히 유형 불일치 오류가 표시됩니다. 이 오류는 homeUiState의 유형이 StateFlow이고 getAllItemsStream()Flow를 반환하기 때문입니다.

  1. stateIn 연산자를 사용하여 FlowStateFlow로 변환합니다. StateFlow는 UI 상태의 관찰 가능한 API로, UI가 자체적으로 업데이트할 수 있도록 합니다.
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()
        )
  1. 앱을 빌드하여 코드에 오류가 없는지 확인합니다. 시각적 변경사항은 없습니다.

4. 인벤토리 데이터 표시

이 작업에서는 HomeScreen에서 UI 상태를 수집하고 업데이트합니다.

  1. HomeScreen.kt 파일의 구성 가능한 HomeScreen 함수에서 HomeViewModel 유형의 새 함수 매개변수를 추가하고 초기화합니다.
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)
)
  1. 구성 가능한 HomeScreen 함수에서 homeUiState라는 val을 추가하여 HomeViewModel에서 UI 상태를 수집합니다. 이 StateFlow에서 값을 수집하고 최신 값을 State를 통해 나타내는 collectAsState()를 사용합니다.
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

val homeUiState by viewModel.homeUiState.collectAsState()
  1. HomeBody() 함수 호출을 업데이트하고 homeUiState.itemListitemList 매개변수에 전달합니다.
HomeBody(
    itemList = homeUiState.itemList,
    onItemClick = navigateToItemUpdate,
    modifier = modifier.padding(innerPadding)
)
  1. 앱을 실행합니다. 앱 데이터베이스에 항목을 저장한 경우 인벤토리 목록이 표시됩니다. 목록이 비어 있다면 일부 인벤토리 항목을 앱 데이터베이스에 추가하세요.

인벤토리 항목이 있는 휴대전화 화면

5. 데이터베이스 테스트

이전 Codelab에서는 코드 테스트의 중요성을 설명했습니다. 이 작업에서는 DAO 쿼리를 테스트하는 단위 테스트를 추가하고 Codelab을 진행하면서 테스트를 더 추가합니다.

데이터베이스 구현을 테스트하는 데 권장되는 접근 방식은 Android 기기에서 실행되는 JUnit 테스트를 작성하는 것입니다. 이러한 테스트에서는 활동을 만들 필요가 없으므로 UI 테스트보다 실행 속도가 더 빠릅니다.

  1. build.gradle.kts (Module :app) 파일에서 Espresso 및 JUnit의 종속 항목을 확인합니다.
// Testing
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
  1. Project 뷰로 전환하고 src를 마우스 오른쪽 버튼으로 클릭한 후 New > Directory를 클릭하여 테스트용 테스트 소스 세트를 만듭니다.

e53b0f0e0b6aba29.png

  1. New Directory 팝업에서 androidTest/kotlin을 선택합니다.

860b7e1af5f116a.png

  1. Kotlin 클래스 ItemDaoTest.kt를 만듭니다.
  2. ItemDaoTest 클래스에 @RunWith(AndroidJUnit4::class) 주석을 답니다. 이제 클래스가 다음 코드 예와 비슷하게 표시됩니다.
package com.example.inventory

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ItemDaoTest {
}
  1. 클래스 내에서 ItemDaoInventoryDatabase 유형의 비공개 var 변수를 추가합니다.
import com.example.inventory.data.InventoryDatabase
import com.example.inventory.data.ItemDao

private lateinit var itemDao: ItemDao
private lateinit var inventoryDatabase: InventoryDatabase
  1. 데이터베이스를 만드는 함수를 추가하고 @Before로 주석을 달아 모든 테스트 전에 실행될 수 있도록 합니다.
  2. 메서드 내에서 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()
}

이 함수에서는 메모리 내 데이터베이스를 사용하며 이를 디스크에 유지하지 않습니다. 이를 위해 inMemoryDatabaseBuilder() 함수를 사용합니다. 이렇게 하는 이유는 프로세스가 종료될 때 정보가 유지되는 것이 아니라 삭제되어야 하기 때문입니다. 테스트용으로 기본 스레드에서 .allowMainThreadQueries()를 사용하여 DAO 쿼리를 실행합니다.

  1. 데이터베이스를 닫는 다른 함수를 추가합니다. @After로 주석을 달아 데이터베이스를 닫고 모든 테스트 후에 이 함수를 실행합니다.
import org.junit.After
import java.io.IOException

@After
@Throws(IOException::class)
fun closeDb() {
    inventoryDatabase.close()
}
  1. 다음 코드 예와 같이 데이터베이스에서 사용할 ItemDaoTest 클래스의 항목을 선언합니다.
import com.example.inventory.data.Item

private var item1 = Item(1, "Apples", 10.0, 20)
private var item2 = Item(2, "Bananas", 15.0, 97)
  1. 데이터베이스에 항목 하나를 추가한 후 항목 두 개를 추가하는 유틸리티 함수를 추가합니다. 나중에 이러한 함수를 테스트에서 사용합니다. 코루틴에서 실행될 수 있도록 suspend로 표시합니다.
private suspend fun addOneItemToDb() {
    itemDao.insert(item1)
}

private suspend fun addTwoItemsToDb() {
    itemDao.insert(item1)
    itemDao.insert(item2)
}
  1. 단일 항목을 데이터베이스에 삽입하는 테스트 insert()를 작성합니다. 테스트 이름을 daoInsert_insertsItemIntoDB로 지정하고 @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)
}

이 테스트에서는 유틸리티 함수 addOneItemToDb()를 사용하여 데이터베이스에 항목을 하나 추가합니다. 그런 다음 데이터베이스의 첫 번째 항목을 읽습니다. assertEquals()를 사용하면 예상값을 실제 값과 비교합니다. runBlocking{}을 사용하여 새 코루틴에서 테스트를 실행합니다. 이 설정으로 인해 유틸리티 함수를 suspend로 표시합니다.

  1. 테스트를 실행하고 통과하는지 확인합니다.

cd95648114520f13.png

6521e8595bb33a91.png

  1. 데이터베이스에서 getAllItems()의 다른 테스트를 작성합니다. 테스트 이름을 daoGetAllItems_returnsAllItemsFromDB로 지정합니다.
@Test
@Throws(Exception::class)
fun daoGetAllItems_returnsAllItemsFromDB() = runBlocking {
    addTwoItemsToDb()
    val allItems = itemDao.getAllItems().first()
    assertEquals(allItems[0], item1)
    assertEquals(allItems[1], item2)
}

위 테스트에서는 코루틴 내에서 데이터베이스에 항목을 두 개 추가합니다. 그런 다음 두 항목을 읽고 예상값과 비교합니다.

6. 항목 세부정보 표시

이 작업에서는 Items Details 화면에서 항목 세부정보를 읽고 표시합니다. Inventory 앱 데이터베이스에서 이름, 가격, 수량 등 항목 UI 상태를 사용하고, ItemDetailsScreen 컴포저블을 사용하여 Item Details 화면에 표시합니다. 구성 가능한 함수 ItemDetailsScreen은 미리 작성되어 있으며 항목 세부정보를 표시하는 구성 가능한 함수 Text 세 개를 포함합니다.

ui/item/ItemDetailsScreen.kt

이 화면은 시작 코드의 일부이며 항목의 세부정보를 표시합니다. 이는 이후 Codelab에서 확인할 수 있습니다. 이 Codelab에서는 이 화면으로 작업하지 않습니다. ItemDetailsViewModel.kt는 이 화면에 상응하는 ViewModel입니다.

a5009ad021b830ff.png

  1. 구성 가능한 함수 HomeScreen에서 HomeBody() 함수 호출을 확인합니다. navigateToItemUpdate는 목록에서 항목을 클릭할 때 호출되는 onItemClick 매개변수에 전달됩니다.
// No need to copy over
HomeBody(
    itemList = homeUiState.itemList,
    onItemClick = navigateToItemUpdate,
    modifier = modifier
        .padding(innerPadding)
        .fillMaxSize()
)
  1. ui/navigation/InventoryNavGraph.kt를 열고 HomeScreen 컴포저블에서 navigateToItemUpdate 매개변수를 확인합니다. 이 매개변수는 탐색 대상을 항목 세부정보 화면으로 지정합니다.
// No need to copy over
HomeScreen(
    navigateToItemEntry = { navController.navigate(ItemEntryDestination.route) },
    navigateToItemUpdate = {
        navController.navigate("${ItemDetailsDestination.route}/${it}")
   }

onItemClick 기능의 이 부분은 이미 구현되어 있습니다. 목록 항목을 클릭하면 앱이 항목 세부정보 화면으로 이동합니다.

  1. 인벤토리 목록에서 항목을 클릭하면 필드가 비어 있는 항목 세부정보 화면이 표시됩니다.

필드가 비어 있는 항목 세부정보 화면

텍스트 필드를 항목 세부정보로 채우려면 ItemDetailsScreen()에서 UI 상태를 수집해야 합니다.

  1. UI/Item/ItemDetailsScreen.kt에서 ItemDetailsViewModel 유형의 ItemDetailsScreen 컴포저블에 새 매개변수를 추가하고 팩토리 메서드를 사용하여 초기화합니다.
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)
)
  1. ItemDetailsScreen() 컴포저블 내에서 uiState라는 val을 만들어 UI 상태를 수집합니다. collectAsState()를 사용하여 uiState StateFlow를 수집하고 State를 통해 최신 값을 나타냅니다. Android 스튜디오에 해결되지 않은 참조 오류가 표시됩니다.
import androidx.compose.runtime.collectAsState

val uiState = viewModel.uiState.collectAsState()
  1. 이 오류를 해결하려면 ItemDetailsViewModel 클래스에서 StateFlow<ItemDetailsUiState> 유형의 uiState라는 val을 만듭니다.
  2. 항목 저장소에서 데이터를 가져와 확장 함수 toItemDetails()를 사용하여 ItemDetailsUiState에 매핑합니다. 확장 함수 Item.toItemDetails()는 시작 코드의 일부로 이미 작성되어 있습니다.
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()
             )
  1. ItemsRepositoryItemDetailsViewModel에 전달하여 Unresolved reference: itemsRepository 오류를 해결합니다.
class ItemDetailsViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
    ) : ViewModel() {
  1. ui/AppViewModelProvider.kt에서 다음 코드 스니펫과 같이 ItemDetailsViewModel의 이니셜라이저를 업데이트합니다.
initializer {
    ItemDetailsViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. ItemDetailsScreen.kt로 돌아가서 ItemDetailsScreen() 컴포저블의 오류가 해결되었음을 확인합니다.
  2. ItemDetailsScreen() 컴포저블에서 ItemDetailsBody() 함수 호출을 업데이트하고 uiState.valueitemUiState 인수에 전달합니다.
ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = {  },
    onDelete = { },
    modifier = modifier.padding(innerPadding)
)
  1. ItemDetailsBody()ItemInputForm()의 구현을 확인합니다. 현재 선택된 itemItemDetailsBody()에서 ItemDetails()로 전달합니다.
// 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()
         )

      //...
    }
  1. 앱을 실행합니다. Inventory 화면에서 목록 요소를 클릭하면 Item Details 화면이 표시됩니다.
  2. 화면이 더 이상 비어 있지 않습니다. 인벤토리 데이터베이스에서 가져온 항목 세부정보가 표시됩니다.

항목 세부정보가 채워진 항목 세부정보 화면

  1. Sell 버튼을 탭합니다. 아무 일도 일어나지 않습니다.

다음 섹션에서는 Sell 버튼의 기능을 구현합니다.

7. 항목 세부정보 화면 구현

ui/item/ItemEditScreen.kt

항목 수정 화면은 이미 시작 코드의 일부로 제공되어 있습니다.

이 레이아웃에는 새 인벤토리 항목의 세부정보를 수정할 수 있는 텍스트 필드의 구성 가능한 함수가 포함되어 있습니다.

필드가 비어 있는 항목 수정 화면

이 앱의 코드는 아직 완전히 작동하지 않습니다. 예를 들어 Item Details 화면에서 Sell 버튼을 탭해도 Quantity in Stock이 감소하지 않습니다. Delete 버튼을 탭하면 앱에 확인 대화상자가 표시됩니다. 하지만 Yes 버튼을 선택해도 앱은 실제로 항목을 삭제하지 않습니다.

항목 삭제 확인 팝업

이제 FAB 버튼 be6c7ed4ac207351.png을 누르면 빈 Edit Item 화면이 열립니다.

필드가 비어 있는 항목 수정 화면

이 섹션에서는 Sell 버튼과 Delete 버튼, FAB 버튼의 기능을 구현합니다.

8. 판매 항목 구현

이 섹션에서는 앱의 기능을 확장하여 판매 기능을 구현합니다. 이 업데이트에는 다음 작업이 포함됩니다.

  • 항목을 업데이트하는 DAO 함수의 테스트를 추가합니다.
  • ItemDetailsViewModel에 함수를 추가하여 수량을 줄이고 앱 데이터베이스의 항목을 업데이트합니다.
  • 수량이 0이면 Sell 버튼을 사용 중지합니다.
  1. ItemDaoTest.kt에서 매개변수 없이 daoUpdateItems_updatesItemsInDB() 함수를 추가합니다. @Test@Throws(Exception::class) 주석을 답니다.
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB()
  1. 함수를 정의하고 runBlocking 블록을 만듭니다. 블록 안에서 addTwoItemsToDb()를 호출합니다.
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
    addTwoItemsToDb()
}
  1. itemDao.update를 호출하여 값이 다른 두 항목을 업데이트합니다.
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
  1. itemDao.getAllItems()를 사용하여 항목을 가져옵니다. 가져온 항목을 업데이트된 항목과 비교하고 어설션합니다.
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
  1. 완성된 함수가 다음과 같은지 확인합니다.
@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))
}
  1. 테스트를 실행하고 통과하는지 확인합니다.

ViewModel에 함수 추가

  1. ItemDetailsViewModel.ktItemDetailsViewModel 클래스 내에서 매개변수 없이 reduceQuantityByOne() 함수를 추가합니다.
fun reduceQuantityByOne() {
}
  1. 함수 내에서 viewModelScope.launch{}를 사용하여 코루틴을 시작합니다.
import kotlinx.coroutines.launch
import androidx.lifecycle.viewModelScope

viewModelScope.launch {
}
  1. launch 블록 내에서 currentItem이라는 val을 만들고 uiState.value.toItem()으로 설정합니다.
val currentItem = uiState.value.toItem()

uiState.valueItemUiState 유형입니다. 확장 함수 toItem()을 사용하여 Item 항목 유형으로 변환합니다.

  1. if 문을 추가하여 quality0보다 큰지 확인합니다.
  2. itemsRepository에서 updateItem()을 호출하고 업데이트된 currentItem을 전달합니다. 함수가 다음과 같이 표시되도록 copy()를 사용하여 quantity 값을 업데이트합니다.
fun reduceQuantityByOne() {
    viewModelScope.launch {
        val currentItem = uiState.value.itemDetails.toItem()
        if (currentItem.quantity > 0) {
    itemsRepository.updateItem(currentItem.copy(quantity = currentItem.quantity - 1))
       }
    }
}
  1. ItemDetailsScreen.kt로 돌아갑니다.
  2. ItemDetailsScreen 컴포저블에서 ItemDetailsBody() 함수 호출로 이동합니다.
  3. onSellItem 람다에서 viewModel.reduceQuantityByOne()을 호출합니다.
ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = { viewModel.reduceQuantityByOne() },
    onDelete = { },
    modifier = modifier.padding(innerPadding)
)
  1. 앱을 실행합니다.
  2. Inventory 화면에서 목록 요소를 클릭합니다. Item Details 화면이 표시되면 Sell을 탭하고 수량이 1씩 감소하는 것을 확인합니다.

판매 버튼을 탭하면 1씩 수량이 줄어드는 항목 세부정보 화면

  1. Item Details 화면에서 수량이 0이 될 때까지 Sell 버튼을 계속 탭합니다.

수량이 0에 도달하면 Sell을 다시 탭합니다. 시각적 변경사항이 없습니다. reduceQuantityByOne() 함수는 수량을 업데이트하기 전에 수량이 0보다 큰지 확인하기 때문입니다.

재고 수량이 0인 항목 세부정보 화면

사용자에게 더 나은 피드백을 제공하려면 판매할 항목이 없을 때 Sell 버튼을 사용 중지하는 것이 좋습니다.

  1. ItemDetailsViewModel 클래스에서 map 변환의 it.quantity에 따라 outOfStock 값을 설정합니다.
val uiState: StateFlow<ItemDetailsUiState> =
    itemsRepository.getItemStream(itemId)
        .filterNotNull()
        .map {
            ItemDetailsUiState(outOfStock = it.quantity <= 0, itemDetails = it.toItemDetails())
        }.stateIn(
            //...
        )
  1. 앱을 실행합니다. 재고 수량이 0일 때 앱이 Sell 버튼을 사용 중지합니다.

판매 버튼이 사용 중지된 항목 세부정보 화면

축하합니다. 앱에 항목 판매 기능을 구현했습니다.

항목 삭제

이전 작업과 마찬가지로 삭제 기능을 구현하여 앱의 기능을 더 확장해야 합니다. 이 기능은 판매 기능보다 훨씬 쉽게 구현할 수 있습니다. 이 프로세스에는 다음 작업이 포함됩니다.

  • DAO 삭제 쿼리 테스트를 추가합니다.
  • ItemDetailsViewModel 클래스에 함수를 추가하여 데이터베이스에서 항목을 삭제합니다.
  • ItemDetailsBody 컴포저블을 업데이트합니다.

DAO 테스트 추가

  1. ItemDaoTest.kt에서 daoDeleteItems_deletesAllItemsFromDB()라는 테스트를 추가합니다.
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB()
  1. runBlocking {}을 사용하여 코루틴을 실행합니다.
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
}
  1. 데이터베이스에 항목 두 개를 추가하고 이 두 항목에서 itemDao.delete()를 호출하여 데이터베이스에서 항목을 삭제합니다.
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
  1. 데이터베이스에서 항목을 검색하고 목록이 비어 있는지 확인합니다. 완성된 테스트는 다음과 같습니다.
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())
}

ItemDetailsViewModel에 삭제 함수 추가

  1. ItemDetailsViewModel에서 매개변수를 사용하지 않고 아무것도 반환하지 않는 deleteItem()이라는 새 함수를 추가합니다.
  2. deleteItem() 함수 내에서 itemsRepository.deleteItem() 함수 호출을 추가하고 uiState.value.toItem()을 전달합니다.
suspend fun deleteItem() {
    itemsRepository.deleteItem(uiState.value.itemDetails.toItem())
}

이 함수에서는 toItem() 확장 함수를 사용하여 uiStateitemDetails 유형에서 Item 항목 유형으로 변환합니다.

  1. ui/item/ItemDetailsScreen 컴포저블에서 coroutineScope라는 val을 추가하고 rememberCoroutineScope()로 설정합니다. 이 접근 방식은 호출되는 컴포지션에 바인딩된 코루틴 범위를 반환합니다(ItemDetailsScreen 컴포저블).
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. ItemDetailsBody() 함수로 스크롤합니다.
  2. onDelete 람다 내에서 coroutineScope를 사용하여 코루틴을 실행합니다.
  3. launch 블록 내 viewModel에서 deleteItem() 메서드를 호출합니다.
import kotlinx.coroutines.launch

ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = { viewModel.reduceQuantityByOne() },
    onDelete = {
        coroutineScope.launch {
           viewModel.deleteItem()
    }
    modifier = modifier.padding(innerPadding)
)
  1. 항목을 삭제한 후 인벤토리 화면으로 다시 이동합니다.
  2. deleteItem() 함수 호출 뒤에 navigateBack()을 호출합니다.
onDelete = {
    coroutineScope.launch {
        viewModel.deleteItem()
        navigateBack()
    }
  1. 계속 ItemDetailsScreen.kt 파일 내에서 ItemDetailsBody() 함수로 스크롤합니다.

이 함수는 시작 코드의 일부입니다. 이 컴포저블은 항목을 삭제하기 전에 사용자의 확인을 받는 알림 대화상자를 표시하고 Yes를 탭하면 deleteItem() 함수를 호출합니다.

// 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()
                },
                //...
            )
        }
    }
}

No를 탭하면 앱에서 알림 대화상자를 닫습니다. showConfirmationDialog() 함수는 다음 알림을 표시합니다.

항목 삭제 확인 팝업

  1. 앱을 실행합니다.
  2. Inventory 화면에서 목록 요소를 선택합니다.
  3. Item Details 화면에서 Delete를 탭합니다.
  4. 알림 대화상자에서 Yes를 탭하면 앱이 Inventory 화면으로 다시 이동합니다.
  5. 삭제한 항목은 더 이상 앱 데이터베이스에 없습니다.

축하합니다. 삭제 기능을 구현했습니다.

알림 대화상자 창이 있는 항목 세부정보 화면

삭제된 항목이 없는 인벤토리 화면

항목 수정

이전 섹션과 마찬가지로 이 섹션에서는 항목을 수정하는 또 다른 기능 개선사항을 앱에 추가합니다.

다음은 앱 데이터베이스에서 항목을 수정하는 이 단계를 간략히 요약한 것입니다.

  • 항목 가져오기 DAO 쿼리 테스트에 테스트를 추가합니다.
  • 텍스트 필드와 Edit Item 화면을 항목 세부정보로 채웁니다.
  • Room을 사용하여 데이터베이스의 항목을 업데이트합니다.

DAO 테스트 추가

  1. ItemDaoTest.kt에서 daoGetItem_returnsItemFromDB()라는 테스트를 추가합니다.
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB()
  1. 함수를 정의합니다. 코루틴 내에서 데이터베이스에 항목 하나를 추가합니다.
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
    addOneItemToDb()
}
  1. itemDao.getItem() 함수를 사용하여 데이터베이스에서 항목을 가져와 item이라는 val로 설정합니다.
val item = itemDao.getItem(1)
  1. 실제 값을 가져온 값과 비교하고 assertEquals()를 사용하여 어설션합니다. 완성된 테스트는 다음과 같습니다.
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
    addOneItemToDb()
    val item = itemDao.getItem(1)
    assertEquals(item.first(), item1)
}
  1. 테스트를 실행하고 통과하는지 확인합니다.

텍스트 필드 채우기

앱을 실행하고 Item Details 화면으로 이동하여 FAB를 클릭하면 이제 화면 제목이 Edit Item인 것을 확인할 수 있습니다. 그러나 모든 텍스트 필드가 비어 있습니다. 이 단계에서는 Edit Item 화면의 텍스트 필드를 항목 세부정보로 채웁니다.

판매 버튼이 사용 중지된 항목 세부정보 화면

필드가 비어 있는 항목 수정 화면

  1. ItemDetailsScreen.kt에서 구성 가능한 함수 ItemDetailsScreen으로 스크롤합니다.
  2. FloatingActionButton()에서, 선택된 항목의 iduiState.value.itemDetails.id를 포함하도록 onClick 인수를 변경합니다. 이 id를 사용하여 항목 세부정보를 검색합니다.
FloatingActionButton(
    onClick = { navigateToEditItem(uiState.value.itemDetails.id) },
    modifier = /*...*/
)
  1. ItemEditViewModel 클래스에서 init 블록을 추가합니다.
init {

}
  1. init 블록 내에서 viewModelScope.launch를 사용하여 코루틴을 실행합니다.
import kotlinx.coroutines.launch

viewModelScope.launch { }
  1. launch 블록 내에서 itemsRepository.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)
    }
}

이 출시 블록에서는 null이 아닌 값만 포함된 흐름을 반환하는 필터를 추가합니다. toItemUiState()를 사용하여 item 항목을 ItemUiState로 변환합니다. actionEnabled 값을 true로 전달하여 Save 버튼을 사용 설정합니다.

Unresolved reference: itemsRepository 오류를 해결하려면 ItemsRepository를 종속 항목으로 뷰 모델에 전달해야 합니다.

  1. ItemEditViewModel 클래스에 생성자 매개변수를 추가합니다.
class ItemEditViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
)
  1. AppViewModelProvider.kt 파일의 ItemEditViewModel 이니셜라이저에서 ItemsRepository 객체를 인수로 추가합니다.
initializer {
    ItemEditViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. 앱을 실행합니다.
  2. Item Details로 이동하여 2ae4a1588eba091b.png FAB를 탭합니다.
  3. 필드가 항목 세부정보로 채워집니다.
  4. 재고 수량이나 다른 필드를 수정하고 Save를 탭합니다.

아무 일도 일어나지 않습니다. 앱 데이터베이스의 항목을 업데이트하지 않기 때문입니다. 다음 섹션에서 이 문제를 해결합니다.

판매 버튼이 사용 중지된 항목 세부정보 화면

필드가 비어 있는 항목 수정 화면

Room을 사용하여 항목 업데이트

이 마지막 작업에서는 최종 코드를 추가하여 업데이트 기능을 구현합니다. ViewModel에서 필요한 함수를 정의하고 이를 ItemEditScreen에서 사용합니다.

이제 코드를 작성해보겠습니다.

  1. ItemEditViewModel 클래스에서 ItemUiState 객체를 사용하고 아무것도 반환하지 않는 updateUiState() 함수를 추가합니다. 이 함수는 사용자가 입력하는 새 값으로 itemUiState를 업데이트합니다.
fun updateUiState(itemDetails: ItemDetails) {
    itemUiState =
        ItemUiState(itemDetails = itemDetails, isEntryValid = validateInput(itemDetails))
}

이 함수에서, 전달된 itemDetailsitemUiState에 할당하고 isEntryValid 값을 업데이트합니다. 앱에서는 itemDetailstrue이면 Save 버튼을 사용 설정합니다. 이 값은 사용자가 입력한 내용이 유효한 경우에만 true로 설정합니다.

  1. ItemEditScreen.kt 파일로 이동합니다.
  2. ItemEditScreen 컴포저블에서 ItemEntryBody() 함수 호출까지 아래로 스크롤합니다.
  3. onItemValueChange 인수 값을 새 함수 updateUiState로 설정합니다.
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = modifier.padding(innerPadding)
)
  1. 앱을 실행합니다.
  2. Edit Item 화면으로 이동합니다.
  3. 항목 값 중 하나를 비워 유효하지 않도록 합니다. Save 버튼이 자동으로 사용 중지되는 방식을 확인하세요.

판매 버튼이 사용 설정된 항목 세부정보 화면

모든 텍스트 필드가 입력되어 있고 저장 버튼이 사용 설정된 항목 수정 화면

저장 버튼이 사용 중지된 항목 수정 화면

  1. ItemEditViewModel 클래스로 돌아가서 아무것도 사용하지 않는 updateItem()이라는 suspend 함수를 추가합니다. 이 함수를 사용하여 업데이트된 항목을 Room 데이터베이스에 저장합니다.
suspend fun updateItem() {
}
  1. getUpdatedItemEntry() 함수 내에서 validateInput() 함수를 사용하여 사용자 입력을 검증하는 if 조건을 추가합니다.
  2. itemsRepository에서 updateItem() 함수를 호출하여 itemUiState.itemDetails.toItem()을 전달합니다. Room 데이터베이스에 추가할 수 있는 항목은 Item 유형이어야 합니다. 완성된 함수는 다음과 같습니다.
suspend fun updateItem() {
    if (validateInput(itemUiState.itemDetails)) {
        itemsRepository.updateItem(itemUiState.itemDetails.toItem())
    }
}
  1. ItemEditScreen 컴포저블로 돌아갑니다. updateItem() 함수를 호출하려면 코루틴 범위가 필요합니다. coroutineScope라는 val을 만들고 rememberCoroutineScope()로 설정합니다.
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. ItemEntryBody() 함수 호출에서 onSaveClick 함수 인수를 업데이트하여 coroutineScope에서 코루틴을 시작합니다.
  2. launch 블록 내 viewModel에서 updateItem()을 호출하고 뒤로 이동합니다.
import kotlinx.coroutines.launch

onSaveClick = {
    coroutineScope.launch {
        viewModel.updateItem()
        navigateBack()
    }
},

완성된 ItemEntryBody() 함수 호출은 다음과 같습니다.

ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = {
        coroutineScope.launch {
            viewModel.updateItem()
            navigateBack()
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. 앱을 실행하고 인벤토리 항목을 수정해 봅니다. 이제 Inventory 앱 데이터베이스의 모든 항목을 수정할 수 있습니다.

항목 세부정보가 수정된 항목 수정 화면

업데이트된 항목 세부정보가 있는 항목 세부정보 화면

축하합니다. Room을 사용하여 데이터베이스를 관리하는 첫 번째 앱을 만들었습니다.

9. 솔루션 코드

이 Codelab의 솔루션 코드는 아래와 같이 GitHub 저장소와 브랜치에 있습니다.

10. 자세히 알아보기

Android 개발자 문서

Kotlin 참조