2021. 5. 11. 17:51ㆍ개발/[Kotlin] 안드로이드 개발
안녕하세요. 양드로이드입니다.
MVVM패턴을 적용한 Room DB 사용법에 대해 알아보겠습니다.
이 예제는 구글 코드랩에 기재된 예제입니다.
developer.android.com/codelabs/android-room-with-a-view-kotlin?hl=ko#0
우선 결과화면 먼저 보도록 하겠습니다.
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에서 소스 확인하세요!
'개발 > [Kotlin] 안드로이드 개발' 카테고리의 다른 글
[DialogFragment, Dialog] 안드로이드 DialogFragment 크기 조정하기, 테두리 둥글게 조절하기 (0) | 2021.10.29 |
---|---|
[Android] Fragment에서 Activity의 resource에 resource까지 접근하기 (0) | 2021.05.25 |
[안드로이드] BroadcastReceiver 간단하게, 제대로 알자! with Kotlin (0) | 2021.03.22 |
[안드로이드] Retrofit2를 사용한 GET/POST 서버통신 with Kotlin (2) | 2021.03.22 |
[안드로이드] Navagation Safe Args를 사용해 데이터 전달 Fragment간 데이터전달 (0) | 2021.02.01 |