저번 포스팅에서는 API를 받아와 리사이클러 뷰에 뿌려주는 것 까지 했다. 남은 기능들을 추가 구현했다.
# 결과물 미리보기
베스트셀러도서뿐 아니라 책을 검색할 수 있는 기능을 추가했다. 또한 해당 도서를 클릭했을 시 상세페이지가 나오게 구현했다. 검색어 저장기능도 구현하여 검색했던 것들이 입력창을 클릭했을 시 나오는 것을 볼 수 있다.
# 도서 검색 기능 구현 ( search )
도서 검색 기능은 간단하게 구현 가능 했다. 먼저 메인레이아웃에 EditText를 추가해주고
private fun search(keyword: String) {
bookService.getBooksByName(getString(R.string.interparkApiKey), keyword)
.enqueue(object : Callback<SearchBookDto> {
override fun onResponse(
call: Call<SearchBookDto>, response: Response<SearchBookDto>,
) {
if (response.isSuccessful.not()) return
response.body()?.let {
//어댑터에 books리스트 전달하여 book리사이클러뷰를 그림
adapter.submitList(it.books)
//DB에 검색한 키워드를 추가해주는 함수
addHistoryInDB(keyword)
}
}
override fun onFailure(call: Call<SearchBookDto>, t: Throwable) {
// 실패 시
}
})
}
# 도서 클릭시 Detail페이지로 연결되는 기능 구현
먼저 위 기능을 구현하기 위해서 어떻게 해야할지 생각해본다. 어딘가 뷰가 눌리고, 눌리고 나서는 새로운 액티비티로 전환되어야 한다.
그렇다면 어떤 뷰에 setOnClickListener를 달아줘야 하는데 어디에 달아줘야 할까? 북 어댑터에서 각기의 뷰들에 초기화 되는게 어떤것인지 정해준다. 그렇다 어댑터의 BookViewHolder에 bind함수에 리스너를 추가해주자
// 인자로 클릭리스너를 받는다.
class BookAdapter(private val itemOnClickedListner: (Book)->Unit) : ListAdapter<Book,BookAdapter.BookViewHolder>(diffUtil) {
inner class BookViewHolder(private val binding : ItemBookBinding ) : RecyclerView.ViewHolder(binding.root){
fun bind(bookModel : Book){
binding.titleTextView.setText(bookModel.title)
binding.descptionTextVIew.setText(bookModel.description)
//setOnClickListener
binding.root.setOnClickListener {
itemOnClickedListner(bookModel)
}
//glide를 이용하여 url이미지 적용
Glide.with(binding.bookImageView.context)
.load(bookModel.coverSmallUrl)
.into(binding.bookImageView)
}
}
//다른 메서드 ....
}
인자로 받은 온클릭 메서드를 뷰홀더가 클릭되면 실행하도록 넘겨준다.
// MainActivity
private fun initRecyclerView() {
adapter = BookAdapter(itemOnClickedListner = {
initItemClickeEvent(it)
})
//layoutManager가 뭘까..?
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
}
private fun initItemClickeEvent(bookModel : Book) {
// TODO 새로운 인탠트로 BOOK정보와 함께 넘겨줌
val intent = Intent(this,DetailActivity::class.java)
intent.apply {
putExtra("bookModel",bookModel)
}
startActivity(intent)
}
메인액티비티에서 initItemClickeEvent와 같은 메서드를 정의하여 어떤 이벤트를 수행할지를 정의한다. 그리고 이것을 어댑터의 매개변수로 보내 뷰에 등록되도록 하는 것이다.
## 모델을 인탠트로 넘겨주기
val intent = Intent(this,DetailActivity::class.java)
intent.apply {
putExtra("bookModel",bookModel)
}
한가지 특이한점은 인텐트로 model을 넘겨준 것인데 이건 그냥 코틀린만 사용해서는 불가능하다. 아래 플러그인을 추가해주고 SyncNow를 해준다.
plugins {
// ...
// 추가
id 'kotlin-parcelize'
}
// DetailActivity
//getParcelableExtra의 반환값은 nullable이기 때문에 사용에 유의한다.
val model = intent.getParcelableExtra<Book>("bookModel")
binding.detaileTitleTextView.setText(model?.title)
binding.detailDescrptionTextView.setText(model?.description)
# 검색어 저장 기능 구현
검색어 저장에는 두가지 중요기능이 들어간다.
- 리사이클러뷰로 검색어 보여주기
- Room데이터베이스를 활용하여 검색어 저장 및 삭제 관리
## 검색어 저장을 위한 리사이클러 뷰 만들기
리사이클러뷰는 몇번 만들어보면서 어느정도의 로직을 이해했다. 먼저 리사이클러뷰 전체를 관리해주는 어댑터를 만들어야한다. X버튼을 똑같이 이벤트리스너를 배개변수로 받아서 버튼 자체에 리스너를 달아주도록 하자. 타겟 되는 버튼이 X버튼 단일이기 때문에 그전과 같이 book어댑터에서와 같이 root를 쓰거나 할 필요가 없다.
class HistoryAdpter(val historyDeleteClickedListener: (String) -> Unit) : ListAdapter<History, HistoryAdpter.HistoryViewHolder>(diffUtil) {
inner class HistoryViewHolder(private val binding : ItemSearchHistoryBinding) : RecyclerView.ViewHolder(binding.root){
fun bind(historyModel : History){
binding.searchHistroyTextView.setText(historyModel.keyword)
binding.searchDeleteButton.setOnClickListener {
historyDeleteClickedListener(historyModel.keyword)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryViewHolder {
return HistoryViewHolder(ItemSearchHistoryBinding.inflate(LayoutInflater.from(parent.context),parent,false))
}
override fun onBindViewHolder(holder: HistoryViewHolder, 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
}
}
}
}
급할때 와서 보기 좋게 전체 코드를 첨부했다.
리사이클러뷰의 어댑터를 활용하는 방법은
private fun initHistoryRecyclerView() {
historyAdpter = HistoryAdpter(historyDeleteClickedListener = { keyword ->
deleteHistory(keyword)
})
binding.searchRecyclerView.layoutManager = LinearLayoutManager(this)
binding.searchRecyclerView.adapter = historyAdpter
}
위와같이 어댑터를 연결 시켜 준 후,
historyAdpter.submitList(historys)
해당 어댑터에 List를 던져줌으로써 해당 리스트 안에 있는 것들을 리사이클러뷰에 그리도록 해주는 것이다.
이제 이 리스트를 던져주기 위한 데이터는 RoomDB에서 관리되기 때문에 Room을 만들어주자. 저번에도 한번 한 기억이 있어서 비교적 이해하기 쉬웠다.
### 주의!
주의해야할 점은 DB를 만들고 앱이 빌드 되면 DB를 수정하려면 migration을 해주며 관리해야하는데 이 과정이 꽤 복잡하다. 앱을 지웠다가 다시 빌드하면 되지만 혹시 모르기때문에 DB를 만들때 집중해야 할 것 같다. 나는 PrimaryKey를 이상하게 잡아줘서 지웠다가 다시만들었다.
## Room 데이터베이스 만들고 데이터 관리하기
DB에 어떤 쿼리가 필요한지 생각해보자. 위 이미지처럼, 전체데이터 불러오기, 특정 데이터 삭제, 특정 데이터 추가가 필요하다. 즉 쿼리문을 구성하는 DAO (인터페이스로 구성)에는 위 3가지 쿼리문만을 주면 되는 것이다.
### build.gradel
build.gradle에 Room을 연결해준다.
// plugins
id 'kotlin-kapt'
// dependencies
kapt 'androidx.room:room-compiler:2.4.2'
implementation 'androidx.room:room-runtime:2.4.2'
### model 정의
먼저 모델(data class)부터 만들어 주겠다. 모델에는 테이블의 column값을 정의해준다. 실질적으로 어떤 값을 가질지를 결정해준다.
primaryKey에는 nullable옵션을 주어, 데이터를 생성할때 값을 넣어주지 않고 자동으로 설정되게 해줘야한다. 이거때문에 앱지웠다가 깔았다. nullable( ? ) 속성을 안주니까 History를 만들때 id값을 넣어줘야 했는데 이러면 auto로 증가하지 않아서... 여튼 잊지 말자!
@Entity
data class History(
// primaryKey에는 nullable옵션을 주어, 데이터를 생성할때 값을 넣어주지 않고 자동으로 설정되게 해줘야한다.
@PrimaryKey val id: Int?,
@ColumnInfo(name = "keyword") val keyword : String
)
### DAO 정의
위에서 말한 3가지에 동작에 대한 것을 쿼리문을 통해 정의해준다.
interface HistroyDao {
@Query("SELECT * FROM History")
fun getAllHistroy() : List<History>
@Insert
fun insertHistroy(history: History)
@Query("DELETE FROM history WHERE keyword == :keyword")
fun deleteHistory(keyword: String)
}
### AppDatabase
@Database(entities = [History::class], version =1)
abstract class AppDataBase : RoomDatabase(){
abstract fun histroyDao() : HistroyDao
}
추상클래스로 AppDataBase를 정의해준다. 여기서 version이 어떻게 쓰이는지 몸소 체감했다. 만약 DB를 구성하는 무엇인가가 수정되고 실행한다 해도 변함이 없을 것이다. version을 1증가시켜주고 migrations을 통해 어떤게 바뀌었는지를 적어줘야한다.
## MainActivity에서 DB사용하기
// db변수는 전역으로 관리
private fun initDataBase() {
db = Room.databaseBuilder(
applicationContext,
AppDataBase::class.java,
"SearchHistroyDB"
).build()
}
db를 만든다. 이제 3가지 쿼리문이 필요한 곳에 db로 값을 불러오고 어댑터에 submit리스트를 해주면 된다.
### DB내 모든 값 불러오기
DB 관련된 내용은 thread를 통해 관리하고, 쓰레드에서 UI가 바뀌는 조작은 메인쓰레드에서 조작이 가능하기 때문에 runOnUiThread를 통해서 관리한다.
private fun showHistory() {
binding.searchRecyclerView.isVisible = true
thread {
val historys = db.histroyDao().getAllHistroy().reversed()
runOnUiThread {
// TODO 가져온 리스트를 UI그려주기
historyAdpter.submitList(historys)
}
}
}
### DB에서 특정값 지우기
private fun deleteHistory(keyword: String) {
thread {
db.histroyDao().deleteHistory(keyword)
showHistory()
}
}
### DB에서 검색값 추가하기
private fun addHistoryInDB(keyword: String) {
thread {
val newHistory = History(id = null,keyword = keyword)
db.histroyDao().insertHistroy(newHistory)
}
}
이제 이 3가지 쿼리를 적재적소에 배치하여 활용하면 된다.
# 알게 된 것
## Thread{}.start() Vs thread{...} 차이
쓰레드를 사용할때, Thread{ ... }.start() 로 시작을 알려야하는데, thread { ... } 는 따로 start를 하지 않았다. 차이점이 뭘까?
구글링 결과 별거 없다. thread는 thread(start = trure) {...} 와 같기 때문에 자동으로 start를 해준다고 한다. 그리고 그걸 생략해서 thread { ... } 로 사용하는 것 이었다. 후반부에서 이거때문에 thread로 쓰고 계속 start해줬는데 에러나서 애먹었다..