이번에는 지난 포스팅들에서 다뤘던 MVVM 패턴과 AAC의 ROOM, LiveData를 적용할 예제 실습을 해볼 것이다.
어떤 구조로 만들어야 하는지 알아보기 위해 참조의 블로그의 예제와 codelab을 참고한 실습이다.
[ 앞으로 할 작업의 개요 ]
- LiveData : 관찰할 수 있는 데이터 홀더 클래스 - 수명 주기를 인식하는 컴포넌트로 항상 최신 버전의 데이터를 보유/캐시하고 데이터가 변경된 경우 Observer에게 알림
- ViewModel : 저장소(데이터)와 UI 간의 통신 센터 역할 - ViewModel 인스턴스는 Activity/Fragment 재생성이도 유지
- Room : 데이터베이스 작업을 간소화하고 기본 SQLite 데이터베이스의 엑세스 포인트 역할
- Entity : Room 작업 시 데이터베이스 테이블을 설명하는 주석처리된 클래스
- DAO : 데이터 엑세스 객체 - SQL 쿼리를 함수에 매핑하며 DAO를 사용할 때 메서드를 호출하면 Room에서 알아서 처리함
[ 프로젝트 디렉토리 ]
아직 코드를 다 완성하지는 않았지만, 현재 디렉토리의 모습은 이렇다. 실습을 진행하기 전에 어떤 구조로 만들어야 할지에 대한 고민이 많았는데 아키텍처를 사용하지 않았던 과거에는 화면과 기능으로 패키지를 나눴다면 지금은 최대한 기능에 초점을 맞추어 나눠보았다. 구글링을 하다가 나와 비슷한 고민을 하고 있던 분의 글을 읽게 되었는데, 디렉토리 구조에 정답은 없다는 답변을 받았다고 했다. 그래서 지금 나도 긴가민가 하고 있지만 이렇게 만들어 두었다. 더 좋은 디렉토리 구조가 있다면 알려주세요! 😇😇
1. Dependency 추가
Room과 LiveData를 사용하기 위한 dependency를 추가한다.
🧐 kotlin-kapt 란?
kapt는 Kotlin Annotation Processing의 약자로 Java 6부터 도입되었고,
Pluggable Annotation Processing API를 kotlin에서도 사용가능하게 된 것이다.
→ kotlin에서 자바의 Annotation을 지원받기 위해 사용하는 것으로 build.gralde 플러그인에 추가하면 된다.
plugins {
...
id 'kotlin-kapt'
}
dependencies {
...
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"
kapt 'android.arch.persistence.room:compiler:1.1.1'
def lifecycleVersion = "2.2.0"
// Lifecycle components
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
def coroutines = "1.3.9"
// Kotlin components
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
}
2. Model 생성 - Room
(1) Entity 생성
@Entity(tableName = "contact")
data class Contact (
@PrimaryKey(autoGenerate = true) var id: Long?,
@ColumnInfo(name = "name") var name: String,
@ColumnInfo(name = "number") var number: String,
@ColumnInfo(name = "initial") var initial: Char ) {
constructor(): this(null, "", "", '\u0000')
}
- @Entity 클래스는 SQLite 테이블을 나타냄
- 클래스 이름과 테이블 명을 다르게 하고 싶다면 tableName 속성으로 명시하면 됨(명시하지 않은 경우, 클래스 명과 동일)
- @PrimaryKey : 기본키
- @ColumnInfo : 테이블의 열명(attribute)
(2) DAO(Data Access Object) 생성
@Dao
interface ContactDAO {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(contact: Contact)
@Delete
suspend fun delete(contact: Contact)
@Query("SELECT * FROM contact ORDER BY name ASC")
fun getAll(): Flow<List<Contact>>
}
- DAO는 인터페이스/추상 클래스여야 함
- 기본적으로 모든 쿼리는 별도의 스레드에서 실행되어야 함
- 코루틴의 suspend 정지 함수로 선언하여 background에서 동작하도록 만들어 줌
- 메인 스레드에서 접근하려고 하면 crash 발생
- 기본적으로 제공하는 CRUD의 어노테이션을 사용하여 쿼리를 대체할 함수 생성
- onConflict = OnConflictStrategy.REPLACE : 충돌 전략(이미 목록에 있는 데이터와 중복된 데이터를 처리할 방법) - IGNORE, REPLACE, ROLLBACK, FAIL, ABORT가 있음
🧐 데이터베이스 변경 사항 관찰? kotlinx-coroutines의 Flow를 사용하자!
Flow는 네트워크 요청이나 데이터베이스 호출, 기타 비동기 코드 등의 비동기 작업에서 값을 한번에 하나씩 생성한다!
나중에 Flow를 ViewModel의 LiveData로 변환한다 (이어지는 포스팅에서 확인!)
(3) Room Database 생성
SQLite 데이터베이스 위에 있는 데이터 베이스 레이어!
SQLiteOpenHelper를 사용하여 처리하던 일반적인 작업을 처리하며 DAO를 사용하여 쿼리를 실행한다.
또한 위에서도 설명했지만, UI 성능 저하를 방지하기 위해 기본 스레드에서 처리할 수 없다!
@Database(entities = [Contact::class], version = 1)
abstract class ContactDatabase: RoomDatabase() {
abstract fun contactDao(): ContactDAO
// 데이터베이스 인스턴스를 싱글톤으로 사용하기 위해 companion object
companion object {
private var INSTANCE: ContactDatabase? = null
// 여러 스레드가 접근하지 못하도록 synchronized로 설정
fun getDatabase(context: Context): ContactDatabase? {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ContactDatabase::class.java,
"contact"
).build()
INSTANCE = instance
instance
}
}
}
}
- Room 데이터베이스 클래스는 추상 클래스여야 하며 RoomDatabase를 확장해야 함
- 일반적으로 전체 앱에 Room 데이터베이스 인스턴스가 하나만 있으면 됨(싱글톤) : companion object
- getDatabase는 싱글톤을 반환 : 처음 액세스할 때 DB를 만들어서 Room 데이터베이스 빌더를 사용하여 ContactDatabase 클래스의 application context에서 contact라는 이름의 RoomDatabase 객체를 만듦
3. Repository 생성
💡 Repository란?
여러 데이터 소스 액세스를 추상화한 클래스!
(AAC는 아니지만 코드 분리와 아키텍처를 위한 권장사항으로 깔끔한 API를 제공)
쿼리를 관리하고 여러 백엔드를 사용하도록 허용함. 가장 일반적으로는 데이터를 네트워크에서 가져올지 로컬 DB에서 캐시된 결과를 사용할지 결정하는 로직을 구현할 수 있음!
// DAO에 대한 액세스만 필요하기 때문에 DAO만 프로퍼티로 선언
class ContactRepository(private val contactDao: ContactDAO) {
val allContacts: Flow<List<Contact>> = contactDao.getAll()
// 기본적으로 Room은 main thread에서 suspend 쿼리를 실행함
// 따라서 DB 작업을 main thread에서 오래 실행하지 않도록 하는 다른 구현 필요 없음
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun insert(contact: Contact) {
contactDao.insert(contact)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun delete(contact: Contact) {
contactDao.delete(contact)
}
}
- DAO에 데이터베이스의 모든 읽기/쓰기 메서드가 있기 때문에 DAO 액세스만 필요하다 → 전체 데이터베이스를 노출할 필요X
- allContacts는 Room에서 LiveData 목록을 가져와서 초기화
- Room은 main thread 밖에서 정지 쿼리를 실행함
4. ViewModel 생성
💡 ViewModel이란?
- 역할 : UI에 데이터를 제공하고 구성 변경에도 유지되는 것 → repository와 UI 간의 통신 센터 역할
- 사용 이유 : ViewModel은 lifecycle을 고려하여 구성 변경에도 유지되는 앱의 UI 데이터를 보유함. 따라서 앱의 UI 데이터를 Activity나 Fragment 클래스에서 분리함으로써 단일 책임 원칙을 잘 준수할 수 있음
🔮 Flow에서 LiveData로 변경
LiveData는 관찰 가능한 데이터 홀더로 데이터가 변경될 때마다 알림을 받을 수 있는 Lifecycle을 인식하는 컴포넌트
→ UI에서 사용하거나 표시하는 변경 가능한 데이터에 적절한 컴포넌트(DB의 데이터가 변경될 때마다 UI 자동 업데이트)
LiveData와 Flow의 차이는 Lifecycle을 인식할 수 있는지 없는지!
class ContactViewModel(private val repository: ContactRepository): ViewModel() {
// 수명 주기를 인식하는 LiveData로 변경
private val contacts: LiveData<List<Contact>> = repository.allContacts.asLiveData()
fun insert(contact: Contact) = viewModelScope.launch {
repository.insert(contact)
}
fun delete(contact: Contact) = viewModelScope.launch {
repository.delete(contact)
}
}
class ContactViewModelFactory(private val repository: ContactRepository): ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ContactViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return ContactViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel Class")
}
}
- ContactRepository를 매개변수로 가져오는 ViewModel를 확장하는 클래스 생성
- repository의 allContacts(Flow)를 사용하여 LiveData를 초기화하고 asLiveData()로 Flow→LiveData 변환
- repository의 insert(), delete() 메서드를 호출하는 래퍼 메서드를 만듦 → 새 코루틴을 실행하고 repository의 정지 함수 호출
- ❌ ViewModel보다 수명 주기가 짧은 context는 참조하면 안됨 : Activity, Fragment, View의 Context를 사용한다면 메모리 leak가 발생할 수 있음
다음 포스팅에서 이어집니다!
LiveData에 대한 자세한 내용은 아래 포스팅 참고!
ViewModel에 대한 자세한 내용은 아래 포스팅 참고!
reference >
'ANDROID > Android Jetpack' 카테고리의 다른 글
[Android/Jetpack] AAC - WorkManager (Persistent Background) (0) | 2022.01.27 |
---|---|
[Android/Jetpack] AAC - Paging (0) | 2022.01.19 |
[Android/Jetpack] Recyclerview Adpater 대신 ListAdapter 적용하기 (0) | 2021.09.27 |
[Android/Jetpack] Room + LiveData + ViewModel : 코루틴을 이용한 예제(2) (0) | 2021.09.25 |
[Android/Jetpack] AAC - Data Binding (0) | 2021.08.11 |
[Android/Jetpack] AAC - Room (0) | 2021.08.03 |
[Android/Jetpack] AAC - ViewModel (0) | 2021.07.27 |
[Android/Jetpack] AAC - LiveData (0) | 2021.07.21 |
댓글