Room 및 Flow 소개

1. 시작하기 전에

이전 Codelab에서는 관계형 데이터베이스의 기초와 SQL 명령어 SELECT, INSERT, UPDATE, DELETE를 사용하여 데이터를 읽고 쓰는 방법을 알아봤습니다. 관계형 데이터베이스 사용법을 아는 것은 프로그래밍 전반에 걸쳐 필요한 기본 기술입니다. 관계형 데이터베이스의 작동 방식을 이해하는 것은 또한 Android 애플리케이션에서 데이터 지속성을 구현하는 데 필수적이며, 데이터 지속성 구현은 이 과정에서 처음 배우게 됩니다.

Android 앱에서 데이터베이스를 쉽게 사용하는 방법은 Room이라는 라이브러리를 사용하는 것입니다. Room은 ORM(객체 관계형 매핑) 라이브러리라고 하며 이름에서 알 수 있듯이 관계형 데이터베이스의 테이블을 Kotlin 코드에서 사용할 수 있는 객체에 매핑합니다. 이 과정에서는 데이터 읽기에만 중점을 둡니다. 미리 채워진 데이터베이스를 사용하여 버스 도착 시간 테이블에서 데이터를 로드하여 RecyclerView에 표시합니다.

70c597851eba9518.png

이 과정에서 데이터베이스 클래스, DAO, 항목, 뷰 모델을 비롯한 Room 사용의 기초를 배웁니다. RecyclerView에 데이터를 표시하는 또 다른 방법인 ListAdapter 클래스, 그리고 UI가 데이터베이스의 변경사항에 응답할 수 있도록 하는 LiveData와 비슷한 Kotlin 언어 기능인 흐름도 알아봅니다.

기본 요건

  • 객체 지향 프로그래밍과 Kotlin의 클래스, 객체, 상속 사용에 관한 지식
  • SQL 기본사항 Codelab에서 알아본 관계형 데이터베이스와 SQL에 관한 기본 지식
  • Kotlin 코루틴 사용 경험

학습할 내용

이 과정을 마치면 다음 작업을 할 수 있습니다.

  • 데이터베이스 테이블을 Kotlin 객체(항목)로 나타냅니다.
  • 앱에서 Room을 사용하도록 데이터베이스 클래스를 정의하고 파일의 데이터베이스를 미리 채웁니다.
  • DAO 클래스를 정의하고 SQL 쿼리를 사용하여 Kotlin 코드에서 데이터베이스에 액세스합니다.
  • UI가 DAO와 상호작용하도록 뷰 모델을 정의합니다.
  • recycler 뷰와 함께 ListAdapter를 사용하는 방법
  • Kotlin 흐름의 기본사항과 이를 사용하여 UI가 기본 데이터의 변경사항에 응답하도록 하는 방법

빌드할 항목

  • Room을 사용하여 미리 채워진 데이터베이스의 데이터를 읽고 간단한 버스 운행 일정 앱의 recycler 뷰에 표시합니다.

2. 시작하기

이 Codelab에서 사용할 앱은 Bus Schedule입니다. 앱에는 버스 정류장과 도착 시간(가장 빠른 도착 시간부터 가장 늦은 도착 시간까지) 목록이 표시됩니다.

70c597851eba9518.png

첫 번째 화면에서 행을 탭하면 선택한 버스 정류장의 예정된 도착 시간만 보여주는 새 화면이 표시됩니다.

f477c0942746e584.png

버스 정류장 데이터는 앱과 함께 사전 패키징된 데이터베이스에서 가져옵니다. 그러나 현재 상태에서는 앱을 처음 실행할 때 아무것도 표시되지 않습니다. 앱이 미리 채워진 도착 시간 데이터베이스를 표시하도록 Room을 통합해야 합니다.

  1. 프로젝트에 제공된 GitHub 저장소 페이지로 이동합니다.
  2. 브랜치 이름이 Codelab에 지정된 브랜치 이름과 일치하는지 확인합니다. 예를 들어 다음 스크린샷에서 브랜치 이름은 main입니다.

1e4c0d2c081a8fd2.png

  1. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 팝업을 엽니다.

1debcf330fd04c7b.png

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

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

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

d8e9dbdeafe9038a.png

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

8d1fda7396afe8e5.png

  1. 파일 브라우저에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 8de56cba7583251f.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.

3. Room 종속 항목 추가

다른 라이브러리와 마찬가지로 먼저 필요한 종속 항목을 추가해야 Bus Schedule 앱에서 Room을 사용할 수 있습니다. 각 Gradle 파일마다 하나씩 두 가지만 간단히 변경하면 됩니다.

  1. 프로젝트 수준 build.gradle 파일에서 ext 블록에 room_version을 정의합니다.
ext {
   kotlin_version = "1.6.20"
   nav_version = "2.4.1"
   room_version = '2.4.2'
}
  1. 앱 수준 build.gradle 파일에서 종속 항목 목록 끝에 다음 종속 항목을 추가합니다.
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"
  1. 변경사항을 동기화하고 프로젝트를 빌드하여 종속 항목이 올바르게 추가되었는지 확인합니다.

앞으로 몇 페이지에 걸쳐 Room을 앱에 통합하는 데 필요한 구성요소인 모델과 DAO, 뷰 모델, 데이터베이스 클래스를 알아봅니다.

4. 항목 만들기

이전 Codelab에서 관계형 데이터베이스를 학습할 때 데이터가 여러 열(각 열은 특정 데이터 유형의 특정 속성을 나타냄)로 이루어진 테이블로 구성되는 방법을 확인했습니다. Kotlin의 클래스가 각 객체의 템플릿을 제공하는 것처럼 데이터베이스의 테이블은 테이블의 각 항목 또는 행의 템플릿을 제공합니다. 따라서 Kotlin 클래스를 사용하여 데이터베이스의 각 테이블을 나타낼 수 있습니다.

Room을 사용할 때 각 테이블은 클래스로 표시됩니다. Room과 같은 ORM(객체 관계형 매핑) 라이브러리에서는 이를 모델 클래스 또는 항목이라고 합니다.

Bus Schedule 앱의 데이터베이스는 버스 도착 시간에 관한 기본 정보가 포함된 단일 테이블(일정)로만 구성됩니다.

  • id: 기본 키 역할을 하는 고유 식별자를 제공하는 정수입니다.
  • stop_name: 문자열입니다.
  • arrival_time: 정수입니다.

데이터베이스에서 사용되는 SQL 유형은 실제로 Int의 경우 INTEGER이고 String의 경우 TEXT입니다. 그러나 Room을 사용할 때 모델 클래스를 정의하는 경우 Kotlin 유형만 고려하면 됩니다. 모델 클래스의 데이터 유형을 데이터베이스에서 사용되는 데이터 유형에 매핑하는 작업은 자동으로 처리됩니다.

프로젝트에 여러 파일이 있는 경우 클래스별로 더 효과적인 액세스 제어를 제공하고 관련 클래스를 더 쉽게 찾을 수 있도록 파일을 여러 패키지로 정리하는 것이 좋습니다. '일정' 테이블의 항목을 만들려면 com.example.busschedule 패키지에서 database라는 새 패키지를 추가합니다. 이 패키지 내에서 항목을 위해 schedule이라는 새 패키지를 추가합니다. 그런 다음 database.schedule 패키지에서 Schedule.kt라는 새 파일을 만들고 Schedule이라는 데이터 클래스를 정의합니다.

data class Schedule(
)

SQL 기본사항 과정에서 알아봤듯이 데이터 테이블에는 각 행을 고유하게 식별하는 기본 키가 있어야 합니다. Schedule 클래스에 추가할 첫 번째 속성은 고유 ID를 나타내는 정수입니다. 새 속성을 추가하고 @PrimaryKey 주석으로 표시합니다. 그러면 새 행이 삽입될 때 Room에 이 속성을 기본 키로 처리하라고 지시합니다.

@PrimaryKey val id: Int

버스 정류장 이름 열을 추가합니다. 열은 String 유형이어야 합니다. 새 열의 경우 @ColumnInfo 주석을 추가하여 열 이름을 지정해야 합니다. 일반적으로 SQL 열 이름에는 Kotlin 속성에 사용되는 lowerCamelCase와 달리 밑줄로 구분된 단어가 있습니다. 이 열의 경우에도 값이 null이 되지 않도록 @NonNull 주석으로 표시해야 합니다.

@NonNull @ColumnInfo(name = "stop_name") val stopName: String,

도착 시간은 정수를 사용하여 데이터베이스에 표시됩니다. 사용 가능한 날짜로 변환할 수 있는 Unix 타임스탬프입니다. 여러 버전의 SQL에서 날짜 변환 방법을 제공하지만 여기서는 Kotlin 날짜 형식 지정 함수를 계속 사용합니다. 다음 @NonNull 열을 모델 클래스에 추가합니다.

@NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int

마지막으로 이 클래스를 데이터베이스 테이블을 정의하는 데 사용할 수 있는 것으로 Room이 인식하도록 클래스 자체에 주석을 추가해야 합니다. 별도의 줄에서 클래스 이름 앞에 @Entity를 추가합니다.

기본적으로 Room은 클래스 이름을 데이터베이스 테이블 이름으로 사용합니다. 따라서 현재 클래스에서 정의한 테이블 이름은 Schedule이 됩니다. 선택적으로 @Entity(tableName="schedule")로 지정할 수도 있지만 Room 쿼리는 대소문자를 구분하지 않으므로 여기에서 소문자 테이블 이름을 명시적으로 정의하지 않아도 됩니다.

일정 항목의 클래스가 이제 다음과 같이 표시됩니다.

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

5. DAO 정의

Room을 통합하기 위해 추가해야 할 다음 클래스는 DAO입니다. DAO는 데이터 액세스 객체를 나타내며 데이터 액세스 권한을 제공하는 Kotlin 클래스입니다. 특히 DAO에는 데이터를 읽고 조작하는 함수가 포함됩니다. DAO에서 함수를 호출하는 것은 데이터베이스에서 SQL 명령어를 실행하는 것과 같습니다. 실제로 이 앱에서 정의하는 함수와 같은 DAO 함수는 SQL 명령어를 지정하는 때가 많으므로 개발자가 함수의 기능을 정확하게 지정할 수 있습니다. 이전 Codelab에서 배운 SQL 지식은 DAO를 정의할 때 유용하게 사용됩니다.

  1. 일정 항목의 DAO 클래스를 추가합니다. database.schedule 패키지에서 ScheduleDao.kt라는 새 파일을 만들고 ScheduleDao라는 인터페이스를 정의합니다. Schedule 클래스와 마찬가지로 이번에는 @Dao 주석을 추가하여 Room에서 인터페이스를 사용할 수 있도록 해야 합니다.
@Dao
interface ScheduleDao {
}
  1. 앱에는 화면이 두 개 있고 각 화면에는 다른 쿼리가 필요합니다. 첫 번째 화면에는 버스 정류장이 도착 시간에 따라 오름차순으로 모두 표시됩니다. 이 사용 사례에서는 쿼리가 모든 열을 가져오고 적절한 ORDER BY 절을 포함하기만 하면 됩니다. 쿼리는 @Query 주석에 전달된 문자열로 지정됩니다. 아래와 같이 @Query 주석을 비롯하여 Schedule 객체 목록을 반환하는 getAll() 함수를 정의합니다.
@Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
fun getAll(): List<Schedule>
  1. 두 번째 쿼리의 경우 일정 테이블에서 모든 열도 선택하려고 합니다. 그러나 선택한 정류장 이름과 일치하는 결과만 원하므로 WHERE 절을 추가해야 합니다. 앞에 콜론(:)을 붙여(예: 함수 매개변수의 :stopName) 쿼리에서 Kotlin 값을 참조할 수 있습니다. 이전과 마찬가지로 결과는 도착 시간에 따라 오름차순으로 정렬됩니다. 아래와 같이 @Query 주석으로 stopName이라는 String 매개변수를 사용하고 Schedule 객체의 List를 반환하는 getByStopName() 함수를 정의합니다.
@Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
fun getByStopName(stopName: String): List<Schedule>

6. ViewModel 정의

이제 DAO를 설정했으므로 기술적으로 프래그먼트에서 데이터베이스에 액세스하는 데 필요한 모든 것을 갖추었습니다. 그러나 이론적으로는 문제가 없지만 일반적으로 권장되지는 않습니다. 더 복잡한 앱에는 데이터의 특정 부분에만 액세스하는 화면이 여러 개 있을 수 있기 때문입니다. ScheduleDao는 비교적 간단하지만 두 개 이상의 화면으로 작업할 때 얼마나 제어하기 어려운지 쉽게 확인할 수 있습니다. 예를 들어 DAO는 다음과 같을 수 있습니다.

@Dao
interface ScheduleDao {

    @Query(...)
    getForScreenOne() ...

    @Query(...)
    getForScreenTwo() ...

    @Query(...)
    getForScreenThree()

}

화면 1의 코드는 getForScreenOne()에 액세스할 수 있지만 다른 메서드에 액세스할 이유는 없습니다. 대신 뷰에 노출하는 DAO의 일부를 뷰 모델이라는 별도의 클래스로 분리하는 것이 좋습니다. 이는 모바일 앱의 일반적인 아키텍처 패턴입니다. 뷰 모델을 사용하면 앱의 UI 코드와 데이터 모델을 명확하게 구분할 수 있습니다. 코드의 각 부분도 독립적으로 테스트할 수 있습니다. 이 주제는 Android 개발 여정을 계속하면서 자세히 살펴봅니다.

ee2524be13171538.png

뷰 모델을 사용하면 ViewModel 클래스를 활용할 수 있습니다. ViewModel 클래스는 앱 UI 관련 데이터를 저장하는 데 사용되고 수명 주기도 인식하므로 활동이나 프래그먼트와 마찬가지로 수명 주기 이벤트에 응답합니다. 화면 회전과 같은 수명 주기 이벤트로 인해 활동이나 프래그먼트가 소멸되었다가 다시 생성되면 연결된 ViewModel을 다시 만들 필요가 없습니다. DAO 클래스에 직접 액세스할 때는 불가능하므로 ViewModel 서브클래스를 사용하여 활동이나 프래그먼트에서 데이터를 로드하는 책임을 분리하는 것이 좋습니다.

  1. 뷰 모델 클래스를 만들려면 viewmodels라는 새 패키지에서 BusScheduleViewModel.kt라는 새 파일을 만듭니다. 뷰 모델의 클래스를 정의합니다. ScheduleDao 유형의 단일 매개변수를 사용해야 합니다.
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {
  1. 이 뷰 모델은 두 화면에서 모두 사용되므로 전체 일정뿐 아니라 정류장 이름으로 필터링된 일정을 가져오는 메서드를 추가해야 합니다. ScheduleDao에서 상응하는 메서드를 호출하면 됩니다.
fun fullSchedule(): List<Schedule> = scheduleDao.getAll()

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

뷰 모델 정의를 완료했지만 BusScheduleViewModel을 직접 인스턴스화하고 모든 것이 작동할 것으로 기대할 수는 없습니다. ViewModel 클래스 BusScheduleViewModel은 수명 주기를 인식해야 하므로 수명 주기 이벤트에 응답할 수 있는 객체로 인스턴스화해야 합니다. 프래그먼트 중 하나에서 직접 인스턴스화하면 프래그먼트 객체가 앱 코드의 기능 범위를 벗어나는 모든 메모리 관리 등 모든 것을 처리해야 합니다. 대신 뷰 모델 객체를 인스턴스화하는 팩토리라는 클래스를 만들 수 있습니다.

  1. 팩토리를 만들려면 뷰 모델 클래스 아래에서 ViewModelProvider.Factory에서 상속받는 새 클래스 BusScheduleViewModelFactory를 만듭니다.
class BusScheduleViewModelFactory(
   private val scheduleDao: ScheduleDao
) : ViewModelProvider.Factory {
}
  1. 뷰 모델을 올바르게 인스턴스화하려면 약간의 상용구 코드만 있으면 됩니다. 클래스를 직접 초기화하는 대신 일부 오류 검사와 함께 BusScheduleViewModelFactory를 반환하는 create()라는 메서드를 재정의합니다. 다음과 같이 BusScheduleViewModelFactory 클래스 내에서 create()를 구현합니다.
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")
   }

이제 BusScheduleViewModelFactory.create()BusScheduleViewModelFactory 객체를 인스턴스화할 수 있으므로 프래그먼트가 이를 직접 처리하지 않고도 뷰 모델이 수명 주기를 인식할 수 있습니다.

7. 데이터베이스 클래스 만들기 및 데이터베이스 미리 채우기

이제 DAO에 액세스하기 위한 프래그먼트의 모델과 DAO, 뷰 모델을 정의했지만 여전히 이러한 모든 클래스로 할 작업을 Room에 알려야 합니다. 여기서 AppDatabase 클래스가 필요합니다. Room을 사용하는 Android 앱은 RoomDatabase 클래스를 서브클래스로 분류하고 몇 가지 주요 책임이 있습니다. 앱에서 AppDatabase는 다음 작업을 실행해야 합니다.

  1. 데이터베이스에서 정의되는 항목을 지정합니다.
  2. 각 DAO 클래스의 단일 인스턴스 액세스 권한을 제공합니다.
  3. 데이터베이스 미리 채우기와 같은 추가 설정을 실행합니다.

Room이 항목과 DAO 객체를 모두 찾을 수 없는 이유가 궁금할 수 있지만 앱에는 데이터베이스가 여러 개 있거나 라이브러리가 개발자의 의도를 가정할 수 없는 시나리오가 얼마든지 있을 수 있습니다. AppDatabase 클래스를 사용하면 모델과 DAO 클래스, 실행하려는 모든 데이터베이스 설정을 완벽하게 제어할 수 있습니다.

  1. AppDatabase 클래스를 추가하려면 database 패키지에서 AppDatabase.kt라는 새 파일을 만들고 RoomDatabase에서 상속받는 새 추상 클래스 AppDatabase를 정의합니다.
abstract class AppDatabase: RoomDatabase() {
}
  1. 데이터베이스 클래스를 통해 다른 클래스에서 DAO 클래스에 쉽게 액세스할 수 있습니다. ScheduleDao를 반환하는 추상 함수를 추가합니다.
abstract fun scheduleDao(): ScheduleDao
  1. AppDatabase 클래스를 사용할 때는 경합 상태나 기타 잠재적 문제를 방지하려고 데이터베이스 인스턴스가 하나만 있는지 확인하려고 합니다. 인스턴스는 컴패니언 객체에 저장되며 기존 인스턴스를 반환하거나 데이터베이스를 처음으로 만드는 메서드도 필요합니다. 컴패니언 객체에 정의되어 있습니다. scheduleDao() 함수 바로 아래에 다음의 companion object를 추가합니다.
companion object {
}

companion object에서 AppDatabase 유형의 INSTANCE라는 속성을 추가합니다. 이 값은 처음에 null로 설정되므로 유형이 ?로 표시됩니다. 또한, @Volatile 주석으로도 표시됩니다. volatile 속성의 사용 시점에 관한 세부정보는 이 과정의 경우 약간 고급 내용이지만 잠재적 버그를 방지하기 위해 AppDatabase 인스턴스에 사용하는 것이 좋습니다.

@Volatile
private var INSTANCE: AppDatabase? = null

INSTANCE 속성 아래에서 AppDatabase 인스턴스를 반환하는 함수를 정의합니다.

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

        instance
    }
}

getDatabase() 구현에서 Elvis 연산자를 사용하여 데이터베이스의 기존 인스턴스(이미 있는 경우)를 반환하거나 필요하다면 처음으로 데이터베이스를 만듭니다. 이 앱에서는 데이터가 미리 채워져 있기 때문입니다. createFromAsset()도 호출하여 기존 데이터를 로드합니다. bus_schedule.db 파일은 프로젝트의 assets.database 패키지에서 찾을 수 있습니다.

  1. 모델 클래스, DAO와 마찬가지로 데이터베이스 클래스에는 특정 정보를 제공하는 주석이 있어야 합니다. 모든 항목 유형(ClassName::class를 사용하여 유형 자체에 액세스)은 배열로 나열됩니다. 데이터베이스에는 버전 번호도 부여되며 1로 설정됩니다. 다음과 같이 @Database 주석을 추가합니다.
@Database(entities = arrayOf(Schedule::class), version = 1)

AppDatabase 클래스를 만들었으니 이제 한 단계만 거치면 사용할 수 있습니다. Application 클래스의 맞춤 서브클래스를 제공하고 getDatabase()의 결과를 보유할 lazy 속성을 만들어야 합니다.

  1. com.example.busschedule 패키지에서 BusScheduleApplication.kt라는 새 파일을 추가하고 Application에서 상속받는 BusScheduleApplication 클래스를 만듭니다.
class BusScheduleApplication : Application() {
}
  1. AppDatabase 유형의 데이터베이스 속성을 추가합니다. 이 속성은 지연되고 AppDatabase 클래스에서의 getDatabase() 호출 결과를 반환해야 합니다.
class BusScheduleApplication : Application() {
   val database: AppDatabase by lazy { AppDatabase.getDatabase(this) }
  1. 마지막으로 기본 클래스 Application이 아닌 BusScheduleApplication 클래스가 사용되도록 하려면 매니페스트를 약간 변경해야 합니다. AndroidMainifest.xml에서 android:name 속성을 com.example.busschedule.BusScheduleApplication으로 설정합니다.
<application
    android:name="com.example.busschedule.BusScheduleApplication"
    ...

이것으로 앱 모델 설정을 완료했습니다. 이제 UI에서 Room의 데이터를 사용할 수 있습니다. 다음 몇 페이지에서는 앱 RecyclerViewListAdapter를 만들어 버스 운행 일정 데이터를 표시하고 데이터 변경에 동적으로 응답합니다.

8. ListAdapter 만들기

이제 어려운 작업을 모두 마쳤으니 모델을 뷰에 연결해보겠습니다. 이전에는 RecyclerView를 사용할 때 RecyclerView.Adapter를 사용하여 정적 데이터 목록을 표시했습니다. 이 방법은 Bus Schedule과 같은 앱에도 확실히 효과적이겠지만 데이터베이스를 사용할 때 일반적인 시나리오는 데이터 변경사항을 실시간으로 처리하는 것입니다. 한 항목의 콘텐츠만 변경되더라도 전체 recycler 뷰가 새로고침됩니다. 지속성을 사용하는 대부분의 앱에는 이 방식이 충분하지 않습니다.

동적으로 변경되는 목록의 대안은 ListAdapter입니다. ListAdapterAsyncListDiffer를 사용하여 이전 데이터 목록과 새 데이터 목록의 차이를 확인합니다. 그러면 recycler 뷰가 두 목록 간 차이에 기반해서만 업데이트됩니다. 그 결과 recycler 뷰가 자주 업데이트되는 데이터를 처리할 때 더 나은 성능을 발휘합니다. 데이터베이스 애플리케이션에 있는 경우가 많기 때문입니다.

f59cc2fd4d72c551.png

UI는 두 화면에서 모두 같으므로 두 화면에서 사용할 수 있는 ListAdapter를 하나만 만들면 됩니다.

  1. 다음과 같이 새 파일 BusStopAdapter.ktBusStopAdapter 클래스를 만듭니다. 이 클래스는 Schedule 객체 목록과 UI의 BusStopViewHolder 클래스를 사용하는 일반 ListAdapter를 확장합니다. BusStopViewHolder의 경우 곧 정의할 DiffCallback 유형도 전달합니다. BusStopAdapter 클래스 자체도 onItemClicked() 매개변수를 사용합니다. 이 함수는 항목이 첫 번째 화면에서 선택될 때 탐색을 처리하는 데 사용되지만 두 번째 화면의 경우 빈 함수만 전달합니다.
class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {
}
  1. recycler 뷰 어댑터와 마찬가지로 뷰 홀더가 있어야 코드의 레이아웃 파일에서 만들어진 뷰에 액세스할 수 있습니다. 셀의 레이아웃은 이미 만들어져 있습니다. 다음과 같이 간단히 BusStopViewHolder 클래스를 만들고 bind() 함수를 구현하여 stopNameTextView의 텍스트를 정류장 이름으로 설정하고 arrivalTimeTextView의 텍스트를 형식이 지정된 날짜로 설정합니다.
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)
        )
    }
}
  1. onCreateViewHolder()를 재정의하여 구현하고 레이아웃을 확장하고 현재 위치의 항목에 onItemClicked()를 호출하도록 onClickListener()를 설정합니다.
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
}
  1. onBindViewHolder()를 재정의하여 구현하고 지정된 위치에 뷰를 바인딩합니다.
override fun onBindViewHolder(holder: BusStopViewHolder, position: Int) {
   holder.bind(getItem(position))
}
  1. ListAdapter에 지정한 DiffCallback 클래스를 기억하나요? 이는 ListAdapter가 목록을 업데이트할 때 새 목록과 이전 목록에서 어떤 항목이 다른지 확인하는 데 도움이 되는 객체일 뿐입니다. 두 가지 메서드가 있습니다. areItemsTheSame()은 ID만 확인하여 객체(또는 여기서는 데이터베이스의 행)가 같은지 확인합니다. areContentsTheSame()은 ID만 아니라 모든 속성이 같은지 확인합니다. 이러한 메서드를 통해 ListAdapter는 삽입되거나 업데이트되거나 삭제된 항목을 확인할 수 있으므로 UI를 적절히 업데이트할 수 있습니다.

컴패니언 객체를 추가하고 다음과 같이 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
       }
   }
}

이것으로 어댑터 설정을 완료했습니다. 앱의 두 화면에서 모두 사용합니다.

  1. 먼저 FullScheduleFragment.kt에서 뷰 모델 참조를 가져와야 합니다.
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. 그런 다음 onViewCreated()에서 다음 코드를 추가하여 recycler 뷰를 설정하고 레이아웃 관리자를 할당합니다.
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
  1. 이제 어댑터 속성을 할당합니다. 전달된 작업은 stopName을 사용하여 선택된 다음 화면을 탐색하므로 버스 정류장 목록이 필터링될 수 있습니다.
val busStopAdapter = BusStopAdapter({
   val action = FullScheduleFragmentDirections.actionFullScheduleFragmentToStopScheduleFragment(
       stopName = it.stopName
   )
   view.findNavController().navigate(action)
})
recyclerView.adapter = busStopAdapter
  1. 마지막으로 목록 보기를 업데이트하려면 submitList()를 호출하여 뷰 모델에서 버스 정류장 목록을 전달합니다.
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.fullSchedule())
}
  1. StopScheduleFragment에도 동일한 작업을 합니다. 먼저 뷰 모델 참조를 가져옵니다.
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. 그런 다음 onViewCreated()에서 recycler 뷰를 구성합니다. 이번에는 {}를 사용하여 빈 블록(함수)을 전달하기만 하면 됩니다. 이 화면의 행을 탭할 때 실제로 어떤 일이 일어나기를 바라지는 않습니다.
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
val busStopAdapter = BusStopAdapter({})
recyclerView.adapter = busStopAdapter
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.scheduleForStopName(stopName))
}
  1. 어댑터를 설정했으므로 이제 Room이 Bus Schedue 앱에 통합되었습니다. 잠시 시간을 내어 앱을 실행하면 도착 시간 목록이 표시됩니다. 행을 탭하면 세부정보 화면으로 이동합니다.

9. Flow를 사용하여 데이터 변경사항에 응답

목록 보기가 submitList()가 호출될 때마다 데이터 변경사항을 효율적으로 처리하도록 설정되어 있지만 앱에서는 아직 동적 업데이트를 처리할 수 없습니다. 직접 확인하려면 Database Inspector를 열고 다음 쿼리를 실행하여 일정 테이블에 새 항목을 삽입해보세요.

INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

그러나 에뮬레이터에서는 아무 일도 일어나지 않습니다. 사용자는 데이터가 변경되지 않았다고 가정하게 됩니다. 변경사항을 확인하려면 앱을 다시 실행해야 합니다.

문제는 List<Schedule>이 각 DAO 함수에서 한 번만 반환된다는 것입니다. 기본 데이터가 업데이트되더라도 UI를 업데이트하려고 submitList()가 호출되지 않으므로 사용자 관점에서는 아무것도 변경되지 않은 것처럼 보입니다.

이 문제를 수정하려면 DAO가 데이터베이스에서 데이터를 지속적으로 내보낼 수 있는 비동기 흐름(종종 흐름이라고만 함)이라는 Kotlin 기능을 활용하면 됩니다. 항목이 삽입되거나 업데이트되거나 삭제되면 그 결과가 프래그먼트로 다시 전송됩니다. collect(),라는 함수를 사용하면 흐름에서 내보낸 새 값을 사용하여 submitList()를 호출할 수 있으므로 ListAdapter가 새 데이터에 기반하여 UI를 업데이트할 수 있습니다.

  1. Bus Schedule에서 흐름을 사용하려면 ScheduleDao.kt를 엽니다. Flow를 반환하도록 DAO 함수를 변환하려면 getAll() 함수의 반환 유형을 Flow<List<Schedule>>로 변경하면 됩니다.
fun getAll(): Flow<List<Schedule>>
  1. 마찬가지로 getByStopName() 함수의 반환 값을 업데이트합니다.
fun getByStopName(stopName: String): Flow<List<Schedule>>
  1. DAO에 액세스하는 뷰 모델의 함수도 업데이트해야 합니다. fullSchedule()scheduleForStopName()에서 모두 반환 값을 Flow<List<Schedule>>로 업데이트합니다.
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {

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

   fun scheduleForStopName(name: String): Flow<List<Schedule>> = scheduleDao.getByStopName(name)
}
  1. 마지막으로 FullScheduleFragment.kt에서 busStopAdapter는 개발자가 쿼리 결과에서 collect()를 호출할 때 업데이트해야 합니다. fullSchedule()은 정지 함수이므로 코루틴에서 호출해야 합니다. 아래 행을
busStopAdapter.submitList(viewModel.fullSchedule())

fullSchedule()에서 반환된 흐름을 사용하는 다음 코드로 바꿉니다.

lifecycle.coroutineScope.launch {
   viewModel.fullSchedule().collect() {
       busStopAdapter.submitList(it)
   }
}
  1. StopScheduleFragment에서도 같은 작업을 실행하지만 scheduleForStopName() 호출을 다음으로 바꿉니다.
lifecycle.coroutineScope.launch {
   viewModel.scheduleForStopName(stopName).collect() {
       busStopAdapter.submitList(it)
   }
}
  1. 위와 같이 변경한 후 앱을 다시 실행하여 데이터 변경사항이 이제 실시간으로 처리되는지 확인할 수 있습니다. 앱이 실행되면 Database Inspector로 돌아가 다음 쿼리를 전송하여 오전 8시 전에 새 도착 시간을 삽입합니다.
INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

새 항목이 목록 상단에 표시됩니다.

79d6206fc9911fa9.png

이것으로 Bus Schedule 앱을 마칩니다. 여기까지 잘하셨습니다. 이제 Room을 사용하기 위한 기본사항을 학습했습니다. 다음 과정에서는 새 샘플 앱을 사용하여 Room에 관해 자세히 알아보고 기기에서 사용자가 만든 데이터를 저장하는 방법을 알아봅니다.

10. 솔루션 코드

이 Codelab의 솔루션 코드는 아래에 나온 프로젝트와 모듈에 있습니다.

  1. 프로젝트에 제공된 GitHub 저장소 페이지로 이동합니다.
  2. 브랜치 이름이 Codelab에 지정된 브랜치 이름과 일치하는지 확인합니다. 예를 들어 다음 스크린샷에서 브랜치 이름은 main입니다.

1e4c0d2c081a8fd2.png

  1. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 팝업을 엽니다.

1debcf330fd04c7b.png

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

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

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

d8e9dbdeafe9038a.png

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

8d1fda7396afe8e5.png

  1. 파일 브라우저에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 8de56cba7583251f.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.

11. 축하합니다

요약하면 다음과 같습니다.

  • SQL 데이터베이스의 테이블은 항목이라는 Kotlin 클래스로 Room에 표시됩니다.
  • DAO는 데이터베이스와 상호작용하는 SQL 명령어에 상응하는 메서드를 제공합니다.
  • ViewModel은 앱의 데이터를 뷰에서 분리하는 데 사용되는 수명 주기 인식 구성요소입니다.
  • AppDatabase 클래스는 사용할 항목을 Room에 알리고 DAO 액세스 권한을 제공하며 데이터베이스를 만들 때 모든 설정을 실행합니다.
  • ListAdapterRecyclerView와 함께 사용되는 어댑터로, 동적으로 업데이트된 목록 처리에 적합합니다.
  • Flow는 데이터 스트림을 반환하는 Kotlin 기능으로, Room과 함께 사용하여 UI와 데이터베이스가 동기화되도록 할 수 있습니다.

자세히 알아보기