▩ 목 차 ▩
1. MVVM을 적용하기 전에 사전 지식 점검
1-1. 필요 기능
1-2. 디자인 패턴(MVC,MVVM) 적용 이유
1-3. MVC vs MVVM
1-4. MVVM 장점
2. MVVM을 적용하여 메모장 만들기
2-1. Dependency 추가
2-2. Room DB 생성 (Entity, DAO, Database) 생성
2-2-1. Room - Entity(항목)
2-2-2. Room - DAO(Data Access Object)
2-2-3. Room - Database 생성
2-4. ViewModel 생성
2-5. MainActivity 설정
2-5-1. 리사이클러뷰
2-5-1-1. 리사이클러뷰 xml
2-5-1-2. 리사이클러뷰 어댑터
3. 오류 해결
3-1. DB Impl does not exist 오류
3-2. Duplicate class android.support.v4. 오류
4. 주의할 점
4-1. MVVM의 ViewModel과 AAC의 ViewModel은 같다?
4-1-1. MVVM에서의 ViewModel이란
4-1-2. AAC에서의 ViewModel이란
4-1-3. MVVM에서의 ViewModel vs AAC에서의 ViewModel
MVVM 디자인 패턴을 적용시키기 위해 Databinding(Viewbinding), ViewModel, LiveData 을 공부해보았다. 이제는 간단하게 예제를 적용해봄으로써 MVVM 패턴의 맛을 한번 보겠다.. 일단 처음이니까 쉽게 쉽게 가자고~^^..(진짜 나한테 하나도 안쉬웠음.. 와 진짜 진짜 어렵다..)
■ 1. MVVM을 적용하기 전에 사전 지식 점검 ■
■ 1-1. 필요 기능
- View(뷰)
UI Controller를 담당하는 Activity, Fragment이다.
화면에 무엇을 그릴지 결정하고, 사용자와 상호작용한다.
데이터의 변화를 감지하기 위한 옵저버를 가지고 있다. - ViewModel(뷰모델)
뷰모델은 UI를 위한 데이터를 가지고 있다.
구성(configuration)이 변경되어도 살아남는다.(EX:화면 회전, 언어 변경)
뷰모델은 뷰와 분리되어 있기 때문에 액티비티가 Destroy 되었다가 다시 Create 되어도 종료되지 않고 데이터를 여전히 가지고 있다. - LiveData(라이브데이터)
관찰이 가능한(Observable) 데이터 홀더 클래스이다.
뷰에서 뷰모델의 라이브데이터를 관찰하게 되면 데이터가 변경될 때 내부적으로 자동으로 알려주게 된다.
액티비티나 프래그먼트의 생명주기를 인지한다. 즉, 액티비티가 화면 위ㅇ에 활성되어 있을 때에만 UI 변경 등의 기능을 동작하게 되고, Destroy 된 상태에서는 동작하지 않기 때문에 memory leak의 발생을 줄여준다. - Repository(레포지토리)
뷰모델과 상호작용하기 위해 잘 정리된 데이터 API를 들고 있는 클래스이다.
앱에 필요한 데이터(내장 DB or 외부 DB)를 가져온다.
뷰모델은 DB나 서버에 직접 접근하지 않고, 라포지토리에 접근하는 것으로 앱의 데이터를 관리한다. - Room(룸)
SQLite 데이터베이스를 편하게 사용하게 해주는 라이브러리이다.
SQLite의 코드를 직접 작성하는 경우, 직접 테이블을 Create하거나 쿼리문을 일일이 변수를 통해 작성해주어야 했지만, Room을 쓰면 직관적이고 편리하게 DB를 사용할 수 있다.
■ 1-2. 디자인 패턴(MVC,MVVM) 적용 이유
디자인 패턴을 적용하지 않고 개발을 하게 된다면 ?
==> 액티비티 혹은 프래그먼트에 기능을 붙이다보면 액티비티가 무거워지거나 혹은 종속성이 너무 강해져 테스트가 힘들고 유지보수가 어려워진다.
■ 1-3. MVC vs MVVM
MVC( Model - View - Controller)
- Controller
Ativity 부분에서 View에게는 화면 업데이트, Model에게는 데이터 갱신을 알리며 View와 Model을 연결해주며 비즈니스 로직을 처리하기 위해 많은 일을 해야 한다. - View
UI 역할을 담당 - Model
Model class로 비즈니스 로직에서의 알고리즘 등의 기능을 처리한다.
MVVM( View - ViewModel - Model )
- View
Activity 역할을 담당하고 UI를 갱신하는 역할에만 충실히 한다.
뷰모델을 관찰(Observer) 한다. ==> 데이터의 변화를 알아차리고 자동으로 화면을 갱신할 수 있다. - ViewModel
Model에게 데이터 갱신 처리를 요청하고 잘 정리된 데이터를 참조한다. - Model
Repository, DataBase부분으로 데이터 처리 역할을 한다.
■ 1-4. MVVM 장점
- 뷰가 데이터를 실시간으로 관찰한다.
LiveData(Observable 패턴)을 이용해 DB를 관찰하고 자동으로 UI를 갱신한다.
직접 뷰를 바꾸어주는 번거로움을 없애고 데이터와 불일치할 확률이 줄인다. - 생명주기로부터 안전하여 memory leak을 방지할 수 있다.
뷰모델을 통해 데이터를 참조하기 때문에 액티비티/프래그먼트의 생명주기를 따르지 않는다.
==> 화면전환(가로,세로) 같이 데이터가 새로 초기화 되어 재구성 되어도 뷰모델이 데이터를 홀드하고 있기 때문에 영향을 받지 않는다.
뷰가 활성화되어있을 경우에만 작동하기 때문에 불필요한 메모리 사용을 줄일 수 있다. - 기능별로 모듈화되어 있어 역할 분리를 할 수 있고 유닛 테스트에 한결 용이해진다.
■ 2. MVVM을 적용하여 메모장 만들기 ■
위의 사진은 MVVM 패턴을 적용하기 위해 필요한 많은 클래스들이다. 위의 흐름을 기반으로 메모장 앱을 만들어보겠다.
내가 만들어 볼 메모장의 기능으로는
- 메모를 작성하고 저장 버튼을 누르면 메모가 로컬 DB(Room DB)에 저장된다.
- 메모는 수정 및 삭제가 가능하다.
- 메모를 수정할 때는 메모를 입력시키는 칸이 사라지고 메모 수정 중 이라는 텍스트가 변경되어 띄어진다.
MVVM 패턴을 적용을 시켜 메모장 앱을 만드는 대략적인 순서는
- Dependency 추가
- Room DB 생성 (Entity, DAO, Database)생성
- Repository 생성
- AAC의 ViewModel 생성
- MainActivity 설정
- RecyclerView 설정 (xml, Adapter)
- AddActivity 생성
메모장 앱을 만들기에 앞서, 다른 블로그에 있는 다양한 예제를 보았는데 패키지 폴더를 만들어서 각 기능 및 역할대로 짜임새 있게 디렉토리 구조를 구성하였다. 이것은 추후에 내가 만든 앱을 다른 사람이 볼때 직관적으로 어떤 구조를 가졌는지에 대해 장점을 줄 것이고, 직관적이라 유지보수를 할 때도 많은 장점을 줄 것이다. 디렉토리 구조를 구성하는데 정답은 없었고 나는 아래와 같이 구성하기로 했다.
■ 2-1. Dependency 추가
App 수준의 Build.gradle에 사용하고자 하는(아래와 같은) 의존성들을 주입한다.
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt' // room-compiler 등을 사용하기 위해 이와 같이 의존성을 주입한다.
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
// Room
implementation 'androidx.room:room-ktx:2.4.3'
implementation "androidx.room:room-runtime:2.4.3"
kapt 'androidx.room:room-compiler:2.4.3' // room-compiler를 의존성 주입하기 위해선 kapt를 이용해야 오류가 나지 않음.
// annotationProcessor 'androidx.room:room-compiler:2.4.3'
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.1"
// Coroutine
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
// recyclerview noinspection GradleCompatible
implementation 'com.android.support:recyclerview-v7:28.0.0'
}
■ 2-2. Room DB 생성 (Entity, DAO, Database) 생성
2-2-1. Room - Entity(항목)
Room DB에 저장할 Memo 클래스이다. (MVVM에서 Model에 해당하는 클래스 혹은 데이터베이스 내의 테이블이다.)
- @Entity 어노테이션을 사용해서 Room DB에 사용할 항목임을 표시한다.
- @PrimaryKey 어노테이션을 사용해서 id변수에 Key로 지정해주고 autoGenerater를 true로 해준다.
==> id가 자동으로 지정이된다. - memo 변수는 메모 값을 넣기 위한 변수로 String으로 할당해준다.
- editMode 변수는 현재 메모가 수정 중인지 수정중이지 아닌지를 확인하기 위해 만든 변수이다. Room DB에는 무조건 false로 저장이 된다. 추후에 만약 수정 중일 경우 true로 초기화해서 사용하면 될 것이다.
package com.example.mvvm_example.database
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
class Memo (
@PrimaryKey(autoGenerate = true)
var id: Long,
var memo: String,
var editMode: Boolean
)
2-2-2. Room - DAO(Data Access Object)
데이터베이스에 접근하는데 사용하는 메서드를 담아두는 인터페이스이다.
- @Dao 어노테이션으로 Room DB에서 DAO 인터페이스임을 표시해준다.
- 데이터에 대한 DAO 인터페이스이다.
- 데이터베이스에 접근해서 항목을 삽입, 수정, 삭제하는 메서드가 구현되어 있다.
- 쿼리문의 형태로 구성된 메서드들도 존재하게 된다.
package com.example.mvvm_example.database
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
@Dao
interface Memo_Dao {
@Insert
suspend fun insertMemo(memo: Memo): Long
@Delete
suspend fun deleteMemo(memo: Memo)
@Query("DELETE FROM memo Where id = :id")
suspend fun deleteMemoById(id: Long)
@Query("SELECT * FROM Memo")
suspend fun getAllMemo(): List<Memo>
@Query("UPDATE Memo SET memo = :memo WHERE id = :id")
suspend fun modifyMemo(id:Long, memo: String)
}
2-2-3. Room - Database 생성
데이터베이스 홀더를 포함하여 앱의 지속적인 관계형 데이터의 기본 연결을 위한 기본 엑세스 포인트 역할을 한다.
- @Database 어노테이션으로 Room DB에서 데이터베이스임을 표시하고, 파라미터로 Entity(항목)과 version을 표시한다.
Entity(항목) : Room DB에서 관리할 Entity들을 표기한다. 여러 개일 경우 아래와 같이 표기한다. entities = [Memo1::class, Memo2::class, ...]
version : 앱을 업데이트하다가 entity의 구조를 변경했을 때 이전 구조와 현재 구조가 다르다 라는 것을 알려주기 위해 즉, 구분해주기 위한 역할을 한다. 만약 구조가 바뀌었는데 버전이 같다면 에러가 뜨면서 디버깅이 되지 않을 것이다.
room DB 객체를 초기화하는 것은 상당히 코스트가 큰 작업이며, context를 가져올 수 있는 곳에서 구현해야 한다. (안드로이드 공식문서)
위와 같은 조건(context, 큰 코스트 값) 때문에 Application 클래스를 상속받는 전역 어플리케이션 클래스를 하나 만들어서 이곳에서 Companion object로 만들어서 사용했다.
==> 위와 같이 해주게되면 한번의 초기화로 모든곳에서 Room DB에 접근이 가능하게 된다.
package com.example.mvvm_example.database
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [Memo::class], version = 1)
abstract class Memo_DB: RoomDatabase() {
abstract fun memoDao(): Memo_Dao
}
package com.example.mvvm_example.database
import android.app.Application
import androidx.room.Room
class Global_Memo_DB: Application() {
companion object{
lateinit var appInstance: Global_Memo_DB
private set
lateinit var appDataBaseInstance: Memo_DB
private set
}
override fun onCreate() {
super.onCreate()
appInstance = this
appDataBaseInstance = Room.databaseBuilder(
appInstance.applicationContext,
Memo_DB::class.java, "memo.db"
)
.fallbackToDestructiveMigration() // DB version 달라졌을 경우 데이터베이스 초기화
.allowMainThreadQueries() // 메인 스레드에서 접근 허용
.build()
}
}
위의 코드를 보게 되면,
- appInstance라는 변수는 현재 앱의 객체이고
- appDataBaseInstance가 room 데이터 베이스 객체이다.
- companion object로 선언하고 private set을 통해 여기서만 초기화가 가능하도록 만들고 초기화를 해준다. 초기화할 때, 매개변수로 context와 Database 클래스, db이름이 필요하다.
또한 주의할 점은 전역 어플리케이션을 생성하고 난 후에는 꼭 Manifest에 android:name에 선언해줘야 한다.
( application 태그 안에 name 코드를 이용해 선언해준다. )
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.mvvm_example">
<application
android:name=".database.Global_Memo_DB"
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/Theme.MVVM_Example"
tools:replace="android:appComponentFactory"
android:appComponentFactory="whateverString">
<activity
android:name=".activity.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
*전역 어플리케이션 클래스란?
대충 알기로는 앱 정보를 가지기 위해 전역 변수 등의 정보를 가지고 있는 Class라고 답할 것이다.
이것을 구체적으로 알아보자면, Application Class가 언제 실행되는지 알아봐야한다.
[ 안드로이드에서도 숨겨진 main 함수가 존재하는데 이것이 실행하게 되면 먼저 메인 루퍼와 핸들러를 호출시켜 main을 실행시킨 Thread를 MainThtread(UIThread)로 설정하고 MainThread의 루퍼를 실행시킨다. 이 MainThread에서 루퍼와 메시키 큐를 연결해 MainThread로서의 역할을 다 할 수 있게 설정하고 나면 이제 첫번째 액티비티로 설정된 액티비티의 실행을 위한 코드를 실행시키게 된다.
이 때 첫 번째 액티비티를 생성하기 전 단계인 handleLaunchActivity에서 Context, Component, Application과 같은 정보를 설정하고 추가한다. ]
간단하게 말을 하자면, 첫번째로 실행되는 액티비티 실행전에 초기화되는 Class 이기 때문에, UI를 생성하기 전에 필요한 데이터들을 미리 설정하거나 각 액티비티의 상태를 전역으로 관리하거나(registerActivityLifeCycleCallback을 지원) 로깅 등 애플리케이션 전역에서 사용될 수 있는 기능들에 대해 정의해두는 공간이라고 생각하면 되겠다.
이러한 전역 어플리케이션 클래스는 주로 액티비티의 전환을 액티비티 밖에서 제어하거나, 전역변수로 두는것이 더 효율적인 변수 및 메소드, 또는 애플리케이션 시작 전에 필수적으로 불러와야 하거나 저장해야하는 것들에 대해 사용하면 매우 효율적으로 사용할 수 있다.
하지만, 전역 어플리케이션 클래스내에서 선언되는 것들은 기본적으로 메모리 상에서 계속 올라가 있기 때문에 많은 용량을 차지하는 데이터를 지니거나 굳이 들고 있을 필요가 없는 것들을 가지고 있는 것에 대해서는 고민을 해봐야 할 것이다.
■ 2-3. Repository 생성
레포지토리에서는 Room DB에 있는 메서드들을 호출한다.
- suspend 메소드는 일시중단 가능한 메서드로, 스레드에서 Block된 경우 기다리지 않고 다른 작업이 가능하게 해준다.
- CoroutineScope, 다른 Suspend 메서드 안에서 사용이 가능하다.
- CoroutineScope는 비동기로 처리하게 해주는 공간이다.
package com.example.mvvm_example.repository
import com.example.mvvm_example.database.Global_Memo_DB
import com.example.mvvm_example.database.Memo
class MemoRepository {
private val appDBInstance = Global_Memo_DB.appDataBaseInstance.memoDao()
suspend fun insertMemo(memo: Memo) = appDBInstance.insertMemo(memo)
suspend fun deleteMemo(memo: Memo) = appDBInstance.deleteMemo(memo)
suspend fun deleteMemoById(id: Long) = appDBInstance.deleteMemoById(id)
suspend fun getAllMemo() = appDBInstance.getAllMemo()
suspend fun modifyMemo(id: Long, memo: String) = appDBInstance.modifyMemo(id, memo)
}
■ 2-4. ViewModel 생성
Repository를 사용할 곳은 이 ViewModel 클래스이다.
ViewModel은 액티비티 혹은 프래그먼트와 다른 생명주기를 가지게 된다. 그렇기에 다음과 같은 장점을 가지게 된다.
- 생명 주기에 영향을 받지 않고 데이터를 유지 할 수 있다.
- UI 컨트롤러(액티비티, 프레그먼트)와 데이터가 분리된다.
- 프래그먼트 간의 데이터 공유가 쉬워진다.
AAC ViewModel을 상속받아, Activity마다 하나의 ViewModel을 사용하도록 만들었다.
매개변수로 Repository를 받고, Repository에 해당하는 메서드들을 여기에 호출하게 된다.
LiveData 변수들을 만들고 각각에 해당하는 메서드들을 만든다.
각 메서드들의 실행 내용은 CoroutineScope를 이용하여 비동기로 처리되게 만들었다.
EditMemoPostData의 객체는 메모 수정 모드 및 수정된 메모를 바꾸게 하기 위해, 즉 PostValue 해주기 위해 만들어 놓은 클래스이다.
리사이클러뷰에서도 갱신이 되게 하기 위해 Memo 객체를 받아와 리스트의 indexOf()메서드를 통해 index를 알아온 후 해당 index에만 뷰 업데이트가 일어나도록 만들었다.
- isEdit : LiveData를 사용하며, 제너릭을 이용해 EditMemoPostData 자료형만을 허용한다.
changeMode() 메서드와 연동되며, 수정모드로 진입하기 위해 사용되는 메서드이다.
_isEdit를 ture를 postValue할 경우 수정모드로 진입하고, _isEdit을 false를 postValue할 경우 수정 모드에서 빠져나온다.
이 변수는 Room DB와는 관계가 없는 라이브 데이터이다. - isMemoInsertComplete : LiveData를 사용하며, 제너릭을 이용해 Long 자료형만을 허용한다.
insetMemo() 메서드와 연동되며, 메모를 삽입하기 위해 사용되는 메서드이다.
Repository를 통해 Room DB에 메모를 삽입하면 해당 Memo의 id를 retrun하게 되는데, 이를 받아서 postValue 해준다. - isMemoDeleteComplete / isMemoDeleteByIdComplete : LiveData를 사용하며, 제너릭을 이용해 Memo 자료형만을 허용한다.
각각 deleteMemo() / deleteMemoById()메서드와 연동되며, 메모 삭제 및 해당하는 메모의 id를 삭제하기 위해 사용되는 메서드이다.
Repository를 통해 Room DB에서 메모를 삭제한 후에, Memo 객체를 postValue 해준다. - isMemoModifiyComplete : LiveData를 사용하며, 제너릭을 이용해 EditMemoPostData 자료형만을 허용한다.
modifyMemo() 메서드와 연동되며, 수정한 메모로 업데이트 시켜주기 위한 메서드이다.
Repository를 통해 Room DB에서 메모를 수정 한 후에 EditMemoPostData객체를 넘겨주고, 이 객체를 받아와서 Memo에 해당하는 index를 알아와서 리스트를 업데이트 시키고, 해당 Memo 객체의 메모를 수정된 메모로 바꿔준다. - isGetAllMemoComplete : LiveData를 사용하며, 제너릭을 이용해 Memo의 List 자료형만을 허용한다.
getAllMemo() 메서드와 연동되며, 모든 메모를 가져오기 위한 메서드이다.
Repository를 통해 Room DB에서 모든 메모를 가져온 후에 메모 리스트들을 postValue해준다.
또한 각 메서드에 Memo를 넘겨줘야하는데, 이것은 리사이클러뷰에서 ViewModel의 메서드만 호출하려고 하기 때문이다.
만약 Memo를 넘겨주지 않으면 각 item의 index를 알 수가 없다. 하지만 Memo를 넘겨주게 되면 List 자료형의 indexOf() 메서드를 이용하여 index를 알 수 있기 때문이다.
package com.example.mvvm_example.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.example.mvvm_example.database.Memo
import com.example.mvvm_example.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 {
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)
}
*EditMemoPostData를 inner class로 정의를 한 이유 ?
코틀린에서 inner class로 정의를 하게 되면 내부 클래스가 된다.
[ inner를 쓰지 않고 그냥 class로 정의를 하게 되면 중첩 클래스가 된다. ]
내부 클래스는 외부 클래스를 참조 가능하다. 외부 클래스를 참조할 일이 있을 줄 알고 Inner Class로 정의를 했다. 그런데 막상 코드를 짜보니 외부 클래스를 참조할 일이 없었다. 중첩 클래스로 짜도 무방하기 때문에 중첩 클래스로 정의를 하기를 바란다.
[ 중첩 클래스는 외부 클래스가 참조 불가능하다.]
==> 즉, 내부 클래스는 항상 외부 클래스의 객체를 참조하기때문에 객체가 적절한 시점에 삭제되지 못하고 메모리 누수가 발생할 수 있다. 그러므로 특별한 경우가 아니라면 내부 클래스 사용을 지양하고 중첩 클래스를 사용하는 것이 권장된다.
*LiveData의 값을 변경해줄때 setValue()도 있지만 postValue()를 쓰는 이유 ?
- setValue() : 메인 쓰레드에서 LiveData의 값을 변경해준다. 메인 쓰레드에서 바로 값을 변경해주기 때문에 setValue()함수를 호출한 뒤 바로 밑에서 getValue() 함수로 값을 읽어오면 변경된 값을 가져올 수 있다.
중요한 점은, setValue()는 메인 쓰레드에서 값을 보내기 때문에 백그라운드에서 setValue()를 호출한다면 오류가 나게 된다. - postValue() : 메인 쓰레드가 아닌 백그라운드 쓰레드에서 값을 변경한다. 즉, 백그라운드 쓰레드에서 동작하다가 메인 쓰레드에 값을 post하는 방식이다. 메인 쓰레드에 적용되기 전에 postValue()가 여러번 호출된다면 모든 값이 적용되는 것이 아니라 가장 최신의 값이 적용된다. 그렇기에 postValue()를 호출한 뒤 바로 getValue()로 값을 읽으려고 한다면 변경된 값을 읽어오지 못할 가능성이 높다. 이유는 Handler()를 통해 메인 쓰레드에 값이 전달되기 전에 getValue()를 호출하기 때문이다.
LiveData의 값을 즉각적으로 변경해야 한다면 postValue()가 아닌 setValue()를 사용해야 한다.
ViewModel은 ViewModelProvider를 통해 초기화하게 되는데, 매개변수를 전달하려면 ViewModelFactory가 있어야 한다.
package com.example.mvvm_example.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.mvvm_example.repository.MemoRepository
class MemoViewModelFactory(private val memoRepository: MemoRepository): ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(MemoRepository::class.java).newInstance(memoRepository)
}
}
■ 2-5. MainActivity 설정
위에서 만든 ViewModel을 호출하는곳(액티비티)이다.
그 전에 메인 액티비티에 올려줄 리사이클러뷰의 어댑터부터 살펴보자.
2-5-1. 리사이클러뷰
2-5-1-1. 리사이클러뷰 xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<import type="android.view.View"/>
<variable
name="memo"
type="com.example.mvvm_example.database.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/white"
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) 코드이다.
간단하게 설명을 해보자면,
- 데이터 바인딩을 이용을 하여 layout <>태그를 사용했다.
- View를 사용하기 위해 android.view.View를 import하여 사용하였다.
- com.example.mvvm_example.database.Memo 경로를 연결(type)해주었고, 이것을 memo라는 이름을 설정(name)하여 사용하였다.
- 양방향 데이터 바인딩을 사용하기 위해 input을 따로 만들었고 java.lang.String 경로를 연결(type)해주었고, 이것을 input 이라는 이름을 설정(name)하여 사용하였다.
- 메모가 저장된 것이 보여지는 layout / 메모가 수정될때 보여지는 layout 따로 만들었다.
- 메모가 저장된 것이 보여지는 layout : memo의 editMode가 true라면 이 layout은 보여지지 않고 false라면 이 layout은 보여지게 된다.
이때 editMode가 false라서 보여지게 되는 View는 저장된 메모, 수정하기 버튼, 지우기 버튼이 보여지게 된다. - 메모가 수정될 때 보여지는 layout : memo의 editMode가 true라면 이 layout은 보여지게되고, false라면 이 layout은 보여지지 않게 된다.
이때 editMode가 true라서 보여지게 되는 View는 메모를 입력할 수 있는 EditText창, 완료 버튼이 보여지게 된다.
- 메모가 저장된 것이 보여지는 layout : memo의 editMode가 true라면 이 layout은 보여지지 않고 false라면 이 layout은 보여지게 된다.
2-5-1-2. 리사이클러뷰 어댑터
리사이클러뷰를 사용하기 위한 어댑터 클래스이다.
- 파라미터로 MemoViewModel 클래스를 받는다.( 수정모드로 진입하게 위함이다. )
- 후에 binding 모델을 초기화하고, binding에서 input을 메모로 초기화해준다.
- 전역 변수에 lastEditIdx가 있는데, 이것은 마지막으로 수정을 누른 메모가 있다면 해당 메모의 수정 모드를 종료시키려는 용도이다.
구체적으로 말해본다면 lastEditldx가 초기화값으로 -1로 저장되어 있고, 이게 -1이 그대로 유지 되어 있을 경우 현재의 memoList의 indexOf() 값으로 할당하고 editMode를 true로 초기화하고 notifyItemChanged를 이용하여 positon을 초기화한다. -1이 아닐 경우에는 즉, lastEditldx가 처ㅇ - 버튼 리스너를 설정한다. ( 관련된 로직들은 조금 뒤에 할 MainActivity에 구현되어 있다. )
- 수정 버튼 : ViewModel의 changeMode() 메서드로 수정모드로 진입한다. 이렇게 하게 되면 메인 액티비티에서는 Observer를 통해 수정모드로 진입한 것을 알 수 있게 된다. (LiveData의 장점 발휘)
리사이클러뷰에서는 해당 메모의 editMode를 true로 바꿔서 해당 메모를 수정 중이라는 것을 UI에 보여준다.
lastEditldx가 -1이 아니라면, 다른 메모가 수정중일 수 있으므로 해당 메모의 editMode를 false로 바꿔준다. - 삭제 버튼 : ViewModel의 deleteMemoById() 메서드로 메모를 삭제해주고, 어댑터의 리스트에서도 자체적으로 삭제해준다.
후에, notifyItemRemoved(), notifyItemChanged()메서드로 해당 메모가 삭제된 것을 갱신시켜준다. - (수정) 완료 버튼 : ViewModel에서 changeMode() 메서드로 수정 모드에서 탈출한다. 그리고 ViewModel의 ModifyMemo() 메서드를 통해 해당 메모를 수정해준다.
리사이클러뷰에서도 업데이트 시켜주고 editMode도 false로 바꾼다.
그리고 수정 완료 후에는 해당 메모를 갱신해줄 필요가 없으므로 다시 lastEditldx를 -1로 수정해준다.
notifyItemChanged() 메서드로 해당 위치의 메모가 수정된 것을 알려준다.
- 수정 버튼 : ViewModel의 changeMode() 메서드로 수정모드로 진입한다. 이렇게 하게 되면 메인 액티비티에서는 Observer를 통해 수정모드로 진입한 것을 알 수 있게 된다. (LiveData의 장점 발휘)
2-5-2. Activity 설정
ViewModel을 호출하는 곳이며, 리사이클러뷰도 띄어지게 되고 리사이클러뷰에 띄어질 로직을 설정한다.
코드들을 살펴보자면,
- initViewModel() 메서드 : ViewModelFactory, ViewModel을 초기화한다.
- setUpObserver() 메서드 : ViewModel에서의 LiveData들에 대한 Observer를 여기서 셋팅해준다.
LiveData에 값이 들어오면 Log를 찍거나 UI를 변경시킨다.
메모를 불러온 후라면, 전역변수 memoList에 불러온 메모리스트들을 넣은 후에 리사이클러뷰를 셋팅한다.
메모를 입력한 후라면, 전역변수 memoList에 따로 메모를 넣어주고 입력하는 창을 초기화한다.
메모 수정 모드에 진입하면, 데이터 바인딩 변수인 isEditing을 isEdit 값(Boolean)을 통해 할당한다. 구체적으로 설명해보자면,
[ ( lastEditIdx는 전역변수 -1로 초기화 되어져 있는 값이다. )
수정 모드가 활성화 되었을 때(isEdit가 true일 때)
처음 어떤 아이템 위치의 수정모드를 클릭하여 들어가게 되면 lastEditIdx는 초기화 값 -1 변함이 없기 때문에 if(memoRecyclerViewAdapter.lastEditIdx != -1)에 들어가지 않는다. 그 후 lastEditIdx값은 lastEditIdx값을 클릭한 memolist의 아이템 위치로 초기화 시키고 클릭한 memolist의 아이템 위치에서의 수정 모드를 활성화시킨다. 그 후, 완료 버튼을 누르지 않고 다른위치에 있는 아이템 수정하기를 클릭을 하게 되면 lastEditIdx의 값은 전에 수정모드로 들어간 memolist의 아이템 위치로 할당되었기 때문에 -1이 아니게 된다. 그렇기에if(memoRecyclerViewAdapter.lastEditIdx != -1)에 들어가고 memoList의 lastEditIdx의 값 위치(처음에 수정모드로 들어갔던 아이템의 위치)의 editMode를 false를 바꾸게 되고, 또 다시 lastEditIdx의 값을 현재 해당하는 memoList의 아이템 위치로 할당하게 되고 memoList의 아이템 위치에서의 수정 모드를 활성시키는 로직이다. ]
메모 수정 및 삭제는 UI 변경 부분은 없고 로그만 찍는다.
리사이클러뷰 관련한 UI 수정도 여기서 모두 해준다. - getAllMemo() : 만들어둔 memoViewModel를 통해 가져오게 된다.
( Memo_Dao에 getAllMemo() 메서드는 매개변수는 없고 List 형식의 Memo클래스 타입을 반환한다. 그렇기에 MemoRepository에서 getAllMemo() = appDBInstance.getAllMemo()로 쓰고 ViewModel에서도 매개변수 없이 이용을 하고 List형식으로 저장된 memo 객체들을 가져온다. )
옵저버에 메모를 가져온 후 뭘 할 지 구현해놨기 때문에 가져오기만 하면 된다.
구현 플로우는 Acitivity -> ViewModel -> Repository -> Room DB 이므로 MVVM 디자인 패턴을 구현했다. - setUpRecyclerView() : 리사이클러뷰 어댑터, 레이아웃 매니저를 초기화하는 메서드이다.
- setUpBtinListener() : 버튼 리스너를 설정하는 메서드이다.
메모 입력 버튼 리스너만 설정(저장)해준다. 여기서 메모 입력 버튼 리스너는 insertMemo() 메서드이다. 이 메서드는 공백이 아닐경우 Memo 객체를 생성해 만들어둔 memoViewModel을 통해 메모를 삽입하게 된다. 삽입 후에는 Observer를 통해 UI가 다시 갱신된다.
■ 3. 오류 해결 ■
■ 3-1. DB Impl does not exist 오류
나는 코드를 작성하고 실행을 하는데 DB Impl does not exist 오류가 떴다.
오류를 찾아보니 DB에노테이션이 존재하는 추상 클래스를 코틀린이 상속받아 만들지 못했다는 이유다.
==> 이것을 해결하기 위해 kapt라는 의존성 관련된 설정을 추가해주면 되는것이였다.
(*kapt : 코틀린이 자바파일의 에노테이션을 만나면 참조하여 동작하는 내용)
즉, java 에노테이션을 코틀린에 적용을 할 때 생긴 오류라고 생각하면 될 것이다.
아래와 같이 설정을 해주어 해결을 하였다. ( app 수준의 gradle에서 plugins에서 id 'kotlin-kapt' 를 입력해주어 의존성을 주입하고 dependencies에서 kapt를 이용하여 room-compiler를 의존성 주입을 하여 오류를 해결한다.)
■ 3-2. Duplicate class android.support.v4. 오류
이 오류도 마찬가지로 코드를 작성하고 실행을 하려고 하는데 발생한 오류이다. 무슨 오류인가 찾아보니 dependencies 설정을 하다보면 위와 같은 오류를 접한다고 한다.
==> 이것에 대한 해결 방법은 gradle.properties 파일에 android.useAndroidX=true / android.enableJetifier=true 을 추가하면 된다.
■ 4. 주의할 점 ■
■ 4-1. MVVM의 ViewModel과 AAC의 ViewModel은 같다?
나는 다른 블로그들을 참고하여 간단하게 예제를 만들어보면서, ViewModel에 관해 의문이 들었다.
찾아보니 MVVM 디자인패턴에서의 ViewModel이 있고, AAC의 ViewModel이 있던 것 이다.
( *AAC(Android Architecture Components) : 테스트와 유지보수가 쉬운 앱을 디자인할 수 있도록 돕는 라이브러리 모음이며, Lifecycles, LiveData, ViewModel, Room, Paging을 포함하여 총 5개의 라이브러리로 구성되어 있다. )
■ 4-1-1. MVVM에서의 ViewModel이란
우선, MVVM 패턴의 목표에 대해 알아볼 필요가 있다. 목표는 비즈니스 로직과 프레젠테이션 로직을 UI로부터 분리하는 것이고 이렇게 하게 된다면 테스트, 유지 보수 측면에서 용이하다.
이러한 목표를 가지고 있는 MVVM 패턴에서의 ViewModel은 View에 연결될 데이터와 메서드를 구현하고, 상태가 변화하게되면 변경 알림 이벤트를 통해 View에게 상태 변화를 알려주고 View에서는 ViewModel을 알고 있지만, ViewModel을 알지 못한다.
일반적으로 ViewModel과 View는 1:n이기 때문에 View는 자신이 이용할 ViewModel을 선택하여 상태 변화 알림을 받게 되는데, ViewModel은 View가 쉽게 사용할 수 있도록 Model의 데이터를 가공하여 View에게 제공한다.
즉, 간단하게 MVVM에서의 ViewModel이란 View와 Model 사이에서 데이터를 관리하고 바인딩해주는 요소라고 생각하면 된다.
■ 4-1-2. AAC에서의 ViewModel이란
이것도 마찬가지로 AAC를 사용하려는 목표에 대해 알아볼 필요가 있다. AAC의 목표는 '안드로이드는 여러 컴포넌트들로 이루져 있지만, 생명주기가 다르게 엮어있다. 앱을 잘 만들기 위해선 이러한 컴포넌트들을 부드럽게 연결해야하는데 그러기 위해선 생명주기를 학습하고 엉키지 않게 해야하는데 이것에 도움을 주는 라이브러리가 AAC 인 것이다.
이러한 생명주기에 관련되어 도움을 주는 목표를 가지고 있는 AAC의 ViewModel은 Android의 수명 주기를 고려하여 UI 관련 데이터를 저장하고 관리하도록 설계되었다.
AAC ViewModel을 사용하게 되면 기존의 Activity가 데이터 관리 측면에서 생명 주기 때문에 겪던 어려움들을 간단하게 처리할 수 있다.
즉, Activity가 생성되고 파괴되기 전까지 ViewModel은 파괴되지 않고 유지하게 된다. 이러한 AAC ViewModel을 사용하는 곳을 말해본다면, 화면 회전을 하는 액션에서 쓰이며 회전을 했을 시 데이터가 파괴되는 것을 보존할 수 있게 된다.
■ 4-1-3. MVVM에서의 ViewModel vs AAC에서의 ViewModel
위에서 말한 각각의 쓰임에 대해 간단하게 말해보자면,
- MVVM의 ViewModel : View에 필요한 데이터를 관리하여 바인딩 해주고, 비즈니스 로직을 담당해 처리하는 요소
- AAC의 ViewModel : Android의 수명주기를 고려하여 UI 관련 데이터를 저장하고 관리하는 요소
==> MVVM의 ViewModel 과 AAC의 ViewModel의 역할을 보게되면 확실하게 다른 개념인 것을 확인 할 수 있다.
그렇지만 두 개의 ViewModel은 아예 관련이 없는 것은 아니다. 왜냐하면 AAC의 ViewModel을 통해서 MVVM 패턴의 ViewModel을 구현 할 수 있기 때문이다.
이것을 바꿔 말하게 되면 Android의 수명주기를 고려하여 UI 관련 데이터를 저장하고 관리하는것(AAC의 ViewModel)을 통해서 View에 필요한 데이터를 관리하여 바인딩 해주고, 비즈니스 로직을 담당해 처리하는 요소의 역할(MVVM의 ViewModel)을 충분히 만들 수 있다는 것으로 해석할 수 있다.
즉, AAC의 ViewModel을 MVVM의 ViewModel처럼 사용하기 위해서는 ObservableField나 LiveData등을 사용하여 데이터 바인딩을 해준다면 MVVM 패턴의 ViewModel처럼 사용할 수 있을 것이다.
참고
'디자인패턴(MVC,MVP,MVVM)' 카테고리의 다른 글
AAC의 ViewModel 이란? (0) | 2022.11.18 |
---|---|
LiveData 란 ? (0) | 2022.11.17 |
데이터 바인딩(Data binding) (0) | 2022.11.16 |
뷰 바인딩(view binding) (0) | 2022.11.16 |
MVC vs MVP vs MVVM (0) | 2022.11.10 |