Room은 SQLite를 활용하는 데이터 베이스이다.
위 링크에서는 다음과 같이 설명하고 있다.
상당한 양의 구조화된 데이터를 처리하는 앱은 데이터를 로컬로 유지하여 대단한 이점을 얻을 수 있습니다. 가장 일반적인 사용 사례는 관련 데이터를 캐싱하는 것입니다. 이런 방식으로 기기가 네트워크에 액세스할 수 없을 때 오프라인 상태인 동안에도 사용자가 여전히 콘텐츠를 탐색할 수 있습니다. 나중에 기기가 다시 온라인 상태가 되면 사용자가 시작한 콘텐츠 변경사항이 서버에 동기화됩니다. |
Room DB의 장점은 다음과 같다.
- 큰 사이즈의 구조화된 데이터를 로컬로 유지하여 관련 데이터를 캐싱할 수 있다.
- 서버와 통신이 완료되기 전에 이전에 불러왔었던 값들로 미리 화면에 UI를 띄워놓을 수 있다.
SharedPreferences 역시 앱에서 로컬에 앱 데이터를 저장해 놓을 수 있지만,
SharedPreferences는 가벼운 데이터를 저장할 목적으로 로컬 DB를 사용하고
(ex. 서버와 통신할 때 쓸 String형 access_token)
Room DB는 큰 사이즈의 데이터를 저장할 목적으로 로컬 DB를 사용하게 된다.
(ex. 유저의 기본 정보 클래스)
주요 구성요소
Room DB에는 세 가지 주요 구성요소가 있다.
- 데이터 베이스 : 데이터 베이스를 포함하고, 앱의 존재하는 데이터들 간의 주된 연결점을 제공한다.
@Database 어노테이션 주석으로 지정된 클래스는 다음 조건들을 만족해야 한다.
1. RoomDatabase 클래스를 상속받아야 한다.
2. 추상 클래스여야 한다.
3. 주석 내에 데이터 베이스와 연결된 클래스들의 목록을 포함해야 한다.
4. 매개변수가 0개이고, @Dao로 지정된 클래스를 반환하는 추상 메서드를 포함해야 한다. - 항목 : 데이터 베이스 내의 테이블. 연결된 클래스들.
- DAO : 데이터 베이스에 액세스 하는데 사용되는 메서드가 포함된 클래스.
Room DB를 통해 데이터베이스와 연결된 데이터 액세스 매개체 또는 DAO를 가져온다.
후에 가져올 데이터에 해당하는 DAO를 사용하여 데이터를 가져오고
변경사항을 다시 데이터베이스에 저장한다.
후에 앱은 항목(Entity)를 사용하여 key 값을 사용해서(DB에서 Primary key)
데이터 베이스 내의 열에 해당하는 값을 가져오고 설정한다.
의존성 주입
App 수준의 Build.gradle에 위와같은 의존성들을 주입한다.
레이아웃 코드
메인 액티비티
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<import type="android.view.View"/>
<variable
name="isEditing"
type="java.lang.Boolean" />
<variable
name="input"
type="java.lang.String" />
</data>
<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"
android:paddingHorizontal="16dp"
android:background="@color/white"
tools:context=".ui.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{isEditing ? View.GONE : View.VISIBLE}"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/input_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:padding="6dp"
android:background="@drawable/square_background"
android:text="@={input}"
android:textSize="18sp"
android:textColor="@color/black"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/input_btn"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Button
android:id="@+id/input_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/save"
android:textSize="18sp"
android:textColor="@color/white"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/input_edit_text"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/is_editing"
android:textSize="18sp"
android:textColor="@color/black"
android:visibility="@{isEditing ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/main_recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/input_layout"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
오늘은 프래그먼트는 사용하지 않을 것이다.
메인 액티비티 레이아웃 xml파일의 전체 코드와 화면 구성이다.
리사이클러뷰 홀더
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<import type="android.view.View"/>
<variable
name="memo"
type="com.khs.roomdbexampleproject.data.model.Memo" />
<variable
name="input"
type="java.lang.String" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:background="@color/MintCream"
android:padding="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/holder_memo_show_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{memo.editMode ? View.GONE : View.VISIBLE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:id="@+id/memo_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{memo.memo}"
android:textSize="24sp"
android:textColor="@color/black"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btn_layout"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/btn_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/memo_tv"
app:layout_constraintEnd_toEndOf="parent">
<Button
android:id="@+id/edit_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/edit"
android:textSize="18sp"
android:textColor="@color/white"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/remove_btn"/>
<Button
android:id="@+id/remove_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/remove"
android:textSize="18sp"
android:textColor="@color/white"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/edit_btn"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/holder_memo_edit_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{memo.editMode ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<EditText
android:id="@+id/memo_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="6dp"
android:text="@={input}"
android:textSize="24sp"
android:textColor="@color/black"
android:background="@drawable/square_background"
android:visibility="@{memo.editMode ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/complete_btn"/>
<Button
android:id="@+id/complete_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/complete"
android:textSize="18sp"
android:textColor="@color/white"
android:visibility="@{memo.editMode ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/memo_edit_text"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
메모 홀더 레이아웃 xml 파일의 전체 코드이다.
데이터 바인딩을 이용해서 나타냈다.
왜 데이터 바인딩마다 input을 따로 뒀냐면,
input을 따로 두지 않으면 제대로 뷰가 갱신되지 않거나,
수정을 하지않고 취소를 해도 리사이클러뷰에서 수정이 돼버리는 문제가 생기게 된다.
(뭔가 더 깔끔한 로직이 있을 것 같은데 생각이 나질 않는다.)
input은 양방향 데이터로 구현했다.
프로젝트 기능
오늘 만들어 볼 프로젝트의 기능은 다음과 같다.
- 메모를 작성하고 저장 버튼을 누르면 메모가 로컬 DB에 저장된다.
- 메모는 수정, 삭제가 가능하다.
- 메모를 수정할 때는 메모를 입력시키는 칸이 사라지고 메모 수정 중 이라는 텍스트가 띄워진다.
AAC ViewModel, MVVM ViewModel, Repository Pattern, LiveData Observe Pattern을 사용해서 구현해 보겠다.
지난 글에서 구현했던 Retrofit2의 Response 비동기 처리 방식과 유사하지만 살짝 다르다.
소스 코드
우선은 Room DataBase의 구성을 짜 보겠다.
위에서 설명했듯이 Room DB의 주요 구성요소는 데이터베이스, 항목, DAO 세 가지이다.
Room - 항목(Entity)
룸 DB에 저장할 Memo 클래스이다. (MVVM에서 Model에 해당하는 클래스이다.)
@Entity 어노테이션을 이용해서 Room DB에 사용할 항목임을 표시한다.
또한 id 변수에 @PrimaryKey 어노테이션을 사용해서 Key로 지정해주고 autoGenerater를 true로 해준다.
이렇게 할 경우 id가 자동으로 지정이 된다.
그리고 memo 변수에는 메모 값,
editMode 변수는 현재 메모가 수정 중인지 나타내기 위해 만든 변수이다. Room DB에는 무조건 false로 저장이 된다.
Room - DAO
@Dao 어노테이션으로 Room DB에서 DAO 인터페이스임을 표시해준다.
데이터에 대한 DAO 인터페이스이다.
데이터베이스에 접근해서 항목을 삽입, 수정, 삭제하는 메서드가 구현되어 있다.
쿼리문의 형태로 구성된 메서드들도 존재하게 된다.
Room - 데이터베이스
@Database 어노테이션으로 Room DB에서 데이터베이스임을 표시하고,
파라미터로 항목(entitiy)과 version을 표시한다.
- 항목 : Room DB에서 관리할 Entity들을 표기한다. 여러 개일 경우 아래와 같이 표기한다.
entities = [Memo1::class, Memo2::class, ...] - 버전 : Room DB에서의 버전 코드이다.
Room DB에서 Entitiy 등 데이터에 대한 정보가 수정된 경우 이 Version을 올려줘야 한다.
데이터베이스 클래스에는 TypeConverter도 지정해줄 수 있다.
이 프로젝트에서는 사용하진 않지만, 예전에 다른 앱에서 사용한 TypeConverter를 보여주겠다.
TypeConverter는 Room DB에서 관리하지 못하는 자료형에 대한 변환 메서드를 제공해 주는 것이다.
날짜 객체 Date를 Long으로 현재 시간에 대한 변수로 저장하거나 불러와서 날짜로 가져오는 TypeConverter이다.
다른 특수 자료형들(배열, 다른 클래스 등)도 위와 같이 구현해서 만들어 줄 수 있다.
@TypeConverters 어노테이션을 이용해서 @TypeConverter 어노테이션이 선언된 메서드가 포함된 클래스를 지정해줄 수 있다.
(companion object는 무시해도 된다.)
그러면 이제 만든 Room DB를 어떻게 사용할 수 있을까?
공식 문서의 경우에는 context를 가져올 수 있는 곳에서 구현하는 것으로 나와있다.
하지만 room DB 객체를 초기화하는 것은 상당히 코스트가 큰 작업이라고 한다.
GlobalApplication
나같은 경우에는 Application 클래스를 상속받는 전역 어플리케이션 클래스를 하나 만들어서
이 곳에서 Companion object로 만들어서 사용했다.
이렇게 만들면 한 번의 초기화로 모든 곳에서 Room DB에 접근이 가능하게 된다.
appInstance는 현재 앱의 객체이고,
appDataBaseInstance가 room 데이터베이스 객체이다.
companion object로 선언하고 private set으로 여기서만 초기화가 가능하도록 만들고 초기화 해준다.
초기화할 때, 매개변수로 context와 DataBase 클래스, db이름이 필요하다.
전역 어플리케이션을 생성하고 난 후에는 꼭 Manifest에 android:name에 선언해줘야 한다.
안드로이드 권장 아키텍처
Room DB 객체까지 생성하는 데 성공했다.
이제부터는 바로 사용이 가능하지만,
이번 프로젝트의 목적은 안드로이드 권장 아키텍처를 따라서 구현하는 것이다.
이제 우리는 ViewModel과 Repository를 구현해야 한다.
Repository
Repository를 먼저 살펴보자.
레포지토리에서는 Room DB에 있는 메서드들을 호출한다.
suspend 메서드는 일시중단 가능한 메서드로,
스레드에서 Block된 경우 기다리지 않고 다른 작업이 가능하게 해준다.
CoroutineScope, 다른 Suspend 메서드 안에서 사용이 가능하다.
CoroutineScope는 비동기로 처리하게 해주는 공간이다. 나중에 따로 정리할 예정이다.
ViewModel
Repository를 사용할 곳은 ViewModel이다. ViewModel클래스를 살펴보자.
package com.khs.roomdbexampleproject.data.viewmodel.memo
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.khs.roomdbexampleproject.data.model.Memo
import com.khs.roomdbexampleproject.data.repository.MemoRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MemoViewModel(private val memoRepository: MemoRepository): ViewModel() {
val isEdit = MutableLiveData<EditMemoPostData>()
val isMemoInsertComplete = MutableLiveData<Long>()
val isMemodeleteComplete = MutableLiveData<Memo>()
val isMemodeleteByIdComplete = MutableLiveData<Memo>()
val isMemoModifyComplete = MutableLiveData<EditMemoPostData>()
val isGetAllMemoComplete = MutableLiveData<List<Memo>>()
fun changeMode(memo: Memo, _isEdit: Boolean) {
CoroutineScope(Dispatchers.IO).launch {
isEdit.postValue(EditMemoPostData(memo, memo.memo, _isEdit))
}
}
fun insertMemo(memo: Memo) {
CoroutineScope(Dispatchers.IO).launch {
memoRepository.insertMemo(memo).let {
id ->
isMemoInsertComplete.postValue(id)
}
}
}
fun deleteMemo(memo: Memo) {
CoroutineScope(Dispatchers.IO).launch {
memoRepository.deleteMemo(memo).let {
isMemodeleteComplete.postValue(memo)
}
}
}
fun deleteMemoById(memo: Memo) {
CoroutineScope(Dispatchers.IO).launch {
memoRepository.deleteMemoByID(memo.id).let {
isMemodeleteByIdComplete.postValue(memo)
}
}
}
fun modifyMemo(memo: Memo, editMemo: String) {
CoroutineScope(Dispatchers.IO).launch {
memoRepository.modifyMemo(memo.id, editMemo).let {
isMemoModifyComplete.postValue(EditMemoPostData(memo, editMemo, false))
}
}
}
fun getAllMemo() {
CoroutineScope(Dispatchers.IO).launch {
memoRepository.getAllMemo().let {
isGetAllMemoComplete.postValue(it)
}
}
}
inner class EditMemoPostData(val memo: Memo, val editMemo: String, val isEdit: Boolean)
}
뷰모델 클래스이다.
AAC ViewModel을 상속받아서, Activity마다 하나의 ViewModel을 사용하도록 만들었다.
(물론 이 프로젝트에서는 Activity 한 개만 존재하긴 한다.)
매개변수로 Repository를 받고, Repository에 해당하는 메서드들을 여기에서 호출하게 된다.
그리고 LiveData 변수들을 만들고 각각에 해당하는 메서드들을 만든다.
각 메서드들의 실행 내용은 CoroutineScope에서 비동기로 처리되게 만들었다.
EditMemoPostData라는 객체가 있는데, 메모 수정 모드 및 수정된 메모를 바꾸게 하기 위해 postValue해주기 위해
만들어 놓은 클래스이다.
리사이클러뷰에서도 갱신이 되게 하기 위해서 Memo객체를 받아와서
리스트의 indexOf()메서드를 통해서 index를 알아온 후에 해당 index에만 뷰 업데이트가 일어나도록 하게 만들었다.
- isEdit : EditMemoPostData 자료형 LiveData.
changeMode() 메서드와 연동된다.
수정 모드로 진입하기 위해 사용되는 메서드이다.
_isEdit을 true를 postValue할 경우 수정모드로 진입.
_isEdit을 false를 postValue할 경우 수정 모드에서 빠져나온다.
해당 변수는 Room DB와는 관계가 없는 라이브 데이터이다. - isMemoInsertComplete : Long형 LiveData.
insertMemo() 메서드와 연동된다.
Repository를 통해 Room DB에 메모를 삽입하면
해당 Memo의 id를 return하게 되는데, 이를 받아서 postValue해준다. - isMemoDeleteComplete / isMemoDeleteByIdComplete : Memo형 LiveData.
각각 deleteMemo() / deleteMemoById() 메서드와 연동된다.
Repository를 통해 Room DB에서 메모를 삭제한 후에,
Memo 객체를 postValue해준다.
나중에 이 객체를 통해서 index를 알아온 후에 리스트에서 지워주고
리사이클러뷰를 갱신해 줄 것이다. - isMemoModifyComplete : EditMemoPostData형 LiveData.
modifyMemo() 메서드와 연동된다.
Repository를 통해 Room DB에서 메모를 수정한 후에
EditMemoPostData객체를 넘겨주고,
이 객체를 받아와서 Memo에 해당하는 index를 알아와서 리스트를 업데이트 시키고,
해당 Memo 객체의 메모를 수정된 메모로 바꿔준다. - isGetAllMemoComplete : Memo의 List형 LiveData.
getAllMemo() 메서드와 연동된다.
Repository를 통해 Room DB에서 모든 메모를 가져온 후에
메모 리스트들을 postValue해준다.
각 메서드에서 설명했듯, Memo를 넘겨주는 이유는,
리사이클러 뷰에서 ViewModel의 메서드만 호출할 예정이다.
이렇게 되면 각 item의 index를 알 수가 없다.
이 때 List 자료형의 indexOf(e: Element) 메서드를 사용하면 객체를 통해서 index를 알 수 있게된다.
또 AAC ViewModel은 ViewModelProvider를 통해 초기화하게 되는데, 매개변수를 전달하려면
ViewModelFactory가 있어야 한다.
아래처럼 구현해준다.
Activity / Fragment
뷰모델을 호출하는 곳은 액티비티가 된다.
이제 액티비티 소스 코드를 살펴보자.
package com.khs.roomdbexampleproject.ui
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.khs.roomdbexampleproject.adapter.MemoRecyclerViewAdapter
import com.khs.roomdbexampleproject.data.AppDataBase
import com.khs.roomdbexampleproject.data.ViewModelFactory
import com.khs.roomdbexampleproject.data.model.Memo
import com.khs.roomdbexampleproject.data.repository.MemoRepository
import com.khs.roomdbexampleproject.data.viewmodel.memo.MemoViewModel
import com.khs.roomdbexampleproject.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
lateinit var viewModelFactory: ViewModelFactory
lateinit var memoViewModel: MemoViewModel
lateinit var memoList: MutableList<Memo>
lateinit var memoRecyclerViewAdapter: MemoRecyclerViewAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
binding.lifecycleOwner = this
setContentView(binding.root)
initActivity()
}
private fun initActivity() {
initViewModel()
setUpObserver()
getAllMemo()
setUpBtnListener()
}
private fun initViewModel() {
viewModelFactory = ViewModelFactory(MemoRepository())
memoViewModel = ViewModelProvider(this, viewModelFactory).get(MemoViewModel::class.java)
}
private fun setUpObserver() {
memoViewModel.isGetAllMemoComplete.observe(this) {
memoList = it.toMutableList()
Log.d("getList::", "size is " + it.size.toString())
setUpRecyclerView()
}
memoViewModel.isMemodeleteComplete.observe(this) {
Log.d("deleteComplete::", "memo delete")
val position = memoList.indexOf(it)
memoList.removeAt(position)
memoRecyclerViewAdapter.notifyItemRemoved(position)
memoRecyclerViewAdapter.notifyItemChanged(position)
}
memoViewModel.isMemodeleteByIdComplete.observe(this) {
Log.d("deleteComplete::", "memo delete")
val position = memoList.indexOf(it)
memoList.removeAt(position)
memoRecyclerViewAdapter.notifyItemRemoved(position)
memoRecyclerViewAdapter.notifyItemChanged(position)
}
memoViewModel.isMemoInsertComplete.observe(this) {
id ->
Log.d("insertComplete::", "memo id is $id")
memoList.add(Memo(id, binding.input.toString(), false))
binding.input = ""
memoRecyclerViewAdapter.notifyItemInserted(memoList.size - 1)
}
memoViewModel.isEdit.observe(this) {
binding.isEditing = it.isEdit
val position = memoList.indexOf(it.memo)
if(it.isEdit) {
if(memoRecyclerViewAdapter.lastEditIdx != -1) {
memoList[memoRecyclerViewAdapter.lastEditIdx].editMode = false
memoRecyclerViewAdapter.notifyItemChanged(memoRecyclerViewAdapter.lastEditIdx)
}
memoRecyclerViewAdapter.lastEditIdx = position
memoList[position].editMode = true
memoRecyclerViewAdapter.notifyItemChanged(position)
}
}
memoViewModel.isMemoModifyComplete.observe(this) {
Log.d("modifyComplete::", "memo modified")
val position = memoList.indexOf(it.memo)
memoList[position].memo = it.editMemo
memoList[position].editMode = false
memoRecyclerViewAdapter.lastEditIdx = -1
memoRecyclerViewAdapter.notifyItemChanged(position)
}
}
private fun getAllMemo() {
memoViewModel.getAllMemo()
}
private fun insertMemo() {
if(binding.input.toString().trim().isEmpty().not()) {
val memo = Memo(0, binding.input.toString(), false)
memoViewModel.insertMemo(memo)
}
}
private fun setUpRecyclerView() {
memoRecyclerViewAdapter = MemoRecyclerViewAdapter(baseContext, memoList, memoViewModel)
binding.mainRecyclerView.adapter = memoRecyclerViewAdapter
binding.mainRecyclerView.layoutManager = LinearLayoutManager(baseContext)
}
private fun setUpBtnListener() {
binding.inputBtn.setOnClickListener { insertMemo() }
}
}
액티비티에서의 로직은 다음과 같다.
- initViewModel() : ViewModelFactory, ViewModel을 초기화한다.
- setUpObserver() : ViewModel에서의 LiveData들에 대한 Observer를 여기서 셋팅해준다.
LiveData에 값이 들어오면 Log를 찍거나 UI를 변경시킨다.
메모를 불러온 후라면 전역변수 memoList에 불러온 메모리스트들을 넣은 후에
리사이클러뷰를 셋팅하고,
메모를 입력한 후라면 memoList에 따로 메모를 넣어주고 입력하는 창을 초기화한다.
그리고 메모 수정 모드에 진입하면 데이터 바인딩 변수인 isEdit을 들어온 값으로 수정한다.
나머지 메모 수정, 삭제는 UI변경 부분은 없고 로그만 찍는다.
리사이클러뷰 관련한 UI 수정도 여기서 모두 해준다.
Observer Pattern으로, 리사이클러 뷰 관련한 UI 수정도 메인 액티비티에서하게 했는데,
indexOf()를 사용해야 한다는 등 불필요한 로직이 반강제적으로 필요하다.
그냥 RecyclerView에서 버튼을 동기적으로 눌러서 반응하도록 만들면
위 코드보다는 쉬운 구현이 가능하다. - getAllMemo() : 메모뷰모델을 통해 메모를 가져오게 된다.
옵저버에 메모를 가져온 후에 뭘 할지 구현해놨기 때문에 가져오기만 하면 된다.
Activity -> ViewModel -> Repository -> Room DB 이므로 권장 아키텍처에 따른 구현이 된다. - setUpRecyclerView() : 리사이클러뷰 어댑터, 레이아웃 매니저를 초기화하는 메서드이다.
- setUpBtnListener() : 버튼 리스너를 설정하는 메서드.
여기서는 메모 입력 버튼 리스너만 설정해준다.
메모 입력 버튼 리스너는 insertMemo() 메서드인데,
해당 메서드는 공백이 아닐경우 Memo 객체를 생성해서 memoViewModel을 통해
메모를 삽입하게 된다.
삽입한 후에는 Observer를 통해 UI가 다시 갱신된다.
코드가 조금 지저분해 보이지만, ViewModel을 통해서 RecyclerView, MainActivity를 갱신하게 된다.
리사이클러뷰 어댑터도 한 번 살펴보겠다.
리사이클러뷰 어댑터
리사이클러 뷰 어댑터에서는 다른 어려운 것은 없다.
package com.khs.roomdbexampleproject.adapter
import android.content.Context
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.khs.roomdbexampleproject.data.AppDataBase
import com.khs.roomdbexampleproject.data.model.Memo
import com.khs.roomdbexampleproject.data.viewmodel.memo.MemoViewModel
import com.khs.roomdbexampleproject.databinding.HolderMemoBinding
import com.khs.roomdbexampleproject.ui.MainActivity
import com.khs.roomdbexampleproject.util.MemoDiffUtil
class MemoRecyclerViewAdapter(val context: Context, val itemList: MutableList<Memo>,
val memoViewModel: MemoViewModel): RecyclerView.Adapter<MemoRecyclerViewAdapter.Holder>() {
var lastEditIdx: Int = -1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val binding = HolderMemoBinding.inflate(LayoutInflater.from(context), parent, false)
return Holder(binding)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val item = itemList[position]
holder.bind(item)
}
override fun getItemCount(): Int {
return itemList.size
}
inner class Holder(val binding: HolderMemoBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(item: Memo) {
binding.memo = item
binding.input = item.memo
//input을 따로 만든 이유 : 양방향 데이터 바인딩을 사용할 때
//memo 모델의 memo 변수를 쓰면 리사이클러 뷰 내에서 자동으로 업데이트 된다.
binding.editBtn.setOnClickListener {
memoViewModel.changeMode(item, true)
}
binding.removeBtn.setOnClickListener {
memoViewModel.deleteMemoById(item)
}
binding.completeBtn.setOnClickListener {
memoViewModel.changeMode(item, false)
memoViewModel.modifyMemo(item, binding.input.toString())
}
}
}
}
일단 파라미터로 MemoViewModel을 받는다.
이는 수정모드로 진입하게 하기 위함이다.
후에 binding 모델을 초기화하고, binding에서 input을 메모로 초기화해준다.
전역 변수에 lastEditIdx가 있는데, 이는 마지막으로 수정을 누른 메모가 있다면
해당 메모의 수정 모드는 종료시키려는 용도이다.
그리고 버튼 리스너를 설정한다.
관련된 로직들은 모두 MainActivity에서 구현되어있다.
- 수정 버튼 : 뷰모델의 changeMode() 메서드로 수정모드로 진입한다.
이렇게 하면 메인 액티비티에서는 Observer를 통해서 수정모드로 진입한 것을 알 수 있게된다.
리사이클러뷰에서는 해당 메모의 editMode를 true로 바꿔서 해당 메모를 수정 중이라는 것을 UI에 보여준다.
추가로, lastEditIdx가 -1이 아니라면,
다른 메모가 수정중이였을 수 있으므로 해당 메모의 editMode를 false로 바꿔준다. - 삭제 버튼 : 뷰 모델의 deleteMemoById() 메서드로 메모를 삭제해 주고,
어댑터의 리스트에서도 자체적으로 삭제해준다.
후에, notifyItemRemoved(), notifyItemChanged() 메서드로 해당 메모가 삭제된 것을 갱신시켜준다. - 수정 완료 버튼 : 뷰 모델에서 changeMode() 메서드로 수정 모드에서 탈출한다.
그리고 뷰 모델의 modifyMemo() 메서드로 해당 메모를 수정해준다.
리사이클러뷰 에서도 업데이트 시켜주고 editMode도 false로 바꾼다.
그리고 수정 완료 후에는 해당 메모를 갱신해줄 필요가 없으므로 다시 lastEditIdx를 -1로 수정해준다.
그리고 notifyItemChanged() 메서드로 해당 위치의 메모가 수정된 것을 알려준다.
코드와 로직은 모두 살펴보았다. 결과 화면을 살펴보자.
결과화면
메모 삽입
위처럼 저장 버튼을 누르게 되면 Room DB에 메모가 저장이 되고
이는 앱을 종료시키고 다시 들어와도 기록이 돼 있다.
왜냐면 로컬 DB(Room-SQLite)에 데이터를 저장시켜놨기 때문이다.
메모 수정
메모 수정은 다음과 같이 적용 된다.
- Room DB에서 수정
- 리사이클러 뷰 내에서 item 수정
또한 수정 모드 중에는 위에 상단에 [ 메모 수정 중 ] 이라는 문구가 나오게 된다.
수정이 완료되면 다시 입력하는 레이아웃이 나오게 된다.
메모 삭제
삭제 버튼을 누르면 해당 메모가 삭제된다.
AAC ViewModel, MVVM ViewModel을 같이 사용하면서 느끼는 거지만,
두 ViewModel이란 이름 때문에 정말 어렵고 혼동을 주는 개념인 것 같다.
조만간 디자인 패턴과 MVVM, AAC ViewModel, 앱 아키텍처 권장사항을 확실하게 정리해야 할 것 같다.
예시 프로젝트는 위 링크에서 확인할 수 있다.