Study Record

[안드로이드] Room (database) 살펴보기 (ViewModel, Flow, ListAdapter, LiveData, Coroutines) 본문

안드로이드

[안드로이드] Room (database) 살펴보기 (ViewModel, Flow, ListAdapter, LiveData, Coroutines)

초코초코초코 2023. 8. 19. 00:51
728x90

😶 Room 개요

데이터베이스를 안드로이드에서 쉽게 사용할 수 있는 방법은 Room 라이브러리를 사용하는 것이다. Room 은 Android Jetpack 의 일부인 지속성 라이브러리로 SQLite 데이터베이스를 관리하는 추상화 계층으로 작업 수행을 위한 특수 언어인 SQL 을 사용한다.

 

Room 은 ORM(Oject Relational Mapping) 으로, 객체형 데이터베이스의 테이블을 Kotlin 에서 사용할 수 있는 객체와 매핑할 수 있다. 즉, 각 테이블은 클래스로 표시하고 이러한 테이블을 모델 클래스 혹은 엔티티(entity)라고 부른다.

 

데이터베이스에 사용되는 SQL 은 실제로 INTEGER, TEXT 로 표시된다. 하지만 Kotlin 의 데이터 유형은 Int, String 이다. 이러한 데이터 유형을 매핑하는 작업은 자동으로 Room 이 처리해 준다.

 

Room 의 주요 컴포넌트에는 Data Entity, DAO(Data Access Objects), Database Class 가 있다.

Data Entity 는 위에서 설명한 것과 같이 테이블을 정의한다.

DAO(Data Access Objects) 는 데이터베이스의 데이터 삽입, 삭제, 업데이트 등과 관련된 메서드를 제공한다.

Database Class 는 앱의 데이터베이스에 대한 기본 접근을 위한 클래스로 데이터베이스와 관련된 DAO 의 인스턴스도 같이 제공한다.

 

 

😶 Room 사용하기

 

dependency 추가하기

dependencies {
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation "androidx.room:room-ktx:$room_version"
}

 

 

table 선언하기 

Room 에서 테이블을 모델 클래스 혹은 엔티티(entity)라고 불리며 클래스로 표시할 수 있다.

import androidx.annotation.NonNull
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

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

@Entity 어노테이션을 붙이면 테이블로 정의할 수 있다. Room 은 대소문자를 구분하지 않는다.

 

 

DAO(Data Access Object) 클래스 선언하기

DAO 는 데이터에 대한 엑세스를 제공하는 Kotlin 클래스로 추상적인 인터페이스를 제공한다. 데이터베이스에서 데이터를 읽고 수정하는 등의 기능이 포함되며 데이터베이스를 다루는 방법은 SQL 문법을 통해 가능하다. DAO 는 데이터베이스 작업을 수행하는 모든 복잡한 작업을 숨겨 실제 데이터 접근 계층이 DAO 에 정의된 코드와 서로 독립적으로 변경된다. 따라서, DAO 를 작성하는 것으로 실제 데이터베이스의 내용을 변경할 수 있다.

@Dao
interface ScheduleDao {
    @Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
    fun getAll(): List<Schedule>

    @Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
    fun getByStopName(stopName: String): List<Schedule>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    fun insert(schedule: Schedule)

    @Delete
    fun delete(schedule: Schedule)
    
    @Update
    fun update(schedule: Schedule)
}

@Query 어노테이션으로 SQL 구문을 작성할 수 있다. SQL 구문에 ":" 기호를 사용하여 변수를 넣을 수 있다.(ex. :stop_name) 

 

 

데이터베이스 선언하기

데이터베이스 클래스는 엔티티 및 데이터 액세스 개체의 목록을 정의한다. 정의한 DAO 의 인스턴스를 앱에 제공해 이 데이터베이스 클래스의 DAO 를 사용하여 테이블 행을 업데이트하거나 삽입할 수 있다.

 

추상 클래스로 RoomDatabase 를 상속받고 @Database 어노테이션을 선언한다. 엔티티(entities)정보에 데이터베이스에 포함될 테이블을 정의해주고, version 으로 현재 데이터베이스 버전을 적어준다. Room 라이브러리가 개발자가 정의한 RoomDatabase 를 상속받은 추상 클래스를 참고로 따로 구현할 것이기 때문에 추상 클래스로 작성한다.

import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.busschedule.database.schedule.Schedule
import com.example.busschedule.database.schedule.ScheduleDao

@Database(entities = arrayOf(Schedule::class), version = 1)
abstract class AppDatabase: RoomDatabase() {
    abstract fun scheduleDao(): ScheduleDao
}

DAO 를 클래스 내 추상 클래스로 선언해두면 나중에 이 메서드를 통해 DAO 를 참조할 수 있다.

 

데이터베이스 클래스에는 DAO 뿐만 아니라 데이터베이스 인스턴스를 제공한다. 전체 앱에서 데이터베이스 인스턴스는 하나만 필요하므로 companion object 를 사용한다. INSTANCE 변수는 데이터베이스가 생성됐을 때 그에 대한 참조를 유지한다. @Volatile 어노테이션은 휘발성 변수의 값은 캐시되지 않으며 주 메모리에서 모든 읽기와 쓰기가 수행된다. 즉, INSTANCE 변수의 값에 대한 변경 사항이 다른 모든 스레드에 즉시 표시된다는 것을 의미한다. 

import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.busschedule.database.schedule.Schedule
import com.example.busschedule.database.schedule.ScheduleDao

@Database(entities = arrayOf(Schedule::class), version = 1)
abstract class AppDatabase: RoomDatabase() {
    abstract fun scheduleDao(): ScheduleDao
    
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

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

여러 개의 스레드가 동시에 실행되어 두 개의 데이터베이스가 생성될 가능성이 있다. 따라서, synchronized{} 블록을 사용하면 하나의 스레드만 블록을 실행할 수 있어 데이터베이스가 한 번만 초기화되도록 한다. createFormatAsset() 메서드로 asset 디렉터리에 있는 파일로 데이터베이스를 초기화할 수 있다.

 

Application 는 앱 전역의 상태를 포함하는 기본 클래스로 앱 프로젝트에서 전역 변수로 사용할 수 있다. 이 클래스에 database 변수를 선언한다. 

class BusScheduleApplication: Application() {
    val database: AppDatabase by lazy {
        AppDatabase.getDatabase(this)
    }
}

 

Application 클래스가 AnroidManifest.xml 파일에 선언되어있지 않다면 추가해 준다.

<application
    android:name=".BusScheduleApplication"
    ...

 

 

ViewModel 에서 DAO 접근하기

ViewModel 클래스가 라이프사이클을 인식하도록 되어있으므로 라이프사이클 이벤트에 응답할 수 있는 객체에 의해 인스턴스화되어야 한다. 이를 직접 인스턴스화한다면 메모리 관리를 포함하여 신경 써야 할 점이 많으니 ViewModel 객체를 인스턴스화하는 팩토리 클래스를 이용할 수 있다.

class BusScheduleViewModel(private val scheduleDao: ScheduleDao) : ViewModel() {

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

    fun scheduleForStopNameNotFlow(name: String): List<Schedule> = scheduleDao.getByStopName(name)
    
    fun updateItem(item: Schedule) {
        viewModelScope.launch(Dispatchers.IO) { itemDao.update(item) }
    }
    
    fun deleteItem(item: Item) {
        viewModelScope.launch(Dispatchers.IO) { itemDao.delete(item) }
    }
    
    fun insertItem(item: Item) {
        viewModelScope.launch(Dispatchers.IO) { itemDao.insert(item) }
    }
}

class BusScheduleViewModelFactory(
    private val scheduleDao: ScheduleDao
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(BusScheduleViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return BusScheduleViewModel(scheduleDao) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

 

 

Fragment 에서 ViewModel 선언 및 사용하기

정의한 ViewModelFactory 를 사용하여 viewModel 을 선언할 수 있다. 데이터베이스에서 데이터를 가져오는 작업은 모두 시간이 걸리는 작업일 수 있기 때문에 메인 스레드에서 실행하는 것은 좋지 않다. 따라서 코루틴(스레드)를 사용한다.

class StopScheduleFragment: Fragment() {

    private val viewModel: BusScheduleViewModel by activityViewModels {
        BusScheduleViewModelFactory(
            (activity?.application as BusScheduleApplication).database.scheduleDao()
        )
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        lifecycle.coroutineScope.launch(Dispatchers.IO) {
            viewModel.scheduleForStopNameNotFlow(stopName)
        }
    }
}

 

 

😶 Room + ViewModel + ListAdapter + Flow + LiveData

데이터베이스를 사용한 프로그램은 데이터가 실시간으로 변경될 가능성이 크다. 

 

ListAdapter

데이터 목록을 보여주는 RecyclerView 에서 사용하는 어댑터는 RecyclerView.Adapter 이다. 이 어댑터는 데이터가 변경될 때 데이터 리스트를 다시 명시하면 데이터 목록 전체를 다시 새로 고침 한다. 이 과정은 불필요할 수 있다.

 

RecyclerView.Adapter 를 상속받는 어댑터 중 하나인 ListAdapter 를 사용하면 데이터가 변경될 때 이전 데이터 목록과 비교하여 차이가 있는 항목만 업데이트되므로 데이터가 실시간으로 변경되는 프로그램에서는 ListAdapter 가 적합하다.

 

예시 코드)

class BusStopAdapter(
    private val onItemClicked: (Schedule) -> Unit
) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback){

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

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

        }
    }

    class BusStopViewHolder(
        private var binding: BusStopItemBinding
    ) : RecyclerView.ViewHolder(binding.root){

        @SuppressLint("SimpleDateFormat")
        fun bind(schedule: Schedule) {
            binding.stopNameTextView.text = schedule.stopName
            binding.arrivalTimeTextView.text = SimpleDateFormat(
                "h:mm a").format(Date(schedule.arrivalTime.toLong() * 1000))
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BusStopViewHolder {
        val viewHolder = BusStopViewHolder(
            BusStopItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )

        viewHolder.itemView.setOnClickListener {
            val position = viewHolder.adapterPosition
            onItemClicked(getItem(position))
        }
        return viewHolder
    }

    override fun onBindViewHolder(holder: BusStopViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
}

/* 데이터 업데이트
val adapter = BusStopAdapter() {}
adapter.submitList(listOf())
*/

 

 

Flow

데이터베이스의 데이터가 변경되면(업데이트, 삭제, 삽입) 당연히 데이터를 참조한 부분도 전부 변경해야 한다. 데이터가 변경되고 다시 데이터를 불러오는 등 후속 작업을 전부 개발자가 감당하는 것은 오류가 날 확률도 높아지고 복잡한 과정이 될 것이다.

 

Flow 를 사용하면 데이터베이스의 데이터가 변경되면 관련 데이터가 자동으로 다시 업데이트되도록 도와준다.

사용 방식은 간단하게, DAO 의 리턴값을 Flow 타입으로 감싼다.

import androidx.room.Dao
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface ScheduleDao {

    @Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
    fun getAll(): Flow<List<Schedule>>

    @Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
    fun getByStopName(stopName: String): Flow<List<Schedule>>

}

 

또한, ViewModel 의 데이터를 가져오는 함수의 리턴값도 마찬가지로 Flow 타입으로 감싸준다.

import kotlinx.coroutines.flow.Flow

class BusScheduleViewModel(private val scheduleDao: ScheduleDao) : ViewModel() {

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

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

}

 

Fragment 에서 데이터를 가져와 View 를 수정하는 부분을 다음과 같이 사용할 수 있다. 프래그먼트에서 코루틴을 사용하여 ViewModel 을 통해 데이터를 가져오는데 collect() 메서드를 통해 데이터가 변경사항이 있어 변경된다면 자동으로 콜백 해준다. 콜백 내용에 데이터와 관련된 View 의 수정코드를 적는다.

class StopScheduleFragment: Fragment() {

    private val viewModel: BusScheduleViewModel by activityViewModels {
        BusScheduleViewModelFactory(
            (activity?.application as BusScheduleApplication).database.scheduleDao()
        )
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
       lifecycle.coroutineScope.launch(Dispatchers.IO) {
            viewModel.scheduleForStopName(stopName).collect() { it ->
                // 데이터가 변경될 때마다 실행된다.
                busStopAdapter.submitList(it)
            }
        }
    }
}

 

 

LiveData + Flow

LiveData 는 데이터의 변경을 감지하고 콜백 해주는 옵져버를 사용할 수 있다. 위에서 소개한 Flow 는 관련된 데이터베이스의 데이터가 변경될 때마다 콜백 해준다. Flow 를 LiveData 로 변경하는 걸 지원하기 때문에 좀 더 간단하게 코드를 정리할 수 있다.

class BusScheduleViewModel(private val scheduleDao: ScheduleDao) : ViewModel() {

    val scheduleItems: LiveData<List<Schedule>> = scheduleDao.getAll().asLiveData()
    
}

 

Fragment 애서 사용할 때, 다음과 같다. LiveData 의 observer 콜백을 정의하는 것으로 대체한다.

class StopScheduleFragment: Fragment() {

    private val viewModel: BusScheduleViewModel by activityViewModels {
        BusScheduleViewModelFactory(
            (activity?.application as BusScheduleApplication).database.scheduleDao()
        )
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        /* Flow 만 사용할 경우
        lifecycle.coroutineScope.launch(Dispatchers.IO) {
            viewModel.scheduleForStopName(stopName).collect() { it ->
                // 데이터가 변경될 때마다 실행된다.
                busStopAdapter.submitList(it)
            }
        }*/
        
        // LiveData Observer Setting
        viewModel.scheduleItems.observe(this.viewLifecycleOwner) {
            busStopAdapter.submitList(it)
        }
    }
}

 

 

 

 

Room  |  Jetpack  |  Android Developers

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Room Room 지속성 라이브러리는 SQLite에 추상화 레이어를 제공하여 SQLite를 완벽히 활용하면서 더 견고한 데이터

developer.android.com

 

AsyncListDiffer  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

 

ListAdapter  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

 

Room을 사용하여 로컬 데이터베이스에 데이터 저장  |  Android 개발자  |  Android Developers

Room 라이브러리를 사용하여 더 쉽게 데이터를 유지하는 방법 알아보기

developer.android.com

 

 

Database Inspector로 데이터베이스 디버그  |  Android 스튜디오  |  Android Developers

Database Inspector로 데이터베이스 디버그 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Database Inspector를 사용하면 앱 실행 중에 앱의 데이터베이스를 검사하

developer.android.com

 

728x90