Unit 5: Data persistence (2)

quokka·2021년 12월 23일
0

Android Basics in Kotlin

목록 보기
22/25
post-thumbnail

대부분의 앱은 사용자가 앱을 종료한 후에도 저장해야 하는 데이터가 있다. Room은 Android Jetpack에 속하는 지속성 라이브러리다. Room은 SQLite 데이터베이스 위에 있는 추상 레이어로, SQLite를 직접 사용하는 대신 Room은 데이터베이스 설정과, 상호작용 작업을 간소화한다.

권장되는 전체 아키텍쳐는 다음 그림과 같다.

학습할 내용

  • Room 라이브러리를 사용하여 SQLite 데이터베이스를 만들고 상호작용하는 방법
  • 항목, DAO, 데이터베이스 클래스를 만드는 방법
  • 데이터 액세스 객체(DAO)를 사용하여 Kotlin 함수를 SQL 쿼리에 매핑하는 방법

Room의 기본 구성요소

Data entities
앱 데이터베이스의 테이블을 나타낸다. 테이블의 행에 저장된 데이터를 업데이트하고 삽입할 새 행을 만드는 데 사용된다.

Data Access Objects (DAO)
데이터 검색 및 업데이트, 삽입, 삭제하는 데 사용하는 메서드를 제공한다.

Database class
데이터베이스를 가지고 있고, 앱 데이터베이스 연결을 위한 메인 액세스 포인트다. 앱에게 데이터베이스와 연결된 DAO 인스턴스를 제공한다.

Inventory App

이번 코드랩에서 완성하게 될 앱은 Inventory App이다.
스타터 코드는 여기에서 받을 수 있다.

Item Entity 만들기

Entity 클래스는 테이블을 정의하고, 이 클래스의 각 인스턴스는 데이터베이스 테이블의 행을 나타낸다.
Entity 클래스에는 데이터베이스의 정보를 어떻게 나타내고 상호작용 할 수 있는지를 Room에게 알려주는 매핑이 있다.

위의 그림에 해당하는 테이블 Entity 클래스를 만들어보자.

Data class

우선 data 패키지를 생성하고 그 아래 item이라는 이름의 파일을 생성했다.
data class인 Item의 속성은 다음과 같다.

@Entity(tableName = "item")
data class Item(
    val id: Int = 0,
    val itemName: String,
    val itemPRice: Double,
    val quantityInStock: Int
)

Primary key, ColumnInfo

id에 primary key를 설정하고, 나머지 속성에 @ColumnInfo를 설정한다. ColumnInfo는 특정 필드와 연결된 열을 맞춤설정하는 데 사용된다.

@Entity(tableName = "item")
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    @ColumnInfo(name = "name")
    val itemName: String,
    @ColumnInfo(name = "price")
    val itemPRice: Double,
    @ColumnInfo(name = "quantity")
    val quantityInStock: Int
)

Item Dao 만들기

Dao는 추상 인터페이스를 제공해 지속성 레이어를 나머지 애플리케이션과 분리하는 패턴이다. 이 분리(isolation)는 single responsibiliry principle을 따른다.

Dao는 데이터베이스 작업 실행과 관련된 복잡성을 숨기는 기능을 한다. Dao는 데이터베이스에 액세스하는 인터페이스를 정의하는 Room의 기본 구성요소!

1. ItemDao.kt 생성

data 패키지에 ItemDao.kt를 생성한다.
클래스 정의는 interface로하고, 상단에 @Dao 주석을 단다.

2. Insert

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)
}
  • OnConflict는 충돌이 발생했을 때 Room 이 실행할 작업을 정한다. OnConflictStrategy.IGNORE은 기본 키가 이미 데이터베이스가 있다면 새 항목을 insert하지 않는다.
  • insert 함수를 생성했으니 insert()를 실행하면 Room은 SQL 쿼리를 실행해 DB에 데이터를 삽입한다.

3. Update / Delete

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

Insert와 마찬가지로 Update와 Delete도 Room 라이브러리가 제공하는 주석을 사용할 수 있다.

4. 나머지 Query

라이브러리가 제공하는 기본 쿼리 이외에 추가하려면 @Query 주석을 사용한다.

    @Query("SELECT * from item WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from item ORDER BY name ASC")
    fun getItems(): Flow<List<Item>>
  • Flow를 사용하면 데이터베이스의 데이터가 변경될 때마다 알 수 있다. Room은 Flow를 자동으로 업데이트한다.

Database Instance 만들기

앞에서 만든 Entity와 Dao를 사용하는 RoomDatabase를 만든다.
데이터베이스 클래스는 entity 목록과 data access objects(DAO) 목록을 정의한다. 메인 액세스 포인트이기도 하다.

@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class ItemRoomDatabase: RoomDatabase() {
    abstract fun itemDao(): ItemDao
    
    companion object{
        @Volatile
        private var INSTANCE: ItemRoomDatabase?=null
        
        fun getDatabase(context: Context): ItemRoomDatabase{
            return INSTANCE ?: synchronized(this){
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    ItemRoomDatabase::class.java,
                    "item_database"
                )
                    .fallbackToDestructiveMigration()
                    .build()
                
                INSTANCE = instance
                return instance
            }
        }
    }
}

1. ItemRoomDatabase.kt 파일 생성

  • data 패키지 내 ItemRoomdatabase.kt 파일을 생성한다.

2. ItemRoomDatabase 클래스

  • RoomDatabase 클래스를 만들기 위해서는 @Database 주석을 작성한다.
  • 추상 클래스를 만드는데, 정의한 추상 클래스는 데이터베이스의 홀더 역할을 한다.
  • @Database 주석 뒤에는 Room이 데이터베이스 빌드하는데 필요한 인수들을 작성한다.
  • entities 목록에는 Item 클래스가 유일하다.
  • version은 테이블 스키마가 변경될 때마다 버전이 높아진다.
  • exportSchema를 false로 설정하면 스키마 버전 기록 백업을 유지하지 않는다.

3. ItemDao

  • 데이터베이스를 DAO를 알고있어야 하므로 클래스 내에 ItemDao를 반환하는 추상 함수를 선언한다.

4. companion 객체

companion object{
        @Volatile
        private var INSTANCE: ItemRoomDatabase?=null
        
        fun getDatabase(context: Context): ItemRoomDatabase{
            return INSTANCE ?: synchronized(this){
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    ItemRoomDatabase::class.java,
                    "item_database"
                )
                    .fallbackToDestructiveMigration()
                    .build()
                
                INSTANCE = instance
                return instance
            }
        }
    }
  • companion object 내에 null을 허용하는 private 변수 INSTANCE를 선언하고, null로 초기화한다. INSTANCE는 데이터베이스가 생성되면 데이터베이스 참조를 유지한다(keep a reference to the database)
  • @Volatile로 하면, INSTANCE값이 항상 최신 상태로 유지된다. 즉 한 스레드에서 INSTANCE를 변경하면 다른 스레드에서도 즉시 표시된다. (변수의 값이 캐시되지 않고 모든 쓰기, 읽기가 메인 메모리에서 실행된다)
  • getDatabase() 메서드를 정의한다. 데이터베이스 빌더에 필요한 Context 매개변수를 전달하고, ItemRoomDatabase 유형을 반환한다.
  • getDatabase()INSTANCE 변수를 반환하거나 INSTANCE가 null일 경우 synchrnized{} 블록 내에서 초기화한다.
  • instance 변수에 데이터베이스 빌더를 사용해 데이터베이스를 가져온다.
  • 애플리케이션 컨텍스트, 데이터베이스 클래스, 데이터베이스 이름을 빌더에 전달한다.
  • Add the required migration strategy to the builder. Use .fallbackToDestructiveMigration()
  • INSTANCE에 새로 빌드한 instance를 할당한다.

Application class 구현

InventoryApplication.kt에서 데이터베이스 인스턴스를 인스턴스화한다.

class InventoryApplication : Application(){
    val database: ItemRoomDatabase by lazy { ItemRoomDatabase.getDatabase(this) }
}
  • database라는 ItemRoomDatabase 유형의 변수를 만들고, ItemRoomDatabse에서 getDatabase()를 호출해 database인스턴스를 인스턴스화한다.
  • lazy이므로 참조가 처음 필요하거나 처음 참조에 액세스할 때 인스턴스가 생성된다.
  • 뒤에서 다룰 ViewModel에서 인스턴스를 만들 때 이 database 인스턴스를 사용한다.

ViewModel 추가하기

앱의 임시 데이터를 저장하고 데이터베이스에도 액세스하려면 ViewModel이 있어야 한다. 인벤토리 ViewModel은 DAO를 통해 데이터베이스와 상호작용하여 UI에 데이터를 제공한다.

1. InventoryViewModel.kt

InventoryViewModel.kt 파일을 생성한다.
InventoryViewModel 클래스와 InventoryViewModelFactory 클래스를 추가한다.

2. InventoryViewModelFactory

class InventoryViewModelFactory(private val itemDao: ItemDao): ViewModelProvider.Factory{
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if(modelClass.isAssignableFrom(InventoryViewModel::class.java)){
            @Suppress("UNCHECKED_CAST")
            return InventoryViewModel(itemDao) as T
        }
        throw IllegalArgumentException("unknown ViewModel class")
    }
}
  • InventoryViewModelFactory 클래스를 추가하여 InventoryViewModel 인스턴스를 인스턴스화한다.
  • 빨간색 전구를 클릭해 create() 메서드를 재정의한다. 클래스 유형을 인수로 사용하여 ViewModel 객체를 반환한다.
  • modelClassInventoryViewModel 클래스가 같은지 확인하고, 인스턴스를 반환한다. 같지 않다면 exception이 발생한다.

3. ViewModel 채우기

class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() {}

InventoryViewModelFactory 위에 있는 InventoryViewModel 클래스를 채워 데이터베이스에 인벤토리 데이터를 추가한다.

class InventoryViewModel(private val itemDao: ItemDao) : ViewModel() {
    private fun insertItem(item: Item) {
        viewModelScope.launch {
            itemDao.insert(item)
        }
    }

    private fun getNewItemEntry(itemName: String, itemPrice: String, itemCount: String): Item {
        return Item(
            itemName = itemName,
            itemPrice = itemPrice.toDouble(),
            quantityInStock = itemCount.toInt()
        )
    }

    fun addNewItem(itemName: String, itemPrice: String, itemCount: String) {
        val newItem = getNewItemEntry(itemName, itemPrice, itemCount)
        insertItem(newItem)
    }
}

1. insertItem()

  • 메인 스레드 밖에서 데이터베이스와 상호작용하려면 코루틴을 launch 하고, 그 안에서 DAO 메서드를 호출해야 한다.
  • ViewModelScope는 ViewModel이 소멸될 때 하위 코루틴을 자동으로 취소하는 ViewModel 클래스의 확장 속성이다.

2. getNewItemEntry()

  • 문자열 3개를 받아 Item 인스턴스를 반환한다.

3. addNewItem()

  • getNewITemEntry()에서 반환된 값을 newItem 변수에 받아 insertItem()을 호출해 데이터베이스에 추가한다.

AddItemFragment 수정

1. viewModel 변수

private val viewModel: InventoryViewModel by activityViewModels{
        InventoryViewModelFactory(
            (activity?.application as InventoryApplication).database
                .itemDao()
        )
    }
  • InventoryViewModel 유형의 변수 viewModel을 만든다.
  • by activityViewModels로 전체 프래그먼트에서 ViewModel을 공유한다.

2. item 변수

viewModel 정의 아래에 Item 유형의 item이라는 lateinit var을 만든다.

 lateinit var item: Item

3. TextField가 채워졌는지 확인

InventoryViewModel에 TextField가 채워졌는지 확인하는 isEntryValid() 메서드를 추가한다.

fun isEntryValid(itemName: String, itemPrice: String, itemCount: String): Boolean {
   if (itemName.isBlank() || itemPrice.isBlank() || itemCount.isBlank()) {
       return false
   }
   return true
}

AddItemFragment.kt에서는 위에 정의한 메서드를 사용하는 isEntryValid()를 추가한다.

    private fun isEntryValid(): Boolean {
        return viewModel.isEntryValid(
            binding.itemName.text.toString(),
            binding.itemPrice.text.toString(),
            binding.itemCount.text.toString()
        )
    }

4. addNewItem()

    private fun addNewItem() {
        if (isEntryValid()) {
            viewModel.addNewItem(
                binding.itemName.text.toString(),
                binding.itemPrice.text.toString(),
                binding.itemCount.text.toString(),
            )
            val action = AddItemFragmentDirections.actionAddItemFragmentToItemListFragment()
            findNavController().navigate(action)
        }

    }
  • addNewItem() 메서드에서 새 item을 추가할 때 텍스트필드가 모두 입력되었는지 확인한다.
  • if문 안에서 viewModel 인스턴스에서 addNewItem()을 호출한다.
  • ItemListFragment로 다시 이동하는 action을 만든다.

실행

앱을 실행하여 새로운 아이템을 추가하면 Database Inspector에 입력되는 것을 확인할 수 있다. 아직 adapter 등 구현하지 않은 부분이 있어서 실제 화면에 추가되지는 않는다.

0개의 댓글