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

1. 시작하기 전에

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

기본 요건

  • Room 라이브러리를 사용하여 SQLite 데이터베이스를 만들고 상호작용하는 방법을 알아야 합니다.
  • 항목, DAO, 데이터베이스 클래스를 만드는 방법을 알아야 합니다.
  • 데이터 액세스 객체(DAO)를 사용하여 Kotlin 함수를 SQL 쿼리에 매핑하는 방법을 알아야 합니다.
  • RecyclerView에 목록 항목을 표시하는 방법을 알아야 합니다.
  • 이 단원의 이전 Codelab인 Room을 사용하여 데이터 유지를 확인해야 합니다.

학습할 내용

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

빌드할 항목

  • 인벤토리 항목 목록을 표시하는 Inventory 앱을 빌드합니다. 앱은 Room을 사용하여 앱 데이터베이스의 항목을 업데이트하고 수정하며 삭제할 수 있습니다.

2. 스타터 앱 개요

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

참고: 현재 버전의 시작 앱에는 데이터베이스에 저장된 날짜가 표시되지 않습니다.

771c6a677ecd96c7.png

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

이 Codelab의 시작 코드 다운로드

이 시작 코드는 이전 Codelab의 솔루션 코드와 같습니다.

이 Codelab의 코드를 가져와서 Android 스튜디오에서 열려면 다음을 실행합니다.

코드 가져오기

  1. 제공된 URL을 클릭합니다. 브라우저에서 프로젝트의 GitHub 페이지가 열립니다.
  2. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 대화상자를 엽니다.

5b0a76c50478a73f.png

  1. 대화상자에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
  2. 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
  3. ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.

Android 스튜디오에서 프로젝트 열기

  1. Android 스튜디오를 시작합니다.
  2. Welcome to Android Studio 창에서 Open an existing Android Studio project를 클릭합니다.

36cc44fcf0f89a1d.png

참고: Android 스튜디오가 이미 열려 있는 경우 File > New > Import Project 메뉴 옵션을 대신 선택합니다.

21f3eec988dcfbe9.png

  1. Import Project 대화상자에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 11c34fc5e516fb1c.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.
  5. Project 도구 창에서 프로젝트 파일을 살펴보고 앱이 구현된 방식을 확인합니다.

3. RecyclerView 추가

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

가격 형식을 지정하는 도우미 함수 추가

다음은 최종 앱의 스크린샷입니다.

d6e7b7b9f12e7a16.png

가격이 통화 형식으로 표시되어 있습니다. double 값을 원하는 통화 형식으로 변환하려면 확장 함수를 Item 클래스에 추가합니다.

확장 함수

Kotlin은 클래스에서 상속받거나 기존 클래스 정의를 수정하지 않고도 새로운 기능으로 클래스를 확장하는 기능을 제공합니다. 즉, 소스 코드에 액세스하지 않고도 함수를 기존 클래스에 추가할 수 있습니다. 확장이라는 특수 선언을 통해 실행됩니다.

예를 들어 수정할 수 없는 타사 라이브러리의 클래스에 관한 새 함수를 작성할 수 있습니다. 이러한 함수는 원래 클래스의 메서드인 것처럼 일반적인 방식으로 호출할 수 있습니다. 이러한 함수를 확장 함수라고 합니다. 기존 클래스의 새 속성을 정의할 수 있는 확장 속성도 있지만 이 Codelab의 범위를 벗어나는 내용입니다.

확장 함수는 실제로 클래스를 수정하지는 않지만 클래스의 객체에서 함수를 호출할 때 점 표기법을 사용할 수 있도록 합니다.

예를 들어 다음 코드 스니펫에는 Square라는 클래스가 있습니다. 이 클래스에는 변의 속성과 정사각형의 면적을 계산하는 함수가 있습니다. Square.perimeter() 확장 함수를 살펴보세요. 이 함수 이름에는 함수가 작동하는 클래스가 앞에 붙습니다. 함수 내에서 Square 클래스의 공개 속성을 참조할 수 있습니다.

main() 함수에서 확장 함수 사용법을 확인합니다. 만들어진 확장 함수인 perimeter()Square 클래스 내에서 일반 함수로 호출됩니다.

예:

class Square(val side: Double){
        fun area(): Double{
        return side * side;
    }
}

// Extension function to calculate the perimeter of the square
fun Square.perimeter(): Double{
        return 4 * side;
}

// Usage
fun main(args: Array<String>){
      val square = Square(5.5);
      val perimeterValue = square.perimeter()
      println("Perimeter: $perimeterValue")
      val areaValue = square.area()
      println("Area: $areaValue")
}

이 단계에서는 항목 가격의 형식을 통화 형식 문자열로 지정합니다. 일반적으로 단지 데이터의 형식을 지정하려고 데이터를 나타내는 항목 클래스를 변경하지는 않으므로(단일 책임 원칙 참고) 대신 확장 함수를 추가합니다.

  1. Item.kt에서 클래스 정의 아래에 매개변수를 사용하지 않고 String을 반환하는 Item.getFormattedPrice()라는 확장 함수를 추가합니다. 함수 이름에서 클래스 이름과 점 표기법을 확인합니다.
fun Item.getFormattedPrice(): String =
   NumberFormat.getCurrencyInstance().format(itemPrice)

Android 스튜디오에서 메시지가 표시되면 java.text.NumberFormat을 가져옵니다.

ListAdapter 추가

이 단계에서는 목록 어댑터를 RecyclerView에 추가합니다. 이전 Codelab을 통해 어댑터 구현을 잘 알고 있으므로 아래에 안내가 요약되어 있습니다. 완성된 ItemListAdapter 파일은 편의를 위해 이 단계의 끝부분에 있으며 Codelab의 Room 개념에 관한 이해를 높이는 데 유용합니다.

  1. com.example.inventory 패키지에서 ItemListAdapter라는 Kotlin 클래스를 추가합니다. onItemClicked()라는 함수를 Item 객체를 매개변수로 가져오는 생성자 매개변수로 전달합니다.
  2. ItemListAdapter 클래스 서명을 변경하여 ListAdapter를 확장합니다. ItemItemListAdapter.ItemViewHolder를 매개변수로 전달합니다.
  3. 생성자 매개변수 DiffCallback을 추가합니다. ListAdapter는 이를 사용하여 목록에서 변경된 내용을 파악합니다.
  4. 필수 메서드 onCreateViewHolder()onBindViewHolder()를 재정의합니다.
  5. onCreateViewHolder() 메서드는 RecyclerView에 필요할 때 새 ViewHolder를 반환합니다.
  6. onCreateViewHolder() 메서드 내에서 새 View를 만들고 자동 생성된 바인딩 클래스 ItemListItemBinding을 사용하여 item_list_item.xml 레이아웃 파일에서 이를 확장합니다.
  7. onBindViewHolder() 메서드를 구현합니다. getItem() 메서드를 사용하여 현재 항목을 가져와 위치를 전달합니다.
  8. itemView에서 클릭 리스너를 설정하고 리스너 내에서 onItemClicked() 함수를 호출합니다.
  9. ItemViewHolder 클래스를 정의하고 RecyclerView.ViewHolder.에서 확장합니다. bind() 함수를 재정의하고 Item 객체를 전달합니다.
  10. 컴패니언 객체를 정의합니다. 컴패니언 객체 내에서 DiffCallback이라는 DiffUtil.ItemCallback<Item>() 유형의 val을 정의합니다. 필수 메서드 areItemsTheSame()areContentsTheSame()을 재정의하고 정의합니다.

완성된 클래스는 다음과 같습니다.

package com.example.inventory

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.inventory.data.Item
import com.example.inventory.data.getFormattedPrice
import com.example.inventory.databinding.ItemListItemBinding

/**
* [ListAdapter] implementation for the recyclerview.
*/

class ItemListAdapter(private val onItemClicked: (Item) -> Unit) :
   ListAdapter<Item, ItemListAdapter.ItemViewHolder>(DiffCallback) {

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
       return ItemViewHolder(
           ItemListItemBinding.inflate(
               LayoutInflater.from(
                   parent.context
               )
           )
       )
   }

   override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
       val current = getItem(position)
       holder.itemView.setOnClickListener {
           onItemClicked(current)
       }
       holder.bind(current)
   }

   class ItemViewHolder(private var binding: ItemListItemBinding) :
       RecyclerView.ViewHolder(binding.root) {

       fun bind(item: Item) {

       }
   }

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

           override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
               return oldItem.itemName == newItem.itemName
           }
       }
   }
}

완성된 앱(이 Codelab의 끝에 있는 솔루션 앱)에서 인벤토리 목록 화면을 확인합니다. 모든 목록 요소에 인벤토리 항목 이름과 통화 형식의 가격, 사용할 수 있는 현재 재고가 표시되어 있습니다. 이전 단계에서는 TextView가 세 개 있는 item_list_item.xml 레이아웃 파일을 사용하여 행을 만들었습니다. 다음 단계에서는 항목 세부정보를 이러한 TextView에 바인딩합니다.

9c416f2fbf1e5ae2.png

  1. ItemListAdapter.ktItemViewHolder 클래스에서 bind() 함수를 구현합니다. itemName TextView를 item.itemName에 바인딩합니다. getFormattedPrice() 확장 함수를 사용하여 통화 형식의 가격을 가져와 itemPrice TextView에 바인딩합니다. quantityInStock 값을 String으로 변환하여 itemQuantity TextView에 바인딩합니다. 완성된 메서드는 다음과 같습니다.
fun bind(item: Item) {
   binding.apply {
       itemName.text = item.itemName
       itemPrice.text = item.getFormattedPrice()
       itemQuantity.text = item.quantityInStock.toString()
   }
}

Android 스튜디오에서 메시지가 표시되면 com.example.inventory.data.getFormattedPrice를 가져옵니다.

ListAdapter 사용

이 작업에서는 이전 단계에서 만든 목록 어댑터를 사용하여 화면에 항목 세부정보를 표시하도록 InventoryViewModelItemListFragment를 업데이트합니다.

  1. InventoryViewModel 클래스 시작 부분에 데이터베이스의 항목에 관한 LiveData<List<Item>> 유형의 allItems라는 val을 만듭니다. 오류는 걱정하지 마세요. 곧 수정합니다.
val allItems: LiveData<List<Item>>

Android 스튜디오에서 메시지가 표시되면 androidx.lifecycle.LiveData를 가져옵니다.

  1. itemDao에서 getItems()를 호출하여 allItems에 할당합니다. getItems() 함수는 Flow를 반환합니다. 데이터를 LiveData 값으로 사용하려면 asLiveData() 함수를 사용합니다. 완성된 정의는 다음과 같습니다.
val allItems: LiveData<List<Item>> = itemDao.getItems().asLiveData()

Android 스튜디오에서 메시지가 표시되면 androidx.lifecycle.asLiveData를 가져옵니다.

  1. ItemListFragment에서 클래스 시작 부분에 InventoryViewModel 유형의 viewModel이라는 변경 불가능한 private 속성을 선언합니다. by 위임을 사용하여 속성 초기화를 activityViewModels 클래스에 전달합니다. InventoryViewModelFactory 생성자를 전달합니다.
private val viewModel: InventoryViewModel by activityViewModels {
   InventoryViewModelFactory(
       (activity?.application as InventoryApplication).database.itemDao()
   )
}

Android 스튜디오에서 요청하면 androidx.fragment.app.activityViewModels를 가져옵니다.

  1. 여전히 ItemListFragment 내에서 onViewCreated() 함수로 스크롤합니다. super.onViewCreated() 호출 아래에서 adapter라는 val을 선언합니다. 아무것도 전달하지 않는 기본 생성자 ItemListAdapter{}를 사용하여 새 adapter 속성을 초기화합니다.
  2. 새로 만든 adapter를 다음과 같이 recyclerView에 바인딩합니다.
val adapter = ItemListAdapter {
}
binding.recyclerView.adapter = adapter
  1. 어댑터를 설정한 후에도 여전히 onViewCreated() 내에 있습니다. allItems에 관찰자를 연결하여 데이터 변경사항을 수신 대기합니다.
  2. 관찰자 내 adapter에서 submitList()를 호출하고 새 목록을 전달합니다. 그러면 새 항목이 목록에 포함되어 RecyclerView가 업데이트됩니다.
viewModel.allItems.observe(this.viewLifecycleOwner) { items ->
   items.let {
       adapter.submitList(it)
   }
}
  1. 완성된 onViewCreated() 메서드가 다음과 같이 표시되는지 확인합니다. 앱을 실행합니다. 항목이 앱 데이터베이스에 저장된 경우 인벤토리 목록이 표시됩니다. 목록이 비어 있으면 앱 데이터베이스에 인벤토리 항목을 추가합니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)

   val adapter = ItemListAdapter {
      }
   binding.recyclerView.adapter = adapter
   viewModel.allItems.observe(this.viewLifecycleOwner) { items ->
       items.let {
           adapter.submitList(it)
       }
   }
   binding.recyclerView.layoutManager = LinearLayoutManager(this.context)
   binding.floatingActionButton.setOnClickListener {
       val action = ItemListFragmentDirections.actionItemListFragmentToAddItemFragment(
           getString(R.string.add_fragment_title)
       )
       this.findNavController().navigate(action)
   }
}

9c416f2fbf1e5ae2.png

4. 항목 세부정보 표시

이 작업에서는 Items Details 화면에서 항목 세부정보를 읽고 표시합니다. 기본 키(항목 id)를 사용하여 인벤토리 앱 데이터베이스에서 이름과 가격, 수량 등 세부정보를 읽고 fragment_item_detail.xml 레이아웃 파일을 사용하여 Item Details 화면에 표시합니다. 레이아웃 파일 fragment_item_detail.xml은 개발자를 위해 미리 디자인되었으며 항목 세부정보가 표시되는 TextView 세 개를 포함합니다.

d699618f5d9437df.png

이 작업에서는 다음 단계를 구현합니다.

  • 클릭 핸들러를 RecyclerView에 추가하여 앱에서 Item Details 화면으로 이동합니다.
  • ItemListFragment 프래그먼트에서 데이터베이스의 데이터를 검색하고 표시합니다.
  • TextView를 ViewModel 데이터에 바인딩합니다.

클릭 핸들러 추가

  1. ItemListFragment에서 onViewCreated() 함수로 스크롤하여 어댑터 정의를 업데이트합니다.
  2. 람다를 생성자 매개변수로 ItemListAdapter{}에 추가합니다.
val adapter = ItemListAdapter {
}
  1. 람다 내에서 action이라는 val을 만듭니다. 초기화 오류는 곧 수정합니다.
val adapter = ItemListAdapter {
    val action
}
  1. 항목 id를 전달하는 ItemListFragmentDirections에서 actionItemListFragmentToItemDetailFragment() 메서드를 호출합니다. 반환된 NavDirections 객체를 action에 할당합니다.
val adapter = ItemListAdapter {
   val action =    ItemListFragmentDirections.actionItemListFragmentToItemDetailFragment(it.id)
}
  1. action 정의 아래에서 this.findNavController()를 사용하여 NavController 인스턴스를 검색하고 action을 전달하는 이 인스턴스에서 navigate()를 호출합니다. 어댑터 정의는 다음과 같습니다.
val adapter = ItemListAdapter {
   val action =   ItemListFragmentDirections.actionItemListFragmentToItemDetailFragment(it.id)
   this.findNavController().navigate(action)
}
  1. 앱을 실행합니다. RecyclerView에서 항목을 클릭합니다. 앱이 Item Details 화면으로 이동합니다. 세부정보가 비어 있습니다. 버튼을 탭해도 아무 일도 일어나지 않습니다.

196553111ee69beb.png

이후 단계에서 항목 세부정보를 Item Details 화면에 표시하고 판매 및 삭제 버튼 기능을 추가합니다.

항목 세부정보 검색

이 단계에서는 새 함수를 InventoryViewModel에 추가하여 항목 id에 따라 데이터베이스에서 항목 세부정보를 검색합니다. 다음 단계에서는 이 함수를 사용하여 항목 세부정보를 Item Details 화면에 표시합니다.

  1. InventoryViewModel에서 항목 id의 Int를 사용하고 LiveData<Item>을 반환하는 retrieveItem()이라는 함수를 추가합니다. 반환 표현식 오류는 곧 수정합니다.
fun retrieveItem(id: Int): LiveData<Item> {
}
  1. 새 함수 내 itemDao에서 getItem()을 호출하여 id 매개변수를 전달합니다. getItem() 함수는 Flow를 반환합니다. Flow 값을 LiveData로 사용하려면 asLiveData() 함수를 호출하여 이를 retrieveItem() 함수의 반환 값으로 사용합니다. 완성된 함수는 다음과 같습니다.
fun retrieveItem(id: Int): LiveData<Item> {
   return itemDao.getItem(id).asLiveData()
}

TextView에 데이터 바인딩

이 단계에서는 ItemDetailFragment에서 ViewModel 인스턴스를 만들고 ViewModel 데이터를 Item Details 화면의 TextView에 바인딩합니다. 또한 데이터베이스의 기본 데이터가 변경되면 ViewModel의 데이터에 관찰자를 연결하여 화면에서 인벤토리 목록을 계속 업데이트합니다.

  1. ItemDetailFragment에서 Item 항목 유형의 item이라는 변경 가능한 속성을 추가합니다. 이 속성을 사용하여 단일 항목에 관한 정보를 저장합니다. 이 속성은 나중에 초기화되므로 앞에 lateinit를 붙입니다.
lateinit var item: Item

Android 스튜디오에서 메시지가 표시되면 com.example.inventory.data.Item을 가져옵니다.

  1. ItemDetailFragment 클래스 시작 부분에서 InventoryViewModel 유형의 viewModel이라는 변경 불가능한 private 속성을 선언합니다. by 위임을 사용하여 속성 초기화를 activityViewModels 클래스에 전달합니다. InventoryViewModelFactory 생성자를 전달합니다.
private val viewModel: InventoryViewModel by activityViewModels {
   InventoryViewModelFactory(
       (activity?.application as InventoryApplication).database.itemDao()
   )
}

Android 스튜디오에서 메시지가 표시되면 androidx.fragment.app.activityViewModels를 가져옵니다.

  1. 계속 ItemDetailFragment에서 Item 항목의 인스턴스를 매개변수로 사용하고 아무것도 반환하지 않는 bind()라는 private 함수를 만듭니다.
private fun bind(item: Item) {
}
  1. bind() 함수를 구현합니다. 이는 ItemListAdapter에서 한 작업과 비슷합니다. itemName TextView의 text 속성을 item.itemName으로 설정합니다. item 속성에서 getFormattedPrice()를 호출하여 가격 값의 형식을 지정하고 itemPrice TextView의 text 속성으로 설정합니다. quantityInStockString으로 변환하고 itemQuantity TextView의 text 속성으로 설정합니다.
private fun bind(item: Item) {
   binding.itemName.text = item.itemName
   binding.itemPrice.text = item.getFormattedPrice()
   binding.itemCount.text = item.quantityInStock.toString()
}
  1. 다음과 같이 코드 블록에 apply{} 범위 함수를 사용하도록 bind() 함수를 업데이트합니다.
private fun bind(item: Item) {
   binding.apply {
       itemName.text = item.itemName
       itemPrice.text = item.getFormattedPrice()
       itemCount.text = item.quantityInStock.toString()
   }
}
  1. 여전히 ItemDetailFragment에서 onViewCreated()를 재정의합니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)
}
  1. 이전 단계 중 하나에서 항목 id를 탐색 인수로 ItemListFragmentItemDetailFragment에 전달했습니다. onViewCreated() 내에서 슈퍼 클래스 함수 호출 아래에 id라는 변경 불가능한 변수를 만듭니다. 탐색 인수를 검색하여 이 새로운 변수에 할당합니다.
val id = navigationArgs.itemId
  1. 이제 이 id 변수를 사용하여 항목 세부정보를 검색합니다. onViewCreated()id를 전달하는 viewModel에서 retrieveItem() 함수를 호출합니다. viewLifecycleOwner와 람다를 전달하는 반환된 값에 관찰자를 연결합니다.
viewModel.retrieveItem(id).observe(this.viewLifecycleOwner) {
   }
  1. 람다 내에서 selectedItem을 데이터베이스에서 검색된 Item 항목이 포함된 매개변수로 전달합니다. 람다 함수 본문에서 selectedItem 값을 item에 할당합니다. item을 전달하는 bind() 함수를 호출합니다. 완성된 함수는 다음과 같습니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)
   val id = navigationArgs.itemId
   viewModel.retrieveItem(id).observe(this.viewLifecycleOwner) { selectedItem ->
       item = selectedItem
       bind(item)
   }
}
  1. 앱을 실행합니다. Inventory 화면에서 목록 요소를 클릭하면 Item Details 화면이 표시됩니다. 화면이 더는 비어 있지 않으므로 이제 인벤토리 데이터베이스에서 검색된 항목 세부정보가 표시됩니다.

  1. Sell, Delete, FAB 버튼을 탭합니다. 아무 일도 일어나지 않습니다. 다음 작업에서는 이러한 버튼의 기능을 구현합니다.

5. 판매 항목 구현

이 작업에서는 앱 기능을 확장하여 판매 기능을 구현합니다. 다음은 이 단계의 안내가 요약되어 있습니다.

  • ViewModel에 함수를 추가하여 항목을 업데이트합니다.
  • 수량을 줄이고 앱 데이터베이스의 항목을 업데이트하는 새 메서드를 만듭니다.
  • 클릭 리스너를 Sell 버튼에 연결합니다.
  • 수량이 0이면 Sell 버튼을 사용 중지합니다.

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

  1. InventoryViewModel에서 항목 클래스 Item의 인스턴스를 사용하고 아무것도 반환하지 않는 updateItem()이라는 비공개 함수를 추가합니다.
private fun updateItem(item: Item) {
}
  1. 새 메서드 updateItem()을 구현합니다. ItemDao 클래스에서 update() 정지 메서드를 호출하려면 viewModelScope를 사용하여 코루틴을 시작합니다. launch 블록 내에서 item을 전달하는 itemDaoupdate() 함수를 호출합니다. 완성된 메서드는 다음과 같습니다.
private fun updateItem(item: Item) {
   viewModelScope.launch {
       itemDao.update(item)
   }
}
  1. 계속 InventoryViewModel 내에서 Item 항목 클래스의 인스턴스를 사용하고 아무것도 반환하지 않는 sellItem()이라는 또 다른 메서드를 추가합니다.
fun sellItem(item: Item) {
}
  1. sellItem() 함수에서 if 조건을 추가하여 item.quantityInStock0보다 큰지 확인합니다.
fun sellItem(item: Item) {
   if (item.quantityInStock > 0) {
   }
}

if 블록 내에서 데이터 클래스의 copy() 함수를 사용하여 항목을 업데이트합니다.

데이터 클래스: copy()

copy() 함수는 기본적으로 데이터 클래스의 모든 인스턴스에 제공됩니다. 이 함수는 일부 속성을 변경하지만 나머지 속성은 변경하지 않고 그대로 두기 위해 객체를 복사하는 데 사용됩니다.

예를 들어 다음과 같은 User 클래스와 인스턴스 jack을 고려해보세요. age 속성만 업데이트하여 새 인스턴스를 만들려면 구현은 다음과 같습니다.

// Data class
data class User(val name: String = "", val age: Int = 0)

// Data class instance
val jack = User(name = "Jack", age = 1)

// A new instance is created with its age property changed, rest of the properties unchanged.
val olderJack = jack.copy(age = 2)
  1. InventoryViewModelsellItem() 함수로 돌아갑니다. if 블록 내에서 newItem이라는 변경 불가능한 새 속성을 만듭니다. 업데이트된 quantityInStock(재고를 1씩 줄임)을 전달하는 item 인스턴스에서 copy() 함수를 호출합니다.
val newItem = item.copy(quantityInStock = item.quantityInStock - 1)
  1. newItem 정의 아래에서 업데이트된 새 항목 newItem을 전달하는 updateItem() 함수를 호출합니다. 완성된 메서드는 다음과 같습니다.
fun sellItem(item: Item) {
   if (item.quantityInStock > 0) {
       // Decrease the quantity by 1
       val newItem = item.copy(quantityInStock = item.quantityInStock - 1)
       updateItem(newItem)
   }
}
  1. 재고 판매 기능을 추가하려면 ItemDetailFragment로 이동합니다. bind() 함수 끝으로 스크롤합니다. apply 블록 내에서 Sell 버튼의 클릭 리스너를 설정하고 viewModel에서 sellItem() 함수를 호출합니다.
private fun bind(item: Item) {
binding.apply {

...
    sellItem.setOnClickListener { viewModel.sellItem(item) }
    }
}
  1. 앱을 실행합니다. Inventory 화면에서 수량이 0보다 큰 목록 요소를 클릭합니다. Item Details 화면이 표시됩니다. Sell 버튼을 탭하고 수량 값이 1씩 감소하는지 확인합니다.

aa63ca761dc8f009.png

  1. Item Details 화면에서 Sell 버튼을 계속 탭하여 수량을 0으로 만듭니다. 도움말: 재고가 적은 항목을 선택하거나 수량이 적은 새 항목을 만드세요. 수량이 0이 되면 Sell 버튼을 탭합니다. 시각적인 변화는 없습니다. 이는 sellItem() 함수가 수량을 업데이트하기 전에 수량이 0보다 큰지 확인하기 때문입니다.

3e099d3c55596938.png

  1. 사용자에게 더 나은 피드백을 제공하려면 판매할 항목이 없을 때 Sell 버튼을 사용 중지하는 것이 좋습니다. InventoryViewModel에서 수량이 0보다 큰지 확인하는 함수를 추가합니다. 함수 이름을 Item 인스턴스를 사용하고 Boolean을 반환하는 isStockAvailable()로 지정합니다.
fun isStockAvailable(item: Item): Boolean {
   return (item.quantityInStock > 0)
}
  1. ItemDetailFragment로 이동하여 bind() 함수로 스크롤합니다. apply 블록 내에서 item을 전달하는 viewModel에서 isStockAvailable() 함수를 호출합니다. 반환 값을 Sell 버튼의 isEnabled 속성으로 설정합니다. 코드는 다음과 같습니다.
private fun bind(item: Item) {
   binding.apply {
       ...
       sellItem.isEnabled = viewModel.isStockAvailable(item)
       sellItem.setOnClickListener { viewModel.sellItem(item) }
   }
}
  1. 앱을 실행하면 재고 수량이 0일 때 Sell 버튼이 사용 중지됩니다. 축하합니다. 앱에 항목 판매 기능을 구현했습니다.

5e49db8451e77c2b.png

항목 삭제

이전 작업과 마찬가지로 삭제 기능을 구현하여 앱 기능을 더 확장합니다. 다음은 이 단계의 안내가 요약되어 있습니다. 판매 기능을 구현하는 것보다 훨씬 쉽습니다.

  • ViewModel에 함수를 추가하여 데이터베이스에서 항목을 삭제합니다.
  • ItemDetailFragment에 새 메서드를 추가하여 새 삭제 함수를 호출하고 탐색을 처리합니다.
  • 클릭 리스너를 Delete 버튼에 연결합니다.

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

  1. InventoryViewModel에서 deleteItem()이라는 새 함수를 추가합니다. 이 함수는 item이라는 Item 항목 클래스의 인스턴스를 사용하고 아무것도 반환하지 않습니다. deleteItem() 함수 내에서 viewModelScope를 사용하여 코루틴을 시작합니다. launch 블록 내에서 item을 전달하는 itemDao에서 delete() 메서드를 호출합니다.
fun deleteItem(item: Item) {
   viewModelScope.launch {
       itemDao.delete(item)
   }
}
  1. ItemDetailFragment에서 deleteItem() 함수의 시작 부분으로 스크롤합니다. viewModel에서 deleteItem()을 호출하고 item을 전달합니다. item 인스턴스에는 Item Details 화면에 현재 표시된 항목이 포함되어 있습니다. 완성된 메서드는 다음과 같습니다.
private fun deleteItem() {
   viewModel.deleteItem(item)
   findNavController().navigateUp()
}
  1. 계속 ItemDetailFragment 내에서 showConfirmationDialog() 함수로 스크롤합니다. 이 함수는 시작 코드의 일부로 제공됩니다. 이 메서드는 항목을 삭제하기 전에 사용자의 확인을 받는 알림 대화상자를 표시하고 긍정적인 버튼을 탭하면 deleteItem() 함수를 호출합니다.
private fun showConfirmationDialog() {
        MaterialAlertDialogBuilder(requireContext())
            ...
            .setPositiveButton(getString(R.string.yes)) { _, _ ->
                deleteItem()
            }
            .show()
    }

showConfirmationDialog() 함수는 다음과 같은 알림 대화상자를 표시합니다.

728bfcbb997c8017.png

  1. ItemDetailFragment에서 bind() 함수 끝 apply 블록 내에 삭제 버튼의 클릭 리스너를 설정합니다. 클릭 리스너 람다 내에서 showConfirmationDialog()를 호출합니다.
private fun bind(item: Item) {
   binding.apply {
       ...
       deleteItem.setOnClickListener { showConfirmationDialog() }
   }
}
  1. 앱을 실행합니다. Inventory 목록 화면에서 목록 요소를 선택하고 Item Details 화면에서 Delete 버튼을 탭합니다. Yes를 탭하면 앱이 Inventory 화면으로 돌아갑니다. 삭제한 항목은 더 이상 앱 데이터베이스에 없습니다. 축하합니다. 삭제 기능을 구현했습니다.

c05318ab8c216fa1.png

항목 수정

이전 작업과 마찬가지로 이 작업에서는 또 다른 개선된 기능을 앱에 추가합니다. 항목 수정 기능을 구현합니다.

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

  • 프래그먼트 제목을 Edit Item으로 업데이트하여 Add Item 화면을 재사용합니다.
  • 클릭 리스너를 FAB에 추가하여 Edit Item 화면으로 이동합니다.
  • 항목 세부정보로 TextView를 채웁니다.
  • Room을 사용하여 데이터베이스의 항목을 업데이트합니다.

FAB에 클릭 리스너 추가

  1. ItemDetailFragment에서 매개변수를 사용하지 않고 아무것도 반환하지 않는 editItem()이라는 새 private 함수를 추가합니다. 다음 단계에서는 화면 제목을 Edit Item으로 업데이트하여 fragment_add_item.xml을 재사용합니다. 프래그먼트 제목 문자열을 항목 id와 함께 작업의 일부로 전송하면 됩니다.
private fun editItem() {
}

프래그먼트 제목을 업데이트하면 Edit Item 화면이 다음과 같이 표시됩니다.

bcd407af7c515a21.png

  1. editItem() 함수 내에서 action이라는 변경 불가능한 변수를 만듭니다. 제목 문자열 edit_fragment_title과 항목 id를 전달하는 ItemDetailFragmentDirections에서 actionItemDetailFragmentToAddItemFragment()를 호출합니다. 반환된 값을 action에 할당합니다. action 정의 아래에서 action을 전달하는 this.findNavController().navigate()를 호출하여 Edit Item 화면으로 이동합니다.
private fun editItem() {
   val action = ItemDetailFragmentDirections.actionItemDetailFragmentToAddItemFragment(
       getString(R.string.edit_fragment_title),
       item.id
   )
   this.findNavController().navigate(action)
}
  1. ItemDetailFragment 내에서 bind() 함수로 스크롤합니다. apply 블록 내에서 FAB의 클릭 리스너를 설정하고 람다에서 editItem() 함수를 호출하여 Edit Item 화면으로 이동합니다.
private fun bind(item: Item) {
   binding.apply {
       ...
       editItem.setOnClickListener { editItem() }
   }
}
  1. 앱을 실행합니다. Item Details 화면으로 이동합니다. FAB를 클릭합니다. 화면 제목이 Edit Item으로 업데이트되었지만 모든 텍스트 필드가 비어 있습니다. 다음 단계에서 이 문제를 수정합니다.

a6a6583171b68230.png

TextView 채우기

이 단계에서는 Edit Item 화면의 텍스트 필드를 항목 세부정보로 채웁니다. Add Item 화면을 사용하고 있으므로 새 함수를 Kotlin 파일 AddItemFragment.kt에 추가합니다.

  1. AddItemFragment에서 텍스트 필드를 항목 세부정보와 바인딩하는 새 private 함수를 추가합니다. 함수 이름을 항목 클래스의 인스턴스를 가져와 아무것도 반환하지 않는 bind()로 지정합니다.
private fun bind(item: Item) {
}
  1. bind() 함수 구현은 앞서 ItemDetailFragment에서 한 작업과 매우 비슷합니다. bind() 함수 내에서 format() 함수를 사용하여 가격을 소수점 이하 두 자리로 반올림한 후 다음과 같이 price라는 val에 할당합니다.
val price = "%.2f".format(item.itemPrice)
  1. price 정의 아래에서 다음과 같이 binding 속성에 apply 범위 함수를 사용합니다.
binding.apply {
}
  1. apply 범위 함수 코드 블록 내에서 item.itemNameitemName의 텍스트 속성으로 설정합니다. setText() 함수를 사용하고 item.itemName 문자열과 TextView.BufferType.SPANNABLEBufferType으로 전달합니다.
binding.apply {
   itemName.setText(item.itemName, TextView.BufferType.SPANNABLE)
}

Android 스튜디오에서 메시지가 표시되면 android.widget.TextView를 가져옵니다.

  1. 위 단계와 마찬가지로 가격 EditText의 텍스트 속성을 다음과 같이 설정합니다. 수량 EditText의 text 속성을 설정하려면 item.quantityInStockString으로 변환해야 합니다. 완성된 함수는 다음과 같습니다.
private fun bind(item: Item) {
   val price = "%.2f".format(item.itemPrice)
   binding.apply {
       itemName.setText(item.itemName, TextView.BufferType.SPANNABLE)
       itemPrice.setText(price, TextView.BufferType.SPANNABLE)
       itemCount.setText(item.quantityInStock.toString(), TextView.BufferType.SPANNABLE)
   }
}
  1. AddItemFragment 내에서 onViewCreated() 함수로 스크롤합니다. 슈퍼 클래스 함수 호출 후 id라는 val을 만들고 탐색 인수에서 itemId를 검색합니다.
val id = navigationArgs.itemId
  1. id가 0보다 큰지 확인하는 조건으로 if-else 블록을 추가하고 Save 버튼 클릭 리스너를 else 블록으로 이동합니다. if 블록 내에서 id를 사용하여 항목을 검색하고 관찰자를 추가합니다. 관찰자 내에서 item 속성을 업데이트하고 item을 전달하는 bind()를 호출합니다. 복사하여 붙여넣을 수 있도록 완성된 함수가 제공됩니다. 간단하고 이해하기 쉬우므로 직접 파악할 수 있습니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)
   val id = navigationArgs.itemId
   if (id > 0) {
       viewModel.retrieveItem(id).observe(this.viewLifecycleOwner) { selectedItem ->
           item = selectedItem
           bind(item)
       }
   } else {
       binding.saveAction.setOnClickListener {
           addNewItem()
       }
   }
}
  1. 앱을 실행하여 Item Details로 이동한 다음 + FAB를 탭합니다. 필드가 항목 세부정보로 채워져 있습니다. 재고 수량이나 다른 필드를 수정하고 저장 버튼을 탭합니다. 아무 일도 일어나지 않습니다. 앱 데이터베이스의 항목을 업데이트하지 않기 때문입니다. 이 문제는 곧 수정합니다.

829ceb9dd7993215.png

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

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

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

  1. InventoryViewModel에서 Int와 항목 세부정보 문자열 itemName, itemPrice, itemCount 세 개를 가져오는 getUpdatedItemEntry()라는 private 함수를 추가합니다. 함수에서 Item의 인스턴스를 반환합니다. 참조용으로 코드가 제공됩니다.
private fun getUpdatedItemEntry(
   itemId: Int,
   itemName: String,
   itemPrice: String,
   itemCount: String
): Item {
}
  1. getUpdatedItemEntry() 함수 내에서 다음과 같이 함수 매개변수를 사용하여 Item 인스턴스를 만듭니다. 함수에서 Item 인스턴스를 반환합니다.
private fun getUpdatedItemEntry(
   itemId: Int,
   itemName: String,
   itemPrice: String,
   itemCount: String
): Item {
   return Item(
       id = itemId,
       itemName = itemName,
       itemPrice = itemPrice.toDouble(),
       quantityInStock = itemCount.toInt()
   )
}
  1. InventoryViewModel 내에서 updateItem()이라는 또 다른 함수를 추가합니다. 이 함수도 Int와 항목 세부정보 문자열 세 개를 사용하고 아무것도 반환하지 않습니다. 다음 코드 스니펫에서 변수 이름을 사용합니다.
fun updateItem(
   itemId: Int,
   itemName: String,
   itemPrice: String,
   itemCount: String
) {
}
  1. updateItem() 함수 내에서 다음과 같이 항목 정보(함수 매개변수로 전달됨)를 전달하는 getUpdatedItemEntry() 함수를 호출합니다. 반환된 값을 updatedItem이라는 변경 불가능한 변수에 할당합니다.
val updatedItem = getUpdatedItemEntry(itemId, itemName, itemPrice, itemCount)
  1. getUpdatedItemEntry() 함수 호출 바로 아래에서 updatedItem을 전달하는 updateItem() 함수를 호출합니다. 완성된 함수는 다음과 같습니다.
fun updateItem(
   itemId: Int,
   itemName: String,
   itemPrice: String,
   itemCount: String
) {
   val updatedItem = getUpdatedItemEntry(itemId, itemName, itemPrice, itemCount)
   updateItem(updatedItem)
}
  1. AddItemFragment로 돌아가서 매개변수 없이 updateItem()이라는 비공개 함수를 추가하고 아무것도 반환하지 않습니다. 함수 내에서 isEntryValid() 함수를 호출하여 사용자 입력을 검증하는 if 조건을 추가합니다.
private fun updateItem() {
   if (isEntryValid()) {
   }
}
  1. if 블록 내에서 항목 세부정보를 전달하는 viewModel.updateItem()을 호출합니다. 탐색 인수의 itemId와 EditText의 이름, 가격, 수량과 같은 기타 항목 세부정보를 다음과 같이 사용합니다.
viewModel.updateItem(
    this.navigationArgs.itemId,
    this.binding.itemName.text.toString(),
    this.binding.itemPrice.text.toString(),
    this.binding.itemCount.text.toString()
)
  1. updateItem() 함수 호출 아래에서 action이라는 val을 정의합니다. AddItemFragmentDirections에서 actionAddItemFragmentToItemListFragment()를 호출하고 반환된 값을 action에 할당합니다. ItemListFragment로 이동하여 action을 전달하는 findNavController().navigate()를 호출합니다.
private fun updateItem() {
   if (isEntryValid()) {
       viewModel.updateItem(
           this.navigationArgs.itemId,
           this.binding.itemName.text.toString(),
           this.binding.itemPrice.text.toString(),
           this.binding.itemCount.text.toString()
       )
       val action = AddItemFragmentDirections.actionAddItemFragmentToItemListFragment()
       findNavController().navigate(action)
   }
}
  1. AddItemFragment 내에서 bind() 함수로 스크롤합니다. binding.apply 범위 함수 블록 내에서 Save 버튼의 클릭 리스너를 설정합니다. 다음과 같이 람다 내에서 updateItem() 함수를 호출합니다.
private fun bind(item: Item) {
   ...
   binding.apply {
       ...
       saveAction.setOnClickListener { updateItem() }
   }
}
  1. 앱을 실행합니다. 인벤토리 항목을 수정해보세요. Inventory 앱 데이터베이스의 모든 항목을 수정할 수 있습니다.

1bbd094a77c25fc4.png

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

6. 솔루션 코드

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

이 Codelab의 코드를 가져와서 Android 스튜디오에서 열려면 다음을 실행합니다.

코드 가져오기

  1. 제공된 URL을 클릭합니다. 브라우저에서 프로젝트의 GitHub 페이지가 열립니다.
  2. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 대화상자를 엽니다.

5b0a76c50478a73f.png

  1. 대화상자에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
  2. 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
  3. ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.

Android 스튜디오에서 프로젝트 열기

  1. Android 스튜디오를 시작합니다.
  2. Welcome to Android Studio 창에서 Open an existing Android Studio project를 클릭합니다.

36cc44fcf0f89a1d.png

참고: Android 스튜디오가 이미 열려 있는 경우 File > New > Import Project 메뉴 옵션을 대신 선택합니다.

21f3eec988dcfbe9.png

  1. Import Project 대화상자에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 11c34fc5e516fb1c.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.
  5. Project 도구 창에서 프로젝트 파일을 살펴보고 앱이 구현된 방식을 확인합니다.

7. 요약

  • Kotlin은 클래스에서 상속받거나 기존 클래스 정의를 수정하지 않고도 새로운 기능으로 클래스를 확장하는 기능을 제공합니다. 확장이라는 특수 선언을 통해 실행됩니다.
  • Flow 데이터를 LiveData 값으로 사용하려면 asLiveData() 함수를 사용합니다.
  • copy() 함수는 기본적으로 데이터 클래스의 모든 인스턴스에 제공됩니다. 이를 통해 객체를 복사하여 일부 속성은 변경하면서 나머지 속성은 변경하지 않고 그대로 유지할 수 있습니다.

8. 자세히 알아보기

Android 개발자 문서

API 참조

Kotlin 참조