뷰를 사용한 Android Room - Kotlin

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

Android 아키텍처 구성요소 모음은 수명 주기 관리 및 데이터 지속성과 같은 일반적인 작업을 위한 라이브러리와 함께 앱 아키텍처에 관한 안내를 제공합니다. 아키텍처 구성요소를 사용하면 상용구 코드는 적게 사용하면서 테스트 가능하며 유지관리가 쉬운 안정적인 방식으로 앱을 구조화할 수 있습니다.

아키텍처 구성요소 라이브러리는 Android Jetpack의 일부입니다.

Kotlin 버전의 Codelab입니다. 자바 프로그래밍 언어 버전은 여기에서 확인할 수 있습니다.

이 Codelab을 진행하는 동안 코드 버그, 문법 오류, 불명확한 콘텐츠 등의 문제가 발생하면 Codelab 왼쪽 하단에 있는 오류 신고 링크를 통해 신고해 주세요.

기본 요건

특히 아래 사항을 비롯하여 Kotlin, 객체 지향 디자인 개념, Android 개발 기본사항을 잘 알고 있어야 합니다.

Model-View-Presenter(MVP)나 Model-View-Controller(MVC)와 같이 사용자 인터페이스에서 데이터를 분리하는 소프트웨어 아키텍처 패턴을 아는 것도 도움이 됩니다. 이 Codelab은 Android 개발자 문서의 앱 아키텍처 가이드에 정의된 아키텍처를 구현합니다.

이 Codelab에서는 Android 아키텍처 구성요소에 중점을 둡니다. 간단히 복사하여 붙여넣을 수 있도록 주제에서 벗어난 개념과 코드가 제공됩니다.

실행할 작업

아키텍처 구성요소 Room, ViewModel, LiveData를 사용하여 앱을 설계하고 구성하는 방법을 알아봅니다. 이 앱에서 하는 작업은 다음과 같습니다.

  • Android 아키텍처 구성요소를 사용하여 권장 아키텍처를 구현합니다.
  • 데이터를 가져오고 저장하며 샘플 단어로 데이터베이스를 미리 채우도록 데이터베이스를 사용합니다.
  • MainActivity 클래스의 RecyclerView에 있는 모든 단어를 표시합니다.
  • 사용자가 + 버튼을 탭하면 두 번째 활동이 열립니다. 사용자가 단어를 입력하면 입력한 단어가 데이터베이스에 추가되고 RecyclerView 목록에 표시됩니다.

이 앱은 기본적이지만 앱을 빌드할 때 템플릿으로 사용하기에 충분한 복합 기능이 있습니다. 다음은 미리보기입니다.

필요한 항목

  • Android 스튜디오 4.0 이상과 사용 방법에 관한 지식. Android 스튜디오, SDK, Gradle이 업데이트되었는지 확인합니다.
  • Android 기기 또는 에뮬레이터

이 Codelab에서는 전체 앱을 빌드하는 데 필요한 코드를 모두 제공합니다.

다음은 아키텍처 구성요소와 구성요소의 작동 방식을 설명하는 간단한 다이어그램입니다. 이 Codelab에서는 구성요소의 하위 집합, 즉 LiveData, ViewModel, Room에 중점을 둡니다. 각 구성요소는 앱에서 사용할 때 자세히 설명합니다.

8e4b761713e3a76b.png

LiveData: 관찰할 수 있는 데이터 홀더 클래스입니다. 항상 최신 버전의 데이터를 보유/캐시하고 데이터가 변경된 경우 관찰자에게 알립니다. LiveData는 수명 주기를 인식합니다. UI 구성요소는 관련 데이터를 관찰하기만 하며 관찰을 중지하거나 재개하지 않습니다. LiveData는 관찰하는 동안 관련 수명 주기 상태의 변경을 인식하므로 이 모든 것을 자동으로 관리합니다.

ViewModel: 저장소(데이터)와 UI 간의 통신 센터 역할을 합니다. UI에서 더 이상 데이터의 출처에 관해 걱정하지 않아도 됩니다. ViewModel 인스턴스는 Activity/Fragment 재생성에도 유지됩니다.

저장소: 개발자가 만드는 클래스로, 여러 데이터 소스를 관리하는 데 주로 사용됩니다.

Entity: Room 작업 시 데이터베이스 테이블을 설명하는 주석 처리된 클래스입니다.

Room 데이터베이스: 데이터베이스 작업을 간소화하고 기본 SQLite 데이터베이스의 액세스 포인트 역할을 합니다(숨김: SQLiteOpenHelper) Room 데이터베이스는 DAO를 사용하여 SQLite 데이터베이스에 쿼리를 실행합니다.

SQLite 데이터베이스: 기기 내 저장소입니다. Room 지속성 라이브러리에서 이 데이터베이스를 만들고 유지관리합니다.

DAO: 데이터 액세스 객체입니다. SQL 쿼리를 함수에 매핑합니다. DAO를 사용할 때 메서드를 호출하면 Room에서 나머지를 처리합니다.

RoomWordSample 아키텍처 개요

다음 다이어그램은 앱의 모든 요소가 상호작용하는 방식을 보여줍니다. 각 직사각형 상자(SQLite 데이터베이스 제외)는 개발자가 만들 클래스를 나타냅니다.

a70aca8d4b737712.png

  1. Android 스튜디오를 열고 Start a new Android Studio project를 클릭합니다.
  2. Create New Project 창에서 Empty Activity를 선택하고 Next를 클릭합니다.
  3. 다음 화면에서 앱 이름을 RoomWordSample로 지정하고 Finish를 클릭합니다.

9b6cbaec81794071.png

다음으로 구성요소 라이브러리를 Gradle 파일에 추가해야 합니다.

  1. Android 스튜디오에서 Projects 탭을 클릭하고 Gradle Scripts 폴더를 펼칩니다.

build.gradle(Module: app)을 엽니다.

  1. build.gradle(Module: app) 파일 상단에 정의된 플러그인 섹션 뒤에 kapt 주석 프로세서 Kotlin 플러그인을 추가하여 적용합니다.
apply plugin: 'kotlin-kapt'
  1. android 블록 내에 packagingOptions 블록을 추가하여 원자 함수 모듈을 패키지에서 제외해 경고를 방지합니다.
  2. 사용할 일부 API에는 1.8 jvmTarget이 필요하므로 이를 android 블록에도 추가해야 합니다.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

}
  1. dependencies 블록을 다음으로 교체합니다.
dependencies {
    implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
    implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"

    // Dependencies for working with Architecture components
    // You'll probably have to update the version numbers in build.gradle (Project)

    // Room components
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
    androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

    // Lifecycle components
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-common-java8:$rootProject.lifecycleVersion"

    // Kotlin components
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

    // UI
    implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
    implementation "com.google.android.material:material:$rootProject.materialVersion"

    // Testing
    testImplementation "junit:junit:$rootProject.junitVersion"
    androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
    androidTestImplementation ("androidx.test.espresso:espresso-core:$rootProject.espressoVersion", {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    androidTestImplementation "androidx.test.ext:junit:$rootProject.androidxJunitVersion"
}

이 시점에서 Gradle은 누락되거나 정의되지 않은 버전으로 문제가 있을 수 있습니다. 다음 단계에서 해결해야 합니다.

  1. build.gradle(Project: RoomWordsSample) 파일에서 아래 코드와 같이 버전 번호를 파일 끝에 추가합니다.
ext {
    activityVersion = '1.1.0'
    appCompatVersion = '1.2.0'
    constraintLayoutVersion = '2.0.2'
    coreTestingVersion = '2.1.0'
    coroutines = '1.3.9'
    lifecycleVersion = '2.2.0'
    materialVersion = '1.2.1'
    roomVersion = '2.2.5'
    // testing
    junitVersion = '4.13.1'
    espressoVersion = '3.1.0'
    androidxJunitVersion = '1.1.2'
}

이 앱의 데이터는 단어이며 이러한 값을 보관할 간단한 테이블이 필요합니다.

3821ac1a6cb01278.png

Room을 사용하면 항목을 통해 테이블을 만들 수 있습니다. 이제 시작하겠습니다.

  1. Word 데이터 클래스가 포함된 Word라는 새 Kotlin 클래스 파일을 만듭니다. 이 클래스는 단어의 항목(SQLite 테이블을 나타냄)을 설명합니다. 클래스의 각 속성은 테이블의 열을 나타냅니다. Room에서는 궁극적으로 이러한 속성을 사용하여 테이블을 만들고 데이터베이스의 행에서 객체를 인스턴스화합니다.

코드는 다음과 같습니다.

data class Word(val word: String)

Word 클래스를 Room 데이터베이스에 의미 있게 만들려면 Kotlin 주석을 사용하여 클래스와 데이터베이스 간의 연결을 만들어야 합니다. 특정 주석을 사용하여 이 클래스의 각 부분이 데이터베이스의 항목과 어떻게 관련되는지 식별합니다. Room은 이와 같은 추가 정보를 사용하여 코드를 생성합니다.

주석을 붙여넣지 않고 직접 입력하면 Android 스튜디오에서는 주석 클래스를 자동으로 가져옵니다.

  1. 다음 코드와 같이 주석으로 Word 클래스를 업데이트합니다.
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

이러한 주석의 기능을 살펴보겠습니다.

  • @Entity(tableName = "word_table")@Entity 클래스는 SQLite 테이블을 나타냅니다. 클래스 선언에 주석을 달아 항목임을 나타냅니다. 클래스 이름과 다르게 하려면 테이블 이름을 지정하면 됩니다. 여기서는 테이블의 이름을 'word_table'로 지정합니다.
  • @PrimaryKey 모든 항목에는 기본 키가 필요합니다. 작업을 간단하게 하기 위해 각 단어는 자체 기본 키 역할을 합니다.
  • @ColumnInfo(name = "word") 멤버 변수 이름과 다르게 하려는 경우 테이블의 열 이름을 지정합니다. 여기서는 열 이름을 'word'로 지정합니다.
  • 데이터베이스에 저장된 모든 속성은 Kotlin 기본값인 공개 가시성이 있어야 합니다.

전체 주석 목록은 Room 패키지 요약 참조에서 확인할 수 있습니다.

DAO란 무엇인가요?

DAO(데이터 액세스 객체)에서 SQL 쿼리를 지정하여 메서드 호출과 연결합니다. 컴파일러는 SQL을 확인하고 @Insert와 같은 일반 쿼리의 편의 주석으로 쿼리를 생성합니다. Room은 DAO를 사용하여 코드를 위한 깔끔한 API를 만듭니다.

DAO는 인터페이스 또는 추상 클래스여야 합니다.

기본적으로 모든 쿼리는 별도의 스레드에서 실행되어야 합니다.

Room에서는 Kotlin 코루틴을 지원합니다. 따라서 쿼리를 suspend 수정자로 주석 처리하고 코루틴이나 다른 정지 함수에서 호출할 수 있습니다.

DAO 구현

다음과 같은 쿼리를 제공하는 DAO를 작성해 보겠습니다.

  • 모든 단어를 알파벳순으로 정렬
  • 단어 삽입
  • 모든 단어 삭제
  1. WordDao라는 새 Kotlin 클래스 파일 만들기
  2. 다음 코드를 복사하여 WordDao에 붙여넣고 필요에 따라 가져오기를 수정하여 컴파일되도록 하기
@Dao
interface WordDao {

    @Query("SELECT * FROM word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

이제 자세히 살펴보겠습니다.

  • WordDao는 인터페이스입니다. DAO는 인터페이스 또는 추상 클래스여야 합니다.
  • @Dao 주석은 Room의 DAO 클래스로 식별합니다.
  • suspend fun insert(word: Word): 한 단어를 삽입하는 정지 함수를 선언합니다.
  • @Insert 주석은 SQL을 제공하지 않아도 되는 특수 DAO 메서드 주석입니다. 행을 삭제하고 업데이트하는 @Delete@Update 주석도 있지만 이 앱에서는 사용하지 않습니다.
  • onConflict = OnConflictStrategy.IGNORE: 선택된 onConflict 전략은 이미 목록에 있는 단어와 정확하게 같다면 새 단어를 무시합니다. 사용 가능한 충돌 전략에 관한 자세한 내용은 문서를 참고하세요.
  • suspend fun deleteAll(): 모든 단어를 삭제하는 정지 함수를 선언합니다.
  • 여러 항목을 삭제하는 편의 주석은 없으므로 일반적인 @Query로 주석 처리됩니다.
  • @Query("DELETE FROM word_table"): @Query에서는 문자열 매개변수로 SQL 쿼리를 주석에 제공하여 복잡한 읽기 쿼리와 기타 작업을 허용해야 합니다.
  • fun getAlphabetizedWords(): List<Word>: 모든 단어를 가져와서 WordsList를 반환하도록 하는 메서드입니다.
  • @Query("SELECT * FROM word_table ORDER BY word ASC"): 오름차순으로 정렬된 단어 목록을 반환하는 쿼리입니다.

데이터가 변경되면 일반적으로 UI에 업데이트된 데이터를 표시하는 등 몇 가지 작업을 실행하는 것이 좋습니다. 즉, 데이터가 변경될 때 대응할 수 있도록 데이터를 관찰해야 합니다.

데이터 변경사항을 관찰하려면 kotlinx-coroutinesFlow를 사용합니다. 메서드 설명에서 Flow 유형의 반환 값을 사용하면 Room은 데이터베이스가 업데이트될 때 Flow를 업데이트하는 데 필요한 모든 코드를 생성합니다.

WordDao에서 반환된 List<Word>Flow로 래핑되도록 getAlphabetizedWords() 메서드 서명을 변경합니다.

   @Query("SELECT * FROM word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): Flow<List<Word>>

이 Codelab의 뒷부분에서 Flow를 ViewModel의 LiveData로 변환합니다. 그러나 이러한 구성요소를 구현한 후 더 자세히 알아봅니다.

Room 데이터베이스란 무엇인가요**?**

  • Room은 SQLite 데이터베이스 위에 있는 데이터베이스 레이어입니다.
  • Room은 개발자가 SQLiteOpenHelper를 사용하여 처리하던 일반적인 작업을 처리합니다.
  • Room은 DAO를 사용하여 데이터베이스에 쿼리를 실행합니다.
  • 기본적으로 UI 성능 저하를 방지하기 위해 Room에서는 기본 스레드에서 쿼리를 실행할 수 없습니다. Room 쿼리가 Flow를 반환하면 쿼리는 자동으로 백그라운드 스레드에서 비동기식으로 실행됩니다.
  • Room은 SQLite 문의 컴파일 시간 확인을 제공합니다.

Room 데이터베이스 구현

Room 데이터베이스 클래스는 추상 클래스이고 RoomDatabase를 확장해야 합니다. 일반적으로 전체 앱에 Room 데이터베이스 인스턴스가 하나만 있으면 됩니다.

이제 만들어 보겠습니다.

  1. WordRoomDatabase라는 Kotlin 클래스 파일을 만들고 이 코드를 파일에 추가합니다.
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time.
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                    ).build()
                INSTANCE = instance
                // return instance
                instance
            }
        }
   }
}

코드를 살펴보겠습니다.

  • Room의 데이터베이스 클래스는 abstract이고 RoomDatabase.를 확장해야 합니다.
  • 클래스를 Room 데이터베이스가 되도록 @Database로 주석 처리하고 주석 매개변수를 사용하여 데이터베이스에 속한 항목을 선언하고 버전 번호를 설정합니다. 각 항목은 데이터베이스에 만들어질 테이블에 상응합니다. 데이터베이스 이전은 이 Codelab의 범위를 벗어나므로 exportSchema는 빌드 경고를 피하기 위해 false로 설정했습니다. 실제 앱에서는 현재 스키마를 버전 제어 시스템으로 확인할 수 있도록 스키마를 내보내는 데 사용할 Room 디렉터리를 설정하는 것이 좋습니다.
  • 데이터베이스는 각 @Dao의 추상 'getter' 메서드를 통해 DAO를 노출합니다.
  • 데이터베이스의 여러 인스턴스가 동시에 열리는 것을 막기 위해 WordRoomDatabase,싱글톤으로 정의했습니다.
  • getDatabase는 싱글톤을 반환합니다. 처음 액세스할 때 데이터베이스를 만들어 Room의 데이터베이스 빌더를 사용하여 WordRoomDatabase 클래스의 애플리케이션 컨텍스트에서 RoomDatabase 객체를 만들고 이름을 "word_database"로 지정합니다.

저장소란 무엇인가요?

저장소 클래스는 여러 데이터 소스 액세스를 추상화합니다. 저장소는 아키텍처 구성요소 라이브러리의 일부는 아니지만 코드 분리와 아키텍처를 위한 권장사항입니다. 저장소 클래스는 나머지 애플리케이션의 데이터에 액세스를 위한 깔끔한 API를 제공합니다.

cdfae5b9b10da57f.png

저장소를 사용하는 이유는 무엇인가요?

저장소는 쿼리를 관리하고 여러 백엔드를 사용하도록 허용합니다. 가장 일반적으로 저장소는 데이터를 네트워크에서 가져올지 또는 로컬 데이터베이스에 캐시된 결과를 사용할지 결정하는 로직을 구현합니다.

저장소 구현

WordRepository라는 Kotlin 클래스 파일을 만들고 다음 코드를 파일에 붙여넣습니다.

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed Flow will notify the observer when the data has changed.
    val allWords: Flow<List<Word>> = wordDao.getAlphabetizedWords()

    // By default Room runs suspend queries off the main thread, therefore, we don't need to
    // implement anything else to ensure we're not doing long running database work
    // off the main thread.
    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

핵심은 다음과 같습니다.

  • DAO는 전체 데이터베이스가 아닌 저장소 생성자에 전달됩니다. DAO에 데이터베이스의 모든 읽기/쓰기 메서드가 포함되어 있으므로 DAO 액세스만 필요하기 때문입니다. 전체 데이터베이스를 저장소에 노출할 필요가 없습니다.
  • 단어 목록은 공개 속성입니다. 이 목록은 Room에서 단어의 Flow 목록을 가져와서 초기화됩니다. '데이터베이스 변경사항 관찰' 단계에서 Flow를 반환하도록 getAlphabetizedWords 메서드를 정의한 방식에 따라 이렇게 할 수 있습니다. Room은 별도의 스레드에서 모든 쿼리를 실행합니다.
  • suspend 수정자는 코루틴이나 다른 정지 함수에서 이를 호출해야 한다고 컴파일러에 알립니다.
  • Room은 기본 스레드 밖에서 정지 쿼리를 실행합니다.

ViewModel이란 무엇인가요?

ViewModel의 역할은 UI에 데이터를 제공하고 구성 변경에도 유지되는 것입니다. ViewModel은 저장소와 UI 간의 통신 센터 역할을 합니다. ViewModel을 사용하여 프래그먼트 간에 데이터를 공유할 수도 있습니다. ViewModel은 수명 주기 라이브러리의 일부입니다.

72848dfccfe5777b.png

이 주제에 관한 입문 가이드는 ViewModel Overview 또는 ViewModel: 간단한 예 블로그 게시물을 참고하세요.

ViewModel을 사용하는 이유는 무엇인가요?

ViewModel은 수명 주기를 고려하여 구성 변경에도 유지되는 앱의 UI 데이터를 보유합니다. 앱의 UI 데이터를 ActivityFragment 클래스에서 분리하면 단일 책임 원칙을 더 잘 준수할 수 있습니다. 활동과 프래그먼트는 화면에 데이터를 그리는 것을 담당하지만 ViewModel은 UI에 필요한 모든 데이터를 보유하고 처리할 수 있습니다.

LiveData 및 ViewModel

LiveData는 관찰 가능한 데이터 홀더로, 데이터가 변경될 때마다 알림을 받을 수 있습니다. Flow와 달리 LiveData는 수명 주기를 인식합니다. 즉, Activity나 Fragment와 같은 다른 구성요소의 수명 주기를 고려합니다. LiveData는 변경을 수신 대기하는 구성요소의 수명 주기에 따라 자동으로 관찰을 중지하거나 다시 시작합니다. 따라서 LiveData는 UI에서 사용하거나 표시하는 변경 가능한 데이터에 적절한 구성요소입니다.

ViewModel은 저장소의 데이터를 Flow에서 LiveData로 변환하고 단어 목록을 LiveData로 UI에 노출합니다. 이렇게 하면 데이터베이스의 데이터가 변경될 때마다 UI가 자동으로 업데이트됩니다.

viewModelScope

Kotlin에서 모든 코루틴은 CoroutineScope 내에서 실행됩니다. 범위는 전체 작업에 걸쳐 코루틴의 전체 기간을 제어합니다. 범위의 작업을 취소하면 그 범위에서 시작된 코루틴이 모두 취소됩니다.

AndroidX lifecycle-viewmodel-ktx 라이브러리는 viewModelScopeViewModel 클래스의 확장 함수로 추가하므로 범위를 사용하여 작업할 수 있습니다.

ViewModel에서 코루틴을 사용하는 방법에 관한 자세한 내용은 Android 앱에서 Kotlin 코루틴 사용 Codelab의 5단계나 Android의 쉬운 코루틴: viewModelScope 블로그 게시물을 참고하세요.

ViewModel 구현

WordViewModel의 Kotlin 클래스 파일을 만들고 다음 코드를 파일에 추가합니다.

class WordViewModel(private val repository: WordRepository) : ViewModel() {

    // Using LiveData and caching what allWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>> = repository.allWords.asLiveData()

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch {
        repository.insert(word)
    }
}

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

이 코드를 자세히 살펴보겠습니다. 여기서 실행한 내용은 다음과 같습니다.

  • WordRepository를 매개변수로 가져오고 ViewModel을 확장하는 WordViewModel이라는 클래스를 만들었습니다. 저장소는 ViewModel에 필요한 유일한 종속 항목입니다. 다른 클래스가 필요했다면 생성자에도 전달되었을 것입니다.
  • 단어 목록을 캐시하는 공개 LiveData 멤버 변수를 추가했습니다.
  • 저장소의 allWords 흐름을 사용하여 LiveData를 초기화했습니다. 그런 다음 asLiveData().를 호출하여 Flow를 LiveData로 변환했습니다.
  • 저장소의 insert() 메서드를 호출하는 래퍼 insert() 메서드를 만들었습니다. 이렇게 하면 insert() 구현이 UI에서 캡슐화됩니다. 새 코루틴을 실행하고 정지 함수인 저장소의 insert를 호출합니다. 앞서 언급한 바와 같이 ViewModel에는 viewModelScope이라는 수명 주기 기반의 코루틴 범위가 있으며 여기서 사용합니다.
  • ViewModel을 만들고 WordViewModel을 만드는 데 필요한 종속 항목(WordRepository)을 매개변수로 가져오는 ViewModelProvider.Factory를 구현했습니다.

viewModelsViewModelProvider.Factory를 사용하여 프레임워크에서 ViewModel의 수명 주기를 처리합니다. 구성 변경에도 유지되고 Activity가 다시 생성되더라도 항상 WordViewModel 클래스의 올바른 인스턴스를 가져오게 됩니다.

이제 목록과 항목의 XML 레이아웃을 추가해야 합니다.

이 Codelab에서는 개발자가 XML로 레이아웃을 만드는 데 익숙하다고 가정하므로 코드만 제공합니다.

AppTheme 상위 요소를 Theme.MaterialComponents.Light.DarkActionBar로 설정하여 애플리케이션 테마 자료를 만듭니다. values/styles.xml에서 목록 항목의 스타일을 추가합니다.

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <!-- The default font for RecyclerView items is too small.
    The margin is a simple delimiter between the words. -->
    <style name="word_title">
        <item name="android:layout_marginBottom">8dp</item>
        <item name="android:paddingLeft">8dp</item>
        <item name="android:background">@android:color/holo_orange_light</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Large</item>
    </style>
</resources>

새 측정기준 리소스 파일을 만듭니다.

  1. Project 창에서 앱 모듈을 클릭합니다.
  2. File > New > Android Resource File을 선택합니다.
  3. Available Qualifiers에서 Dimension을 선택합니다.
  4. 파일 이름을 dimens로 지정합니다.

aa5895240838057.png

이 측정기준 리소스를 values/dimens.xml에 추가합니다.

<dimen name="big_padding">16dp</dimen>

layout/recyclerview_item.xml 레이아웃을 추가합니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        style="@style/word_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_orange_light" />
</LinearLayout>

layout/activity_main.xml에서 TextViewRecyclerView로 바꾸고 플로팅 작업 버튼(FAB)을 추가합니다. 이제 다음과 같이 레이아웃이 표시됩니다.

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        tools:listitem="@layout/recyclerview_item"
        android:padding="@dimen/big_padding"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"/>

</androidx.constraintlayout.widget.ConstraintLayout>

FAB 모양은 사용 가능한 작업에 대응해야 하므로 아이콘을 '+' 기호로 대체하는 것이 좋습니다.

먼저 새 벡터 애셋을 추가해야 합니다.

  1. File > New > Vector Asset을 선택합니다.
  2. Clip Art: 입력란에서 Android 로봇 아이콘을 클릭합니다. 8d935457de8e7a46.png
  3. 'add'를 검색하여 '+' 애셋을 선택합니다. OK를 클릭합니다. 758befc99c8cc794.png
  4. Asset Studio 창에서 Next를 클릭합니다. 672248bada3cfb25.png
  5. 아이콘 경로를 main > drawable로 확인하고 Finish를 클릭하여 애셋을 추가합니다. ef118084f96c6176.png
  6. layout/activity_main.xml에서 새 드로어블을 포함하도록 FAB를 업데이트합니다.
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"
        android:src="@drawable/ic_add_black_24dp"/>

RecyclerView에 데이터가 표시되며 TextView에 데이터가 나열되는 것보다 조금 더 낫습니다. 이 Codelab에서는 개발자가 RecyclerView, RecyclerView.ViewHolder, ListAdapter의 작동 원리를 알고 있다고 가정합니다.

다음을 만들어야 합니다.

  • ListAdapter를 확장하는 WordListAdapter 클래스
  • WordListAdapter.의 중첩된 DiffUtil.ItemCallback 클래스 부분
  • 각 단어가 목록에 표시되는 ViewHolder

코드는 다음과 같습니다.

class WordListAdapter : ListAdapter<Word, WordViewHolder>(WordsComparator()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        return WordViewHolder.create(parent)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = getItem(position)
        holder.bind(current.word)
    }

    class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val wordItemView: TextView = itemView.findViewById(R.id.textView)

        fun bind(text: String?) {
            wordItemView.text = text
        }

        companion object {
            fun create(parent: ViewGroup): WordViewHolder {
                val view: View = LayoutInflater.from(parent.context)
                    .inflate(R.layout.recyclerview_item, parent, false)
                return WordViewHolder(view)
            }
        }
    }

    class WordsComparator : DiffUtil.ItemCallback<Word>() {
        override fun areItemsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem === newItem
        }

        override fun areContentsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem.word == newItem.word
        }
    }
}

구현한 클래스 내용은 다음과 같습니다.

  • WordViewHolder 클래스를 사용하면 텍스트를 TextView에 바인딩할 수 있습니다. 이 클래스는 레이아웃 확장을 처리하는 정적 create() 함수를 노출합니다.
  • WordsComparator는 두 단어가 동일한 경우 또는 콘텐츠가 동일한 경우 계산하는 방법을 정의합니다.
  • WordListAdapteronCreateViewHolder에서 WordViewHolder를 만들어 onBindViewHolder에서 바인딩합니다.

MainActivityonCreate() 메서드에 RecyclerView를 추가합니다.

setContentView 이후 onCreate() 메서드에서 다음을 실행합니다.

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter()
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

앱을 실행하여 모든 것이 제대로 작동하는지 확인합니다. 아직 데이터를 연결하지 않아서 항목은 없습니다.

79cb875d4296afce.png

앱에 데이터베이스 인스턴스와 저장소 인스턴스를 하나씩만 사용하려고 합니다. 이 작업을 실행하는 쉬운 방법은 인스턴스를 Application 클래스의 멤버로 생성하는 것입니다. 그러면 매번 구성하지 않고 필요할 때마다 Application에서 가져올 수 있습니다.

Application을 확장하는 WordsApplication이라는 새 클래스를 만듭니다. 코드는 다음과 같습니다.

class WordsApplication : Application() {
    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

이 코드에서 작성한 내용은 다음과 같습니다.

  • 데이터베이스 인스턴스를 만들었습니다.
  • 데이터베이스 DAO에 기반하여 저장소 인스턴스를 만들었습니다.
  • 이러한 객체는 앱을 시작할 때가 아니라 처음 필요할 때만 만들어야 하므로 Kotlin의 속성 위임 by lazy.를 사용합니다.

이제 Application 클래스를 만들었으므로 AndroidManifest 파일을 업데이트하고 WordsApplicationapplication android:name으로 설정합니다.

애플리케이션 태그가 표시되는 방식은 다음과 같습니다.

<application
        android:name=".WordsApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
...

현재 데이터베이스에 데이터가 없습니다. 두 가지 방법으로 데이터를 추가합니다. 데이터베이스를 만들 때 데이터를 추가하고 단어를 추가하는 Activity를 추가합니다.

앱을 만들 때마다 모든 콘텐츠를 삭제하고 데이터베이스를 다시 채우려면 RoomDatabase.Callback을 만들고 onCreate()를 재정의합니다. Room 데이터베이스 작업을 UI 스레드에서 할 수 없으므로 onCreate()는 IO Dispatcher에서 코루틴을 실행합니다.

코루틴을 실행하려면 CoroutineScope이 필요합니다. 코루틴 범위도 매개변수로 가져오려면 WordRoomDatabase 클래스의 getDatabase 메서드를 업데이트합니다.

fun getDatabase(
       context: Context,
       scope: CoroutineScope
  ): WordRoomDatabase {
...
}

데이터베이스를 채우는 작업은 UI 수명 주기와 관련이 없으므로 viewModelScope과 같은 CoroutineScope을 사용해서는 안 됩니다. 이는 앱의 수명 주기와 관련이 있습니다. applicationScope을 포함하도록 WordsApplication을 업데이트한 다음 WordRoomDatabase.getDatabase로 전달합니다.

class WordsApplication : Application() {
    // No need to cancel this scope as it'll be torn down with the process
    val applicationScope = CoroutineScope(SupervisorJob())

    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this, applicationScope) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

WordRoomDatabase에서는 RoomDatabase.Callback()의 맞춤 구현을 만듭니다. 이 구현은 CoroutineScope을 생성자 매개변수로도 가져옵니다. 그런 다음, onOpen 메서드를 재정의하여 데이터베이스를 채웁니다.

다음은 WordRoomDatabase 클래스 내에서 콜백을 만드는 코드입니다.

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onCreate(db: SupportSQLiteDatabase) {
        super.onCreate(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

마지막으로 Room.databaseBuilder()에서 .build()를 호출하기 직전에 데이터베이스 빌드 시퀀스에 콜백을 추가합니다.

.addCallback(WordDatabaseCallback(scope))

최종 코드는 다음과 같습니다.

@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   private class WordDatabaseCallback(
       private val scope: CoroutineScope
   ) : RoomDatabase.Callback() {

       override fun onCreate(db: SupportSQLiteDatabase) {
           super.onCreate(db)
           INSTANCE?.let { database ->
               scope.launch {
                   var wordDao = database.wordDao()

                   // Delete all content here.
                   wordDao.deleteAll()

                   // Add sample words.
                   var word = Word("Hello")
                   wordDao.insert(word)
                   word = Word("World!")
                   wordDao.insert(word)

                   // TODO: Add your own words!
                   word = Word("TODO!")
                   wordDao.insert(word)
               }
           }
       }
   }

   companion object {
       @Volatile
       private var INSTANCE: WordRoomDatabase? = null

       fun getDatabase(
           context: Context,
           scope: CoroutineScope
       ): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                )
                 .addCallback(WordDatabaseCallback(scope))
                 .build()
                INSTANCE = instance
                // return instance
                instance
        }
     }
   }
}

values/strings.xml에 다음 문자열 리소스를 추가합니다.

<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>
<string name="add_word">Add word</string>

value/colors.xml에 다음 색상 리소스를 추가합니다.

<color name="buttonLabel">#FFFFFF</color>

values/dimens.xmlmin_height 측정기준 리소스를 추가합니다.

<dimen name="min_height">48dp</dimen>

Empty Activity 템플릿을 사용하여 새로운 빈 Android Activity를 만듭니다.

  1. File > New > Activity > Empty Activity를 선택합니다.
  2. Activity 이름으로 NewWordActivity를 입력합니다.
  3. 새 활동이 Android 매니페스트에 추가되었는지 확인합니다.
<activity android:name=".NewWordActivity"></activity>

다음 코드를 사용하여 레이아웃 폴더의 activity_new_word.xml 파일을 업데이트합니다.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/edit_word"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="@dimen/min_height"
        android:fontFamily="sans-serif-light"
        android:hint="@string/hint_word"
        android:inputType="textAutoComplete"
        android:layout_margin="@dimen/big_padding"
        android:textSize="18sp" />

    <Button
        android:id="@+id/button_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        android:text="@string/button_save"
        android:layout_margin="@dimen/big_padding"
        android:textColor="@color/buttonLabel" />

</LinearLayout>

활동 코드를 업데이트합니다.

class NewWordActivity : AppCompatActivity() {

    private lateinit var editWordView: EditText

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_word)
        editWordView = findViewById(R.id.edit_word)

        val button = findViewById<Button>(R.id.button_save)
        button.setOnClickListener {
            val replyIntent = Intent()
            if (TextUtils.isEmpty(editWordView.text)) {
                setResult(Activity.RESULT_CANCELED, replyIntent)
            } else {
                val word = editWordView.text.toString()
                replyIntent.putExtra(EXTRA_REPLY, word)
                setResult(Activity.RESULT_OK, replyIntent)
            }
            finish()
        }
    }

    companion object {
        const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
    }
}

마지막 단계는 사용자가 입력하는 새 단어를 저장하고 RecyclerView에 단어 데이터베이스의 현재 콘텐츠를 표시하여 UI를 데이터베이스에 연결하는 것입니다.

데이터베이스의 현재 콘텐츠를 표시하려면 ViewModel에서 LiveData를 관찰하는 관찰자를 추가하세요.

데이터가 변경될 때마다 onChanged() 콜백이 호출되므로 어댑터의 setWords() 메서드를 호출하여 어댑터의 캐시된 데이터를 업데이트하고 표시된 목록을 새로 고칩니다.

MainActivity에서 ViewModel을 만듭니다.

private val wordViewModel: WordViewModel by viewModels {
    WordViewModelFactory((application as WordsApplication).repository)
}

ViewModel을 만들기 위해 viewModels 위임을 사용하여 WordViewModelFactory의 인스턴스를 전달했습니다. 이는 WordsApplication에서 가져온 저장소에 기반하여 생성됩니다.

또한 onCreate()에서 WordViewModel의 allWords LiveData 속성 관찰자를 추가합니다.

onChanged() 메서드(람다의 기본 메서드)는 관찰된 데이터가 변경되고 활동이 포그라운드에 있을 때 실행됩니다.

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.submitList(it) }
})

FAB를 탭하면 NewWordActivity가 열리고 MainActivity로 돌아오면 데이터베이스에 새 단어를 삽입하거나 Toast를 표시하려고 합니다.

이를 위해 먼저 다음과 같이 요청 코드를 정의합니다.

private val newWordActivityRequestCode = 1

MainActivity에서 NewWordActivityonActivityResult() 코드를 추가합니다.

활동이 RESULT_OK로 반환되면 WordViewModelinsert() 메서드를 호출하여 반환된 단어를 데이터베이스에 삽입합니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

MainActivity,에서 사용자가 FAB를 탭하면 NewWordActivity를 시작합니다. MainActivity onCreate에서 FAB를 찾고 다음 코드로 onClickListener를 추가합니다.

val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
  val intent = Intent(this@MainActivity, NewWordActivity::class.java)
  startActivityForResult(intent, newWordActivityRequestCode)
}

완성된 코드는 다음과 같습니다.

class MainActivity : AppCompatActivity() {

    private val newWordActivityRequestCode = 1
    private val wordViewModel: WordViewModel by viewModels {
        WordViewModelFactory((application as WordsApplication).repository)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
        val adapter = WordListAdapter()
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        // Add an observer on the LiveData returned by getAlphabetizedWords.
        // The onChanged() method fires when the observed data changes and the activity is
        // in the foreground.
        wordViewModel.allWords.observe(owner = this) { words ->
            // Update the cached copy of the words in the adapter.
            words.let { adapter.submitList(it) }
        }

        val fab = findViewById<FloatingActionButton>(R.id.fab)
        fab.setOnClickListener {
            val intent = Intent(this@MainActivity, NewWordActivity::class.java)
            startActivityForResult(intent, newWordActivityRequestCode)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) {
        super.onActivityResult(requestCode, resultCode, intentData)

        if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
            intentData?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let { reply ->
                val word = Word(reply)
                wordViewModel.insert(word)
            }
        } else {
            Toast.makeText(
                applicationContext,
                R.string.empty_not_saved,
                Toast.LENGTH_LONG
            ).show()
        }
    }
}

이제 앱을 실행합니다. NewWordActivity에서 데이터베이스에 단어를 추가하면 UI가 자동으로 업데이트됩니다.

이제 작동하는 앱을 만들었으므로 빌드한 내용을 요약해 보겠습니다. 다음은 앱 구조입니다.

a70aca8d4b737712.png

앱의 구성요소는 다음과 같습니다.

  • MainActivity: RecyclerViewWordListAdapter를 사용하여 목록에 단어를 표시합니다. MainActivity에는 데이터베이스의 단어를 관찰하고 변경될 때 알림을 받는 Observer가 있습니다.
  • NewWordActivity: 새 단어를 목록에 추가합니다.
  • WordViewModel: 데이터 영역에 액세스하는 메서드를 제공하고 MainActivity에서 관찰자 관계를 설정할 수 있도록 LiveData를 반환합니다.*
  • LiveData<List<Word>>: UI 구성요소에서 자동 업데이트를 가능하게 합니다. flow.toLiveData()를 호출하여 Flow에서 LiveData로 변환할 수 있습니다.
  • Repository: 하나 이상의 데이터 소스를 관리합니다. Repository는 ViewModel이 기본 데이터 제공자와 상호작용하는 메서드를 노출합니다. 이 앱에서는 백엔드가 Room 데이터베이스입니다.
  • Room: SQLite 데이터베이스의 래퍼이고 이를 구현합니다. Room은 개발자가 직접 해야 했던 많은 작업을 처리합니다.
  • DAO: 메서드 호출을 데이터베이스 쿼리에 매핑하므로 저장소가 getAlphabetizedWords()와 같은 메서드를 호출할 때 Room에서 SELECT * FROM word_table ORDER BY word ASC를 실행할 수 있습니다**.**
  • DAO는 데이터베이스의 변경사항에 관한 알림을 받으려고 할 때 일회성 요청의 suspend 쿼리와 Flow 쿼리를 노출할 수 있습니다.
  • Word: 단일 단어가 포함되는 항목 클래스입니다.
  • Views, Activities, FragmentsViewModel을 통해서만 데이터와 상호작용합니다. 따라서 데이터의 출처는 중요하지 않습니다.

자동 UI 업데이트를 위한 데이터 흐름(반응형 UI)

LiveData를 사용하고 있으므로 자동 업데이트가 가능합니다. MainActivity에는 데이터베이스에서 단어 LiveData를 관찰하고 변경될 때 알림을 받는 Observer가 있습니다. 변경사항이 있으면 관찰자의 onChange() 메서드가 실행되고 WordListAdapter에서 mWords가 업데이트됩니다.

데이터가 LiveData이므로 관찰할 수 있습니다. 관찰되는 것은 WordViewModel allWords 속성에서 반환하는 LiveData<List<Word>>입니다.

WordViewModel은 UI 레이어에서 백엔드에 관한 모든 것을 숨깁니다. 데이터 영역에 액세스하는 메서드를 제공하고 MainActivity에서 관찰자 관계를 설정할 수 있도록 LiveData를 반환합니다. Views, Activities, FragmentsViewModel을 통해서만 데이터와 상호작용합니다. 따라서 데이터의 출처는 중요하지 않습니다.

이 경우 데이터의 출처는 Repository입니다. ViewModel은 이 저장소가 상호작용하는 대상을 알 필요가 없습니다. Repository와 상호작용하는 방법만 알면 되며 그 방법은 Repository에서 노출된 메서드를 통해서입니다.

저장소는 하나 이상의 데이터 소스를 관리합니다. WordListSample 앱에서는 백엔드가 Room 데이터베이스입니다. Room은 SQLite 데이터베이스의 래퍼이고 이를 구현합니다. Room은 개발자가 직접 해야 했던 많은 작업을 처리합니다. 예를 들어 Room은 개발자가 SQLiteOpenHelper 클래스를 사용하여 했던 모든 작업을 처리합니다.

DAO는 메서드 호출을 데이터베이스 쿼리에 매핑하므로 저장소가 getAllWords()와 같은 메서드를 호출할 때 Room에서 SELECT * FROM word_table ORDER BY word ASC를 실행할 수 있습니다.

쿼리에서 반환된 결과가 관찰된 LiveData이므로 Room의 데이터가 변경될 때마다 Observer 인터페이스의 onChanged() 메서드가 실행되고 UI가 업데이트됩니다.

[선택사항] 솔루션 코드 다운로드

아직 확인하지 않았다면 Codelab의 솔루션 코드를 확인하세요. GitHub 저장소를 참고하거나 여기에서 코드를 다운로드할 수 있습니다.

소스 코드 다운로드

다운로드한 ZIP 파일의 압축을 해제합니다. 이렇게 하면 전체 앱이 포함된 루트 폴더 android-room-with-a-view-kotlin의 압축이 해제됩니다.