SQLite 를 사용해서 데이터를 저장한다는 사실은 알았다. 이러한 SQLite 를 위한 라이브러리가 Room 이다. Room 은 SQLite 위에서 작동하며, SQLite 의 장점을 이용하는 동시에 코드를 더욱 간결하게 사용할 수 있게된다.
이러한 Room 을 이용하는 앱들은 보통 MVVM 구조를 갖춘 앱들이다. MVVM 이란 Model-View-ViewModel 을 줄인 말로써,
단어 추측 앱의 경우 View 와 ViewModel 이 적용되었지만, Model 의 개념이 적용되지는 않았다. 즉, 데이터베이스에 접근하여 정보를 저장하고 추츨하는 과정이 없었다.
앱 그레이들 스크립트에 추가
buildscript {
...
ext.lifecycle_version = "2.3.1"
ext.room_version = "2.4.0-beta01"
// m1 칩 맥북은 이 2.4.0-alpha03 이상의 버전을 이용해야한다.
}
모듈 그레이들 스크립트에 추가
...
buildFeatures {
dataBinding true
}
...
dependencies {
...
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
...
}
1. 테이블 구성 객체 : 데이터베이스의 테이블 구조를 담는 데이터 클래스
2. Dao 객체 : 데이터 베이스에 접근하는 함수를 정의한 객체
3. 데이터 베이스 객체 : 데이터 베이스 생성을 담당하는 객체
데이터 클래스를 정의하여 데이터 베이스에 등록하면 테이블이 하나 생성되는 것과 같다. 그곳에 데이터 클래스를 insert 하면 이는, row 가 하나 생성되는 것과 같다.
// 테이블 이름 특정하기
@Entity(tableName = "task_Table")
data class Task(
@PrimaryKey(autoGenerate = true) // pk 지정
@ColumnInfo(name = "id") // column 정보중 이름 지정
var taskId: Long = 0L,
@ColumnInfo(name = "task_name")
var taskName: String = "",
@ColumnInfo(name = "task_done")
var taskDone: Boolean = false
)
만약 Room 라이브러리가 특정 데이터 클래스를 저장하는걸 원치 않는다면, @Ignore 에노테이션을 붙이면 된다.
@Dao 에노테이션이 붙은 인터페이스를 정의하면 DAO 로서 동작한다.
@Dao
interface TaskDAO {
// Insert 에노테이션을 설정하면
// Room 라이브러리는 이 함수가 Task 데이터를 insert 하는데 필요한 모든 기능을 정의해둔다.
@Insert
suspend fun insert(task: Task)
// 여러 Task 를 insert 하는 함수도 가능
@Insert
suspend fun insertAll(tasks: List<Task>)
@Update
suspend fun update(task: Task)
@Delete
suspend fun delete(task: Task)
// 이외의 것들은 @Query 를 이용하면 된다.
@Query("SELECT * FROM task_table WHERE id = :taskId")
fun get(taskId: Long) : LiveData<Task>
@Query("SELECT * FROM task_table ORDER BY id DESC")
fun getAll(): LiveData<List<Task>>
}
모든 함수에 suspend 키워드를 붙여서 메인 스레드가 데이터베이스에 접근하는것을 방지했다. 코루틴을 이용하는 것으로, 이렇게 함으로써 메인쓰레드는 UI에 집중하도록 할 수 있다.
하지만 Room 라이브러리를 이용할때 라이브데이터를 반환하는 함수는 자동으로 백그라운드 스레드를 이용하게 되므로 suspend 를 명시해줄 필요가 없다.
추상 클래스를 이용헤서 데이터 베이스를 정의한다. 이 ㅋㄹ래스는 데이터 베이스의 이름, 버전, 데이터 베이스를 정의하는 클래스와 인터페이스 정보가 담겨야한다.
// 만약 데이터 베이스 스키마를 변경한다면 version 숫자를 변경해야한다.
// exportSchema 데이터베이스 스키마를 폴더로 저정해서 히스토리를 볼 수 있게 할 것인지.
@Database(entities = [Task::class], version = 1, exportSchema = false)
abstract class TaskDatabase: RoomDatabase() {
// DAO 인터페이스를 지정
abstract val taskDao: TaskDAO
}
그리고 데이터베이스 객체를 인스턴스화 하여 반환해야 한다.
@Database(entities = [Task::class], version = 1, exportSchema = false)
abstract class TaskDatabase: RoomDatabase() {
abstract val taskDao: TaskDAO
companion object{
@Volatile
private var INSTANCE : TaskDatabase? = null
fun getInstance(context: Context): TaskDatabase {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
TaskDatabase::class.java,
"task_database"
).build()
INSTANCE = instance
}
return instance
}
}
}
companion object 를 이용하여 TaskDatabase 클래스를 인스턴스화 하지 않아도 getInstance() 함수를 호출할 수 있도록 하였다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView 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:id="@+id/fragmentContainerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:name="com.example.room.TasksFragment"
tools:context=".MainActivity">
</androidx.fragment.app.FragmentContainerView>
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<data>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<EditText
android:id="@+id/task_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:hint="Enter a task name"/>
<Button
android:id="@+id/save_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Save Task" />
<TextView
android:id="@+id/task"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
</layout>
class TaskViewModel(val dao: TaskDAO): ViewModel() {
var newTaskName = ""
// 뷰 모델에서 코루틴을 이용할때는 viewModelScope.launch {..} 를 이용한다.
fun addTask() {
viewModelScope.launch {
val task = Task()
task.taskName = newTaskName
dao.insert(task)
}
}
}
클래스 생성자가 인자를 받기 때문에 디폴트 뷰모델 프로바이더로는 생성할 수 없다. 뷰모델 팩토리를 만들어야 한다.
class TaskViewModelFactory(private val dao: TaskDAO)
: ViewModelProvider.Factory{
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(TaskViewModel::class.java)) {
return TaskViewModel(dao) as T
}
throw IllegalArgumentException("Unknown ViewModel")
}
}
class TasksFragment: Fragment() {
private var _binding: FragmentTaskBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentTaskBinding.inflate(inflater, container, false)
val view = binding.root
val application = requireNotNull(this.activity).application
val dao = TaskDatabase.getInstance(application).taskDao
val viewModelFactory = TaskViewModelFactory(dao)
val viewModel = ViewModelProvider(this, viewModelFactory)
.get(TaskViewModel::class.java)
binding.viewModel = viewModel
return view
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<data>
<variable
name="viewModel"
type="com.exmaple.room.TaskViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<EditText
android:id="@+id/task_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:hint="Enter a task name"
android:text="@={viewModel.newTaskName}"/>
<!-- @={} 는 레이아웃에서 viewModel 을 업데이트 할 수 있음을 의미한다. -->
<!-- 단순히 코드상에서 변경되는 값을 레이아웃에 반영하는 것은 @{viewModel.newTaskName} 을 이용한다.-->
<Button
android:id="@+id/save_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Save Task"
android:onClick="@{() -> viewModel.addTask()}"/>
<TextView
android:id="@+id/task"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
</layout>
Dao 언터페이스에서 리턴타입으로 라이브 데이터를 썼다.
@Dao
interface TaskDao {
...
@Query("SELECT * FROM task_table WHERE taskId = :key")
fun get(key: Long): LiveData<Tasks>
@Query("SELECT * FROM task_table ORDER BY taskId DESC")
fun getAll(): LiveData<List<Tasks>>
}
리턴 타입으로 라이브 데이털르 쓴 함수는 한번 호출되고 나면 항상 데이터베이스를 최신으로 유지하려고 한다. 위의 예제를 예시로 한면, 새로운 Task 가 추가될 때마다 getAll() 은 항상 추가된 값을 가져온다는 것이다.
그런데 그동안 써온 라이브데이터는 LiveData<Data>
식이었는데, 이번에는 라이브 데이터가 List<Data>
를 감싸고 있다. 이러한 데이터 타입을 바로 뷰에 결합시킬 수 있을까? 그건 아무래도 힘들 것이다.
이럴때는 LiveData<List<Task>>
형 데이터를 LiveData<String>
형 데이터로 변경해 주면 뷰에 매핑하기 좋게된다.
이러한 LiveData 내부의 데이터 타입을 바꾸는 작업은 Transformations 클래스의 map 객체를 이용할 수 있다.
map 함수는 LiveData 를 source 파라미터로 받고, source에 mapFuntion 이라는 임의의 함수를 적용시킨 뒤, 그 결과값을 LiveData 형태로 반환하는 함수이다.
@MainThread
@NonNull
open static fun <X : Any!, Y : Any!> map(
@NonNull source: LiveData<X>,
@NonNull mapFunction: Function<X, Y>
): LiveData<Y>
mapFunction 이 파라미터에 List<Task>
의 요소를 하나씩 빼내서 원하는 String 으로 바꿔서 반환하는 함수를 넘겨주면 된다. (리사이클러 뷰를 사용하지 않고 TextView 에서 하나의 String 으로 처리할 것이기 때문이다.)
List 에 있는 값들을 하나씩 빼서 조작하는 방법은 여러가지가 있겠지만, 별도의 파라미터 설정 없이 람다 안에서 처리하기에는 fold 와 reduce 를 이용하는 것이 적절하다.
public inline fun <T, R> Iterable<T>.fold(initial: R, operation: (acc: R, T) -> R): R {
var accumulator = initial
for (element in this) accumulator = operation(accumulator, element)
return accumulator
}
fold 와 reduce 함수는 모두 acc 라는 누적 파라미터에 T 를 처리해서 더하고 누적된 파라미터를 리턴하는 형태를 갖고있다. 둘의 차이는 fold 는 최초 누적 파라미터에 값을 초기화하고 시작한다는 것이고, reduce 는 이러한 initial 파라미터의 역할을 list[0] 요소가 대신한다는 데 있다.
(내부적으로 iterator 를 쓰느냐 쓰지 않느냐 등의 추가적인 차이가 있지만, 피상적으로 함수만 사용한다면 다 알필요는 없을 것 같다.)
class TasksViewModel(val dao: TaskDao): ViewModel() {
...
private val tasks = dao.getAll()
val tasksString = Transformations.map(tasks) {
tasks -> formatTasks(tasks)
}
private fun formatTasks(tasks: List<Tasks>): String {
return tasks.fold("") {
str, item -> str + '\n' + formatTask(item)
}
}
private fun formatTask(tasks: Tasks): String {
var str = "ID: ${tasks.taskId}"
str += '\n' + "Name: ${tasks.taskName}"
str += '\n' + "Complete: ${tasks.taskDone}" + '\n'
return str
}
레이아웃과
...
<TextView
android:id="@+id/tasks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{viewModel.tasksString}" />
</LinearLayout>
프레그먼트에 코드 추가
binding.lifecycleOwner = viewLifecycleOwner
안드로이드 스튜디오(버전 4.1 이상)는 데이터베이스에 어떤 테이블이 있는지, 어떤 리코드가 있는지 확인할 수 있는 기능을 제공한다.
이를 이용하기 위해서는 API 버전 26이상으로 돌아가는 에뮬레이터에서 앱을 실행시키고, view -> ToolWindows -> App Inspection 을 키고, 실행중인 앱 프로세스를 드롭다운 메뉴에서 선택한다. 그러면 실행중인 앱의 데이터베이스가 창에 나타난다.
이렇게 나타난 창에 특정 셀을 더블 클릭하고 새로운 값을 입력, Enter 를 누르면 데이터를 수정할 수 있다. 만약 이때 라이브데이터를 사용중이라면 실행중인 앱에 바로 적용된다.
Database Inspector를 사용하여 앱이 실행되는 동안 앱의 데이터베이스에서 맞춤 SQL 쿼리를 실행할 수도 있다.
(1) open new query 탭을 클릭하고
(2) 쿼리를 입력하고 Run 을 누르면 실핼 결과가 나온다.