[안드로이드 ROOM] MVVM패턴으로 배우는 ROOM DB(ViewModel, Repository, Koin, Coroutine)

2021. 5. 11. 17:51개발/[Kotlin] 안드로이드 개발

반응형

안녕하세요. 양드로이드입니다.

MVVM패턴을 적용한 Room DB 사용법에 대해 알아보겠습니다.

이 예제는 구글 코드랩에 기재된 예제입니다.

developer.android.com/codelabs/android-room-with-a-view-kotlin?hl=ko#0

 

Android Room with a View - Kotlin  |  Android 개발자  |  Android Developers

In this codelab you’ll build an Android app in Kotlin that uses Android Architecture Components (RoomDatabase, Entity, DAO, AndroidViewModel, LiveData) together with Kotlin coroutines. This sample app stores a list of words in a Room database and display

developer.android.com

우선 결과화면 먼저 보도록 하겠습니다.

+ 버튼을 통해 단어를 저장하면 메인화면에서 단어가 저장되는 모습입니다.

Architecture Component

MODEL VIEW VIEWMODEL : MVVM 패턴 적용

위 구조를 적용하기 위해서는 LiveData와 ViewModel, Coroutine, Koin이 사용 됩니다.

1. Dependency

Build.Gradle(Project 수준)
    ext {
        kotlin_version = "1.4.32"
        room_version = "2.2.6"
        koin_version = '2.2.2'
        nav_version = "2.3.2"
        lifecycle_version = "2.2.0"
        retrofit_version = "2.9.0"
        coroutines_version = "1.3.9"
        activity_version = '1.1.0'
    }
Build.Gradle (APP 수준)
    // Coroutine
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"

    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"

    // LiveData
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"

    // Retrofit
    implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
    implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"

    // Koin for Android - Scope feature
    implementation "org.koin:koin-android:$koin_version"
    implementation "org.koin:koin-android-viewmodel:$koin_version"

    // Room
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    androidTestImplementation "androidx.room:room-testing:$room_version"
    // optional - Kotlin Extensions and Coroutines support for Room
    implementation "androidx.room:room-ktx:$room_version"

    implementation "androidx.activity:activity-ktx:$activity_version"

2. Entity 구현

Entity - Database 안에 있는 테이블을 Java나 Kotlin 클래스로 나타낸 것이다. 데이터 모델 클래스라고 볼 수 있다.

@Entity(tableName = "word_table")
data class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

@Entity : SQLite table을 나타낸다. Entity라고 선언함에 동시에 테이블 이름을 지정한다.

@PrimaryKey : 모든 Entitiy에는 primary key 기본키가 존재한다. 기본키를 선언한다.

@ColumnInfo(name = "word")  : 만약 멤버변수와 이름을 다르게 하고 싶으면 이 어노테이션으로 지정한다. 

3. DAO 구현

DAO - Database Access Object, 데이터베이스에 접근해서 실질적으로 insert, delete 등을 수행하는 메소드를 포함한다.

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

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

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

@Dao : Room을 위한 DAO클래스를 나타낸다.

@Query : 이 메서드가 수행할 SQL 문을 정의한다.

@Insert : Insert 어노테이션은 별도의 SQL문을 작성할 필요가 없다. 왜냐면 Word 클래스에 맞게 데이터가 들어간다. 

onConflict = OnConflictStrategy.IGNORE : 중복되는 값은 무시한다.

suspend : 코루틴을 사용할 수 있도록 suspend를 붙여 백그라운드에서 실행되도록 한다.

Flow : 비동기로 동작하면서 여러개의 값을 반환하는 Function을 만들 때 사용하는 Builder

4. Room Database 구현

Database - database holder를 포함하며, 앱에 영구 저장되는 데이터와 기본 연결을 위한 주 액세스 지점이다. RoomDatabase를 extend 하는 추상 클래스여야 하며, 테이블과 버전을 정의하는 곳이다.

 

@Database(entities = [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 {
                    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)
        }
    }

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

        fun getDatabase(context: Context, scope: CoroutineScope): WordRoomDatabase {
        // 만약에 INSTANCE가 null이 아니면 리턴하고 널이면 데이터베이스를 만들어라.
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    WordRoomDatabase::class.java,
                    "word_database"
                )
                    // Wipes and rebuilds instead of migrating if no Migration object.
                    // Migration is not part of this codelab.
                    .fallbackToDestructiveMigration()
                    .addCallback(WordDatabaseCallback(scope))
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

※ 이렇게 singleton패턴으로 만들면 접근도 쉽고 메모리에서도 효율적이다.

5. Repository 생성

Repository 패턴에 대해서 잘 모르시는 분들을 위해 설명을 드리자면 MVVM의 Model에 속합니다.

Repository에서 네트워크나 Dao통신을 데이터를 가져옵니다.

간단하게 말하면 데이터를 요청하고 가져오는 Model 클래스라고 생각하시면 됩니다.

class WordRepository(private val wordDao: WordDao) {
    val allWords = wordDao.getAlphabetizedWords()
    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

allWords : Dao의 Select문인 getAlphabetizedWords()메서드 수행 결과를 대입

insert() : WorkerThread로지정하고 dao의 insert 수행

6. ViewModel 구현

LiveData : 데이터가 바뀔 때 마다 특정 행위를 할 수 있는 살아있는 데이터

ViewModel : lifecycle을 알고 있는 LiveData를 담고 다른 수명주기를 가지고 있음

class WordViewModel(private val repository: WordRepository) : ViewModel() {
    val allWords: LiveData<List<Word>> = repository.allWords.asLiveData()
    fun insert(word: Word) = viewModelScope.launch {
        repository.insert(word)
    }
}

repository의 allWords를 라이브데이터로 대입

코루틴을 사용해 백그라운드에서 repository에서 insert통신을 한다.

7. XML 추가

values/theme.xml

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

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

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <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"
            android:src="@drawable/ic_baseline_add_24"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

File > New > Vector Asset을 통해 +이미지 추가

8. Adapter 추가

class WordListAdapter : ListAdapter<Word, WordListAdapter.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
        }
    }
}

리사이클러뷰 Adapter를 추가해줍니다.

9. Koin을 사용해 DI

class MyApplication : Application() {
        override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@MyApplication)
            modules(appModule)
        }
    }
}
val appModule = module {
    val applicationScope = CoroutineScope(SupervisorJob())

    single {
       WordRoomDatabase.getDatabase(androidApplication(), applicationScope)
    }
    single {
        WordRepository(get())
    }
    single {
        get<WordRoomDatabase>().wordDao()
    }
    viewModel {
        WordViewModel(get())
    }
}

※ Koin사용을 하면서 인터페이스를 싱글턴 객체로 만들려면 WordRoomDatabase에 추상함수로 만든 후 get으로 얻은 후에 wordDao()를 사용해 객체를 생성해야함.

Manifest에 Application name 등록은 필수 !!

10. NewWordActivity 생성

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.xml

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

NewWordActivity생성 후 Manifest 추가

<activity android:name=".NewWordActivity"></activity>

activity_new_word.xml

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android">
    <LinearLayout
        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/purple_500"
            android:text="@string/button_save"
            android:layout_margin="@dimen/big_padding"
            android:textColor="@color/buttonLabel" />

    </LinearLayout>
</layout>

NewWordActivity

class NewWordActivity : BaseActivity<ActivityNewWordBinding>(R.layout.activity_new_word) {
    private lateinit var editWordView: EditText
    override fun init() {
        super.init()
        editWordView = findViewById(R.id.edit_word)
        binding.buttonSave.setOnClickListener {
            val replyIntent = Intent()
            if(TextUtils.isEmpty(binding.editWord.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"
    }
}

단어를 적고 Save버튼을 누르면 setResult를 통해 reply한다.

11.  MainActivity

class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {
    private val newWordActivityRequestCode = 1
    private val wordViewModel: WordViewModel by viewModel()

    override fun init() {
        super.init()
        val adapter = WordListAdapter()
        binding.recyclerview.adapter = adapter
        binding.recyclerview.layoutManager = LinearLayoutManager(this)
        wordViewModel.allWords.observe(this, Observer { words ->
            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, 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()
        }
    }

}

allWords를 구독하면서 데이터 변경이 감지되면 ListAdapter의 submitList함수를 사용해 데이터 업데이트

onActivityResult로 단어가 들어오면 Insert

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <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"
            android:src="@drawable/ic_baseline_add_24"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

아키텍처 구조

수고하셨습니다.

BaseActivity, DataBinding등을 참고하려면 github.com/YuYangWoo/today-i-learned/tree/main/RoomDB에서 소스 확인하세요!

반응형