뷰를 사용한 Android Room - Kotlin

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

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

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

기본 요건

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

또한 MVP나 MVC와 같이 사용자 인터페이스에서 데이터를 분리하는 소프트웨어 아키텍처 패턴을 아는 것도 도움이 됩니다. 이 Codelab에서는 앱 아키텍처 가이드에 정의된 아키텍처를 구현합니다.

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

Kotlin에 익숙하지 않다면 여기에서 자바 프로그래밍 언어로 제공되는 Codelab 버전을 사용할 수 있습니다.

실행할 작업

이 Codelab에서는 아키텍처 구성요소 Room, ViewModel 및 LiveData를 사용하여 앱을 디자인 및 구성하고 다음과 같은 작업을 하는 앱을 빌드하는 방법을 알아봅니다.

  • Android 아키텍처 구성요소를 사용하여 권장 아키텍처를 구현합니다.
  • 데이터베이스를 사용하여 데이터를 가져와 저장하고 단어 몇 개로 데이터베이스를 미리 채웁니다.
  • MainActivityRecyclerView에 모든 단어를 표시합니다.
  • 사용자가 + 버튼을 탭하면 두 번째 활동이 열립니다. 사용자가 단어를 입력하면 데이터베이스와 목록에 단어가 추가됩니다.

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

필요한 항목

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

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

아키텍처 구성요소를 사용하고 권장 아키텍처를 구현하려면 여러 단계를 거쳐야 합니다. 가장 중요한 점은 진행 상황에 관한 정신 모델을 만들어 내용을 정확하게 파악하고 데이터의 흐름 방식을 이해하는 것입니다. 이 Codelab을 진행하면서 코드를 복사하여 붙여넣지만 말고 정확한 내용을 이해하려고 노력하세요.

용어에 관해 소개하기 위해 다음은 아키텍처 구성요소와 그 작동 방식을 간략히 설명합니다. 이 Codelab에서는 구성요소의 하위 집합, 즉 LiveData, ViewModel, Room에 중점을 둡니다. 각 구성요소는 사용 방법에 따라 자세히 설명되어 있습니다.

다음 다이어그램은 아키텍처의 기본 형태를 보여 줍니다.

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

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

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

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

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

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

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

RoomWordSample 아키텍처 개요

다음 다이어그램은 앱의 모든 부분을 보여 줍니다. 각 인클로징 상자(SQLite 데이터베이스 제외)는 만들 클래스를 나타냅니다.

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

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

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

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

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

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. dependencies 블록 끝에 다음 코드를 추가합니다.
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"

// 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"

// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
  1. build.gradle(Project: RoomWordsSample) 파일에서 아래 코드와 같이 버전 번호를 파일 끝에 추가합니다.
ext {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

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

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

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

코드는 다음과 같습니다.

data class Word(val word: String)

Word 클래스를 Room 데이터베이스에 의미 있게 만들려면 클래스에 주석을 달아야 합니다. 주석은 이 클래스의 각 부분이 데이터베이스의 항목과 어떻게 관련되는지 식별합니다. 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에는 코루틴이 지원되므로 쿼리를 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에 업데이트된 데이터를 표시하는 등 몇 가지 작업을 실행하는 것이 좋습니다. 즉, 데이터가 변경될 때 대응할 수 있도록 데이터를 관찰해야 합니다.

이는 데이터가 저장되는 방식에 따라 까다로울 수 있습니다. 앱의 여러 구성요소에서 데이터 변경사항을 관찰하면 구성요소 간에 명확하고 엄격한 종속성 경로를 만들 수 있습니다. 특히 테스트와 디버깅이 어려워집니다.

데이터 관찰을 위한 수명 주기 라이브러리 클래스인 LiveData가 이 문제를 해결합니다. 메서드 설명에 LiveData 유형의 반환 값을 사용하면 데이터베이스가 업데이트될 때 Room에서 LiveData를 업데이트하는 데 필요한 모든 코드를 생성합니다.

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

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

이 Codelab의 뒷부분에서 MainActivityObserver를 통해 데이터 변경사항을 추적합니다.

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

  • Room은 SQLite 데이터베이스 위에 있는 데이터베이스 레이어입니다.
  • Room은 개발자가 SQLiteOpenHelper를 사용하여 처리하던 일반적인 작업을 처리합니다.
  • Room은 DAO를 사용하여 데이터베이스에 쿼리를 실행합니다.
  • 기본적으로 UI 성능 저하를 방지하기 위해 Room에서는 기본 스레드에서 쿼리를 실행할 수 없습니다. Room 쿼리가 LiveData를 반환하면 쿼리는 자동으로 백그라운드 스레드에서 비동기식으로 실행됩니다.
  • 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 {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java, 
                        "word_database"
                    ).build()
                INSTANCE = instance
                return instance
            }
        }
   }
}

코드를 살펴보겠습니다.

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

저장소란 무엇인가요?

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

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

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

저장소 구현

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 LiveData will notify the observer when the data has changed.
    val allWords: LiveData<List<Word>> = wordDao.getAlphabetizedWords()
 
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

핵심은 다음과 같습니다.

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

ViewModel이란 무엇인가요?

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

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

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

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

ViewModel에서 UI가 사용하거나 표시하는 변경 가능한 데이터에 LiveData를 사용합니다. LiveData를 사용하면 여러 가지 이점이 있습니다.

  • 변경사항을 폴링하는 대신 데이터에 관찰자를 배치하고 데이터가 실제로 변경될 때만
    UI를 업데이트할 수 있습니다.
  • 저장소와 UI는 ViewModel으로 완전히 구분됩니다.
  • ViewModel에서 데이터베이스 호출이 생성되지 않으므로 (저장소에서 모두 처리됨) 코드를 더 쉽게 테스트할 수 있습니다.

viewModelScope

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

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

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

ViewModel 구현

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

class WordViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: WordRepository
    // Using LiveData and caching what getAlphabetizedWords 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>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

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

다음은 지금까지 학습한 내용입니다.

  • Application를 매개변수로 가져오고 AndroidViewModel을 확장하는 WordViewModel이라는 클래스를 만들었습니다.
  • 저장소 참조를 보유하는 비공개 구성원 변수를 추가했습니다.
  • 단어 목록을 캐시하는 공개 LiveData 멤버 변수를 추가했습니다.
  • WordRoomDatabase에서 WordDao의 참조를 가져오는 init 블록을 만들었습니다.
  • init 블록에서 WordRoomDatabase를 기반으로 WordRepository를 구성했습니다.
  • init 블록에서 저장소를 사용하여 allWords LiveData를 초기화했습니다.
  • 저장소의 insert() 메서드를 호출하는 래퍼 insert() 메서드를 만들었습니다. 이렇게 하면 insert() 구현이 UI에서 캡슐화됩니다. 삽입으로 기본 스레드를 차단하지 않을 것입니다. 따라서 새 코루틴을 실행하고 정지 함수인 저장소 삽입을 호출합니다. 앞서 언급했듯이 ViewModel에는 viewModelScope라는 수명 주기에 기반한 코루틴 범위가 있으며 여기에서 사용합니다.

이제 목록과 항목의 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_width">match_parent</item>
        <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>

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 모양은 사용 가능한 작업에 상응해야 하므로 아이콘을 '+' 기호로 대체하는 것이 좋습니다.

먼저 새 Vector Asset을 추가해야 합니다.

  1. File > New > Vector Asset을 선택합니다.
  2. Clip Art: 입력란에서 Android 로봇 아이콘을 클릭합니다.
  3. '추가'를 검색하여 '+&' 애셋을 선택합니다. 확인
    을 클릭합니다.
  4. 그런 다음 Next를 클릭합니다.
  5. 아이콘 경로를 main > drawable로 확인하고 Finish를 클릭하여 애셋을 추가합니다.
  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.LayoutManager, RecyclerView.ViewHolder, RecyclerView.Adapter의 작동 방식을 알고 있다고 가정합니다.

어댑터의 words 변수는 데이터를 캐시합니다. 다음 작업에서는 데이터를 자동으로 업데이트하는 코드를 추가합니다.

RecyclerView.Adapter을 확장하는 WordListAdapter의 Kotlin 클래스 파일을 만듭니다. 코드는 다음과 같습니다.

class WordListAdapter internal constructor(
        context: Context
) : RecyclerView.Adapter<WordListAdapter.WordViewHolder>() {

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    private var words = emptyList<Word>() // Cached copy of words

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

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        val itemView = inflater.inflate(R.layout.recyclerview_item, parent, false)
        return WordViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = words[position]
        holder.wordItemView.text = current.word
    }

    internal fun setWords(words: List<Word>) {
        this.words = words
        notifyDataSetChanged()
    }

    override fun getItemCount() = words.size
}

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

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

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

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

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

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

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

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

범위도 전달하도록 WordViewModelinit 블록에 있는 데이터베이스 검색 이니셜라이저를 업데이트합니다.

val wordsDao = WordRoomDatabase.getDatabase(application, viewModelScope).wordDao()

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

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

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

    override fun onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(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 onOpen(db: SupportSQLiteDatabase) {
           super.onOpen(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>

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

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

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

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

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

<dimen name="small_padding">8dp</dimen>
<dimen name="big_padding">16dp</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 lateinit var wordViewModel: WordViewModel

ViewModelProvider를 사용하여 ViewModelActivity와 연결합니다.

Activity가 처음 시작되면 ViewModelProvidersViewModel를 생성합니다. 활동이 소멸되는 경우(예: 구성 변경을 통해) ViewModel는 유지됩니다. 활동이 다시 생성되면 ViewModelProviders는 기존 ViewModel를 반환합니다. 자세한 내용은 ViewModel를 참고하세요.

onCreate()RecyclerView 코드 블록 아래에 있는 ViewModelProvider에서 ViewModel를 가져옵니다.

wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)

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

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

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.setWords(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 const val newWordActivityRequestCode = 1
   private lateinit var wordViewModel: WordViewModel

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

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

       wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)
       wordViewModel.allWords.observe(this, Observer { words ->
           // Update the cached copy of the words in the adapter.
           words?.let { adapter.setWords(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, 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()
       }
   }
}

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

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

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

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

* ViewsActivities(및 Fragments)는 ViewModel을 통해서만 데이터와 상호작용합니다. 따라서 데이터의 출처는 중요하지 않습니다.

자동 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의 압축이 해제됩니다.