본문 바로가기
Android/Jetpack Compose App

JETPACK COMPOSE: 노트 앱 만들기 - 3, ViewModel, Android ROOM, Dependency Injection(DI), Hilt & Dagger, Coroutine

by 개발자J의일상 2022. 4. 6.
반응형

지난 시간 리뷰

 

JETPACK COMPOSE: 노트 앱 만들기 - 2, ViewModel, Android ROOM, Dependency Injection(DI), Hilt & Dagger, Coroutine

 

JETPACK COMPOSE: 노트 앱 만들기 - 2, ViewModel, Android ROOM, Dependency Injection(DI), Hilt & Dagger, Coroutine

지난 시간 리뷰 JETPACK COMPOSE: 노트 앱 만들기 - 1, ViewModel, Android ROOM, Dependency Injection(DI), Hilt & Dagger, Coroutine JETPACK COMPOSE: 노트 앱 만들기 - 1, ViewModel, Android ROOM, Dependen..

mypark.tistory.com

 

지난 시간에는 DI와 Hilt and Dagger 그리고 ROOM에 대해 살펴보았습니다.

 

이번 시간에는 DAO에 대해 살펴보려고 합니다.

 

DAO데이터액세스 객체(Data Access Object)로 SQL 쿼리를 지정하여 메서드 호출과 연결합니다.

컴파일러는 SQL을 확인하고 @Insert와 같은 일반 쿼리의 어노테이션으로 쿼리를 생성합니다.

 

DAO는 interface 또는 abstract class여야 합니다.

 

기본적으로 모든 쿼리는 별도의 스레드에서 실행되어야 하는데 Room에서는 Kotlin Coroutine(코루틴)을 지원합니다. 

쿼리를 suspend 수정자로 처리하고 코루틴이나 다른 정지 함수에서 호출할 수 있습니다.

 

아래 코드는 NoteDatabaseDao라는 인터페이스입니다.

package com.example.jetnote.data

import androidx.room.*
import com.example.jetnote.model.Note
import kotlinx.coroutines.flow.Flow

@Dao
interface NoteDatabaseDao {
    @Query("SELECT * from notes_table")
    fun getNotes(): Flow<List<Note>>

    @Query("SELECT * from notes_table where id=:id")
    suspend fun getNoteById(id: String): Note

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(note: Note)

    @Update(onConflict = OnConflictStrategy.REPLACE)
    suspend fun update(note: Note)

    @Query("DELETE from notes_table")
    suspend fun deleteAll()

    @Delete
    suspend fun deleteNote(note: Note)
}
  • @Dao 어노테이션은 이 interface를 Room의 DAO 클래스로 식별합니다.
  • suspend fun getNoteById(id: String): Note => id를 가지고 노트를 찾아서 반환하는 suspend 함수를 선언합니다. 코루틴 내에서 suspend fun으로 만들면 일시 중단 작업을 수행하게 됩니다. 일시 중단을 하는 이유는 다른 동작이 같이 수행 중이면 충돌이 나거나 데이터가 도중에 변하기 때문에 이러한 것을 막기 위함입니다. 자세한 것은 thread의 개념을 이해해야 하기 때문에 넘어가겠습니다.
  • @Insert 어노테이션은 SQL을 제공하지 않아도 되는 특수 DAO 메서드입니다. 행을 삭제하고 업데이트하는 @Delete@Update도 있습니다.
  • onConflict = OnConflictStrategy.IGNORE : 선택된 onConflict 전략은 이미 목록에 있는 Note와 정확하게 같다면 새 단어를 무시합니다. 여기서 쓰인 REPLACE는 새 단어로 대체하겠다는 뜻입니다.
  • suspend fun deleteAll() : 저장된 모든 Note를 제거하는 정지함수입니다.
  • getNotes() : Flow<List<Note>> Flow는 코루틴의 데이터 스트림이며, 코루틴 상에서 리액티브 프로그래밍을 지원하기 위한 요소입니다. Flow에 대한 설명은 해당 홈페이지 참고 : https://kotlinworld.com/175

Jetpack Compose에서는 데이터가 바뀌면 UI를 update 해주려면 mutableState라를 것을 사용해서 변화가 되면 UI가 변경되는 형태였습니다. 이를 데이터의 생산자 소비자의 관점으로 보면 노트는 생산자가 되고 변화해야 되는 UI는 소비자가 됩니다. 이를 지원하는 것이 Flow라고 생각하면 쉽게 다가올 것 같습니다.

 

Flow는 값의 비동기 시퀀스입니다.
Flow는 네트워크 요청이나 데이터베이스 호출, 기타 비동기 코드 등의 비동기 작업에서 값을 생성할 수 있는 값을 한 번에 모두가 아니라 한 번에 하나씩 생성합니다. API 전체에서 코루틴을 지원하므로 코루틴을 사용하여 흐름도 변환할 수 있습니다.

 

이제 Room 데이터베이스를 추가해 봅시다.

 

Room 데이터베이스는 

  • Room은 SQLite 데이터베이스 위에 있는 데이터베이스 레이어
  • Room은 개발자가 SQLiteOpenHelper를 사용하여 처리하던 일반적인 작업을 처리
  • Room은 DAO를 사용하여 데이터베이스에 쿼리를 실행
  • 기본적으로 UI 성능 저하를 방지하기 위해 Room에서는 기본 스레드에서 쿼리를 실행할 수 없음, Room 쿼리가 Flow를 반환하면 쿼리는 자동으로 백그라운드 스레드에서 비동기식으로 실행됨
  • Room은 SQLite 문의 컴파일 시간 확인을 제공함

Room 데이터 베이스는 추상 클래스이고 RoomDatabase를 확장해야 합니다.

일반적으로 전체 앱에 RoomDatabase 인스턴스가 하나만 있으면 됩니다.

 

package com.example.jetnote.data

import androidx.room.Database
import androidx.room.RoomDatabase
import com.example.jetnote.model.Note

@Database(entities = [Note::class], version = 1, exportSchema = false)
abstract class NoteDatabase: RoomDatabase() {
    abstract fun noteDao(): NoteDatabaseDao
}
  • NoteDatabase는 abstract 클래스이고 RoomDatabase를 확장하고 있습니다.
  • NoteDatabase가 Room 데이터베이스가 되도록 @Database 어노테이션을 작성하고 어노테이션에 매개변수를 사용하여 데이터베이스에 속한 항목을 선언하고 버전 헌호를 설정합니다. 각 항목은 데이터베이스에 만들어질 테이블에 상응합니다. exportSchema는 빌드 경고를 피하기 위해 false로 설정하였습니다. 실제 앱에서는 현재 스키마 버전 제어 시스템으로 확인할 수 있도록 스키마를 내보내는 데 사용할 Room 디렉터리를 설정하는 것이 좋습니다.
  • 데이터베이스는 각 @Dao의 추상 'getter' 메서드를 통해 DAO를 노출합니다. 여기서는 noteDao를 통해 NoteDatabaseDao에 접근이 가능합니다. 
@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Singleton
    @Provides
    fun provideNotesDao(noteDatabase: NoteDatabase) : NoteDatabaseDao
        = noteDatabase.noteDao()

    @Singleton
    @Provides
    fun provideAppDatabase(@ApplicationContext context: Context) : NoteDatabase
        = Room.databaseBuilder(
            context,
            NoteDatabase::class.java,
            "notes_database")
        .fallbackToDestructiveMigration()
        .build()
}

데이터베이스의 여러 인스턴스가 동시에 열리는 것을 막기 위해 NoteDatabase를 Singleton으로 정의했습니다.

 

provideAppDatabase는 싱글톤을 반환합니다. 

처음 액세스 할 때 데이터베이스를 만들어 Room의 데이터베이스 빌더를 사용하여 NoteDatabase 클래스의 Application context에 RoomDatabase 객체를 만들고 이름을 "notes_database"로 지정합니다.

 

 

다음으로 Repository(저장소)를 만들어보겠습니다.

 

저장소 클래스는 여러 데이터 소스 액세스를 추상화합니다. 

저장소는 아키텍처 구성요소 라이브러리의 일부는 아니지만 코드 분리와 아키텍처를 위한 권장사항입니다.

저장소 클래스는 나머지 애플리케이션의 데이터에 액세스를 위한 깔끔한 API를 제공합니다.

저장소를 사용하는 이유저장소는 쿼리를 관리하고 여러 백엔드를 사용하도록 허용합니다. 가장 일반적으로 저장소는 데이터를 네트워크에서 가져올지 또는 로컬 데이터베이스에 캐시 된 결과를 사용할지 결정하는 로직을 구현합니다.

 

NoteRepository라는 Kotlin 클래스를 만들고 @Inject를 하고 constructor에서 private property로 NoteDatabaseDao를 전달받습니다.

package com.example.jetnote.repository

import com.example.jetnote.data.NoteDatabaseDao
import com.example.jetnote.model.Note
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject

class NoteRepository @Inject constructor(private val noteDatabaseDao: NoteDatabaseDao){
    suspend fun addNote(note: Note) = noteDatabaseDao.insert(note)
    suspend fun updateNote(note: Note) = noteDatabaseDao.update(note)
    suspend fun deleteNote(note: Note) = noteDatabaseDao.deleteNote(note)
    suspend fun deleteAllNote() = noteDatabaseDao.deleteAll()
    fun getAllNotes(): Flow<List<Note>>  = noteDatabaseDao.getNotes().flowOn(Dispatchers.IO)
        .conflate()
}
  • DAO는 전체 데이터베이스가 아닌 저장소 생성자에 전달됩니다. DAO에 데이터베이스의 모든 읽기/쓰기 메서드가 포함되어 있으므로 DAO 액세스만 필요하고 전체 데이터베이스를 Repository에 노출할 필요가 없습니다.
  • suspend modifier는 코루틴이나 다른 정지 함수에서 이를 호출해야 한다고 컴파일러에게 알립니다.
  • Room은 기본 스레드 밖에서 정지 쿼리를 실행합니다.

이제 ViewModel을 구현해 봅시다.

 

ViewModel이란? 

ViewModel의 역할은 UI에 데이터를 제공하고 구성 변경에도 유지되는 것입니다. ViewModel은 저장소와 UI 간의 통신 센터 역할을 합니다. ViewModel을 사용하여 프래그먼트 간에 데이터를 공유할 수도 있습니다. 

ViewModel은 수명 주기 라이브러리의 일부입니다.

ViewModel은 수명 주기를 고려하여 구성 변경에도 유지되는 앱의 UI 데이터를 보유합니다.

앱의 UI 데이터를 Activity 및 Fragement 클래스에 분리하면 단일 책임 원칙을 더 잘 준수할 수 있습니다.

활동과 프래그먼트는 화면에 데이터를 그리는 것을 담당하지만 ViewModel은 UI에 필요한 모든 데이터를 보유하고 처리할 수 있습니다.

 

아래 코드는 지난 시간에 구현했던 코드입니다. 

package com.example.jetnote.screen

import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import com.example.jetnote.data.NotesDataSource
import com.example.jetnote.model.Note

class NoteViewModel : ViewModel() {
    private var noteList = mutableStateListOf<Note>()

    init {
        noteList.addAll(NotesDataSource().loadNotes())
    }
    fun addNote(note: Note) {
        noteList.add(note)
    }

    fun removeNote(note: Note) {
        noteList.remove(note)
    }

    fun getAllNotes(): List<Note> {
        return noteList
    }
}

위의 코드를 수정해 봅시다.

ViewModel을 상속하는 것은 똑같지만 

@Inject와 NoteRepository를 생성자로 받는 것, @HiltViewModel이 추가되었습니다.

package com.example.jetnote.screen

import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetnote.data.NotesDataSource
import com.example.jetnote.model.Note
import com.example.jetnote.repository.NoteRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class NoteViewModel @Inject constructor(private val repository: NoteRepository) : ViewModel() {
    private val _noteList = MutableStateFlow<List<Note>>(emptyList())
    val noteList = _noteList.asStateFlow()
    //private var noteList = mutableStateListOf<Note>()

    init {
        viewModelScope.launch(Dispatchers.IO) {
            repository.getAllNotes().distinctUntilChanged()
                .collect { listOfNotes ->
                    if (listOfNotes.isNullOrEmpty()) {
                        Log.d("Empty", ": Empty list")
                    } else {
                        _noteList.value = listOfNotes
                    }
                }
        }
        //noteList.addAll(NotesDataSource().loadNotes())
    }
    fun addNote(note: Note) = viewModelScope.launch { repository.addNote(note) }
    fun updateNote(note: Note) = viewModelScope.launch { repository.updateNote(note) }
    fun removeNote(note: Note) = viewModelScope.launch { repository.deleteNote(note) }
    fun removeAllNote() = viewModelScope.launch { repository.deleteAllNote() }
}

MutableStateFlow<List<Note>>를 private인 _noteList로 받고 공유하는 것은 실제로 noteList가 됩니다. 

noteList는 _noteList.asStateFlow()로 받아오게 됩니다. (캡슐화)

asStateFlow는 읽기 전용 State flow로 나타내기 위함입니다.

 

나머지 함수들은 viewModelScope.launch를 통해 호출이 됩니다. (CoroutineScope 내에서 실행)

 

빌드 에러가 발생했었는데 annotationProcessor가 아닌 kapt로 해야 실행 시 에러가 발생하지 않습니다.

Build -> Clean Project, Build -> Rebuild Project를 다시 해주시면 정상 동작됩니다.

dependencies {
    //Hilt-Dagger
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-compiler:$hilt_version"

    //Room
    implementation "androidx.room:room-runtime:$room_version"
    //annotationProcessor "androidx.room:room-compiler:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    kapt("androidx.room:room-compiler:$room_version")

 

이것으로 종료가 되어도 값이 저장되어 있는 note app을 만들어 보았습니다.

Android Room과 Coroutine에 대해서 공부가 더 필요하다는 생각이 들었습니다.

 

아직 완벽히 숙달이 된 것이 아니라 좀 더 들여다보고 연구를 해봐야겠습니다!

 

감사합니다.

 

300x250

댓글