[Android/Kotlin] 도서 리뷰 앱 - Android Room 사용
저번글에서 editText에 키워드를 입력하고 엔터를 누르면 검색하는 것까지 구현했다
이번에는 Android Room을 이용해 검색 키워드를 저장해보자
Android Room이란?!
Room은 SQLite에 대한 추상화 레이어를 제공하여 개발자가 편리하게 로컬DB에 접근할 수 있도록
해주는 Jetpack 라이브러리이다
Room은 AAC(Android Architecture Components), 스마트폰 내장 DB에 데이터를 저장하기 위해 사용하는 라이브러리이고 ORM(Object Relational Mapping)라이브러리로서 DB 데이터를 Java 또는 코틀린 객체로 매핑해준다.
SQLite를 내부적으로 사용하고 있지만, DB를 구조적으로 분리해 데이터 접근의 편의성을 높여주고 유지보수에 편리하다.
다양한 Annotation을 통해 컴파일시 코드들을 자동으로 만들어주며 LiveData, RxJava와 같은 Observation 형태를 지원하고 MVP, MVVM과 같은 아키텍쳐 패턴에 쉽게 활용할 수 있도록 되어있다.
Room을 권장하는 이유?!
SQlite는 쿼리의 컴파일 시간 검증을 할 수 없다. 그러나 Room에는 컴파일 타임에 SQL 유효성 검사가 있다.
그리고 SQLite는 스키마가 변경되면 영향을 받는 SQL 쿼리를 수동으로 업데이트해야 한다.
또한, SQL 쿼리와 Java 데이터 개체 간에 변환하려면 많은 상용구 코드를 사용해야 한다.
그러나 Room은 상용구 코드 없이 데이터베이스 데이터를 Java 또는 Kotlin 객체에 매핑할 수 있다.
Room은 데이터 관찰을 위해 LiveData 및 RxJava와 함께 작동하도록 구축되었지만 SQLite는 그렇지 않다.
게다가 Room은 SQLite에서는 되지않던 기능들을 사용할 수 있게 되어 내부 DB를 좀더 간편하게 구현할 수 있다.
Room에서 허용된 기능
- 컴파일 도중 SQL에 대한 유효성 검사 가능
- Schema가 변경될 시 자동으로 업데이트 가능
- Java 데이터 객체를 변경하기 위해 상용구 코드 없이 ORM 라이브러리를 통해 매핑 가능
- LiveData와 RX Java를 위한 Observation 생성 및 동작 가능
Room의 구성요소
- Room Database
- Entities
- Data Access Objects
- build.gradle
Room을 사용하기 위해 build.gradle파일에 종속항목을 추가한다
plugins {
id 'kotlin-kapt'
}
dependencies {
...
implementation 'androidx.room:room-runtime:2.4.2'
kapt 'androidx.room:room-compiler:2.4.2'
...
}
- History.kt
우선 History 데이터를 정의할 History.kt을 만들어보자
@Entity
data class History(
@PrimaryKey val uid: Int?,
@ColumnInfo(name = "keyword") val keyword: String?
)
검색기록은 정수형 id와 문자열 keyword로 정의하였다
- HistoryDao
다음은 데이터를 엑세스하기 위한 Dao를 정의해주어야한다
필요한 함수는 1) 모든 기록 가져오기 2) 기록 추가 3) 기록 삭제
이렇게 3개가 필요하다
@Dao
interface HistoryDao {
@Query("SELECT * FROM history")
fun getAll(): List<History>
@Insert
fun insertHistory(history: History)
@Query("DELETE FROM history WHERE keyword == :keyword")
fun delete(keyword: String)
}
간단하게 select, insert, delete로 정의했다
Dao 정의에 관한 설명은 document에 잘 나와있으니 참고하시길,,
데이터베이스 배운사람은 전혀 어려울 것 없다
빠르게 다음으로 넘어가보자
- AppDatabase.kt
AppDatabase는 데이터베이스 구성을 정의하고 데이터에 대한 앱의 기본 엑세스 포인트 역할을 한다
이 클래스는 RoomDatabase를 상속받는 추상클래스여야 한다
@Database(entities = [History::class], version = 1)
abstract class AppDatabase: RoomDatabase(){
abstract fun historyDao(): HistoryDao
}
데이터베이스와 연결된 데이터 항목을 모두 나열하는 entities배열이 포함된 @Database를 달아야한다
우리의 데이터 항목은 History이므로 위와 같이 포함시켜준다
Room을 사용하기 위한 준비는 끝났다
이제 Room을 사용해보자
- MainActivity.kt
val db = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"BookSearchDB"
).build()
databaseBuilder의 인자로는 context, klass, name이 필요하므로 잘 넣어준다
db는 도서 검색시 키워드 저장하는데 사용하므로 search함수로 가서
함수 내부에 키워드를 저장하는 함수를 호출해준뒤 private fun으로 정의해준다
private fun search(keyword: String){
bookService.getBooksByName(CLIENT_ID, CLIENT_SECRET, keyword)
.enqueue(object: Callback<SearchDto>{
override fun onResponse(call: Call<SearchDto>, response: Response<SearchDto>) {
saveSearchKeyword(keyword)
if(response.isSuccessful().not()){
return
}
adapter.submitList(response.body()?.books.orEmpty())
}
override fun onFailure(call: Call<SearchDto>, t: Throwable) {
hideHistoryView()
}
})
}
private fun saveSearchKeyword(keyword: String) {
Thread {
db.historyDao().insertHistory(History(null, keyword))
}.start()
}
saveSearchKeyword함수는 간단하다
Thread를 열어서 insertHistory함수로 입력받은 키워드를 저장하도록 한다
코드만 봐도 어떻게 진행이되는지 보이니까 따로 설명은 하지 않겠다
이렇게 저장한 keyword들은 이제 어쩔까?!
저번에 했던 recyclerView를 이용해서 저장한 keyword들을 보여주도록 하자
먼저 activity_main.xml에 recyclerView를 추가해주자
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
...
tools:context=".MainActivity">
<EditText ... />
<androidx.recyclerview.widget.RecyclerView ... />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/historyRecyclerView"
android:layout_width="0dp"
android:visibility="gone"
android:layout_height="0dp"
android:background="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchEditText" />
</androidx.constraintlayout.widget.ConstraintLayout>
우리는 도서목록 위에 검색어목록을 띄울것이기 때문에 저번에 만든 recyclerView아래에 추가해준다
또 검색어를 보여줄때 뒤에 도서목록이 비치치 않도록 background를 white로 줬고,
처음에는 도서목록이 보여야되기때문에 visibility 속성은 gone으로 설정했다
visibility를 설정하는 함수를 먼저 만들어야될 것 같다
private fun showHistoryView() {
Thread {
val keywords = db.historyDao().getAll().reversed()
runOnUiThread {
binding.historyRecyclerView.isVisible = true
historyAdapter.submitList(keywords.orEmpty())
}
}.start()
binding.historyRecyclerView.isVisible = true
}
private fun hideHistoryView() {
binding.historyRecyclerView.isVisible = false
}
private fun showHistoryView() {
Thread {
val keywords = db.historyDao().getAll().reversed()
runOnUiThread {
binding.historyRecyclerView.isVisible = true
historyAdapter.submitList(keywords.orEmpty())
}
}.start()
binding.historyRecyclerView.isVisible = true
}
private fun hideHistoryView() {
binding.historyRecyclerView.isVisible = false
}
isVisibler값을 줘서 recyclerView의 visibility값을 변경한다
showHistoryView는 뷰를 보여줘야하니까 값을 true로 주고
HideHistoryView는 뷰를 가려야하니까 값을 false로 준다
showHistoryView는 뷰를 보여줄때 저장된 keyword들을 가져와서 보여줘야하므로
Thread를 열어 데이터를 가져오는 코드를 추가해주자
private fun showHistoryView() {
Thread {
val keywords = db.historyDao().getAll().reversed() ①
runOnUiThread {
binding.historyRecyclerView.isVisible = true
historyAdapter.submitList(keywords.orEmpty())
}
}.start()
binding.historyRecyclerView.isVisible = true
}
private fun hideHistoryView() {
binding.historyRecyclerView.isVisible = false
}
Dao의 getAll()메서드로 keyword를 전부 가져올 수 있고,
최근 검색어 순으로 보여주기 위해서 가져온 데이터에 reversed()로 순서를 바꿔주었다
private fun showHistoryView() {
Thread {
val keywords = db.historyDao().getAll().reversed() ①
runOnUiThread {
binding.historyRecyclerView.isVisible = true
historyAdapter.submitList(keywords.orEmpty())
}
}.start()
binding.historyRecyclerView.isVisible = true
}
private fun hideHistoryView() {
binding.historyRecyclerView.isVisible = false
}
keywords를 어댑터에 submitList를 해주면 recyclerView로 보여줄 수 있다
그런데 아직 adapter를 만들지 않았으니까 HistoryAdapter를 만들어보자
그전에 item_history.xml을 만들자
keyword를 출력할 TextView와 누르면 기록을 삭제할 수 있는 ImageButton을 만들었다
<?xml version="1.0" encoding="utf-8"?>
<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">
<TextView
android:id="@+id/historyKeywordTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginTop="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:text="aaaaaaaaaa"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/historyKeywordDeleteButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/historyKeywordDeleteButton"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginRight="16dp"
android:src="@drawable/ic_baseline_clear_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
- HistoryAdapter.kt
class HistoryAdapter(val historyDeleteClickListener: (String) -> Unit): ListAdapter<History, HistoryAdapter.HistoryItemViewHolder> (diffUtil) {
inner class HistoryItemViewHolder(private val binding: ItemHistoryBinding): RecyclerView.ViewHolder(binding.root){
fun bind(historyModel: History){
binding.historyKeywordTextView.text = historyModel.keyword
binding.historyKeywordDeleteButton.setOnClickListener{
historyDeleteClickListener(historyModel.keyword.orEmpty())
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryAdapter.HistoryItemViewHolder {
return HistoryItemViewHolder(
ItemHistoryBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: HistoryAdapter.HistoryItemViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<History>(){
override fun areItemsTheSame(oldItem: History, newItem: History): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: History, newItem: History): Boolean {
return oldItem.keyword == newItem.keyword
}
}
}
}
전에 만들었던 BookAdapter와 비슷해서 가져다가 수정했다
우선 Book과 관련된 이름들을 History로 변경해준다
bind()함수에는 textView와 imageButton에 대해 설정해준다
historyKeywordTextView는 어려울 것 없다
나는 historyKeywordDeleteButton 이벤트 설정하는 것이 어려웠다
이걸 쓰면서 다시한번 복습하도록 하자...
이벤트는 MainActivity에서 가져와야한다
-> 메인에서 DB에 저장이 되는 함수를 던져주면
-> Adapter에서 클릭 리스너가 발생했을 때 그 함수를 호출하는 방식
이런 방법으로 이벤트를 정의해줄 수 있다
HistoryAdapter 생성자 부분을 보자
class HistoryAdapter(val historyDeleteClickListener: (String) -> Unit):
ListAdapter<History, HistoryAdapter.HistoryItemViewHolder> (diffUtil) {
historyDeleteClickListener라는 변수를 하나 만들고 타입은 함수로 준다 (람다 사용!)
(string 인자를 가진 함수를 받고 반환값은 없음)
이 변수를 binding.historyKeywordDeleteButton.setOnClickListener에 사용한다
historyDeleteClickListener(historyModel.keyword.orEmpty())이렇게 써두고
Main에서 어댑터를 사용할 때 HistoryAdapter(historyDeleteClickListener = {deleteSearchKeyword(it)})해준다
이렇게되면 delete버튼을 눌렀을 때 historyDeleteClickListener를 통해서
deleteSearchKeyword함수가 실행이 된다
deleteSearchKeyword는 Thread에서 dao의 delete를 실행한 후에 뷰를 보여준다
여기까지 하고 다음 글에서는 검색했을 때 나오는 도서들의 상세페이지를 만들어보겠다
참고