이번 영상에서는 Room 데이터베이스를 UI와 연동하는 법에 대해 알아보도록 하겠습니다.
우선 BookSearchRepository에 Room Dao를 조작하기 위한 메소드를 추가합니다.
interface BookSearchRepository {
...
+ // Room
+ suspend fun insertBooks(book: Book)
+ suspend fun deleteBooks(book: Book)
+ fun getFavoriteBooks(): LiveData<List<Book>>
}
BookSearchRepositoryImpl에서 이 인터페이스를 구현합니다. 생성자로 BookSearchDatabase를 받아 dao를 통해 각 메소드를 구현하면 됩니다.
class BookSearchRepositoryImpl(
+ private val db: BookSearchDatabase
) : BookSearchRepository {
...
+ // Room
+ override suspend fun insertBooks(book: Book) = db.bookSearchDao().insertBook(book)
+ override suspend fun deleteBooks(book: Book) = db.bookSearchDao().deleteBook(book)
+ override fun getFavoriteBooks(): LiveData<List<Book>> = db.bookSearchDao().getFavoriteBooks()
}
그리고 MainActivity에서 Repository를 생성할 때 BookSearchDatabase 객체를 전달해줍니다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
// setNavigation()
setJetpackNavigation()
+ val database = BookSearchDatabase.getInstance(this)
+ val bookSearchRepository = BookSearchRepositoryImpl(database)
val factory = BookSearchViewModelProviderFactory(bookSearchRepository)
bookSearchViewModel = ViewModelProvider(this, factory)[BookSearchViewModel::class.java]
}
다음은 BookSearchViewModel을 정의합니다.
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository,
) : ViewModel() {
...
+ // Room
+ fun saveBook(book: Book) = viewModelScope.launch {
+ bookSearchRepository.insertBooks(book)
+ }
+ fun deleteBook(book: Book) = viewModelScope.launch {
+ bookSearchRepository.deleteBooks(book)
+ }
+ val favoriteBooks: LiveData<List<Book>> = bookSearchRepository.getFavoriteBooks()
}
CRUD를 수행하는 suspend 함수는 viewModelScope 안에서 실행되도록 합니다. getFavoriteBooks로 읽어온 데이터는 favoriteBooks가 가지고 있도록 정의했습니다.
그럼 이제 책 정보를 저장하고 화면에 표시하는 기능을 구현하겠습니다. 우선은 fragment_book.xml 에 FloatingActionButton을 하나 추가합니다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
...
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_favorite"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="32dp"
+ android:layout_marginBottom="32dp"
+ android:src="@drawable/ic_favorite"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
그리고 BookFragment에서 ViewModel을 전달받게하고 FloatingActionButton에 클릭 리스너를 붙여줍니다. 클릭이 일어나면 전달받은 book argument를 saveBook으로 저장하면 됩니다.
+private lateinit var bookSearchViewModel: BookSearchViewModel
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ bookSearchViewModel = (activity as MainActivity).bookSearchViewModel
val book = args.book
...
+ binding.fabFavorite.setOnClickListener {
+ bookSearchViewModel.saveBook(book)
+ Snackbar.make(view, "Book has saved", Snackbar.LENGTH_SHORT).show()
+ }
}
FavoriteFragment에서 저장된 책 데이터를 표시하도록 하겠습니다. 우선은 화면에 RecyclerView를 추가합니다.
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.fragment.FavoriteFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_favorite_books"
android:layout_width="0dp"
android:layout_height="0dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
다음은 FavoriteFragment에 ViewModel과 RecyclerView를 설정합니다. setupRecyclerView는 SearchFragment와 동일하게 터치한 아이템의 url 속성을 BookFragment에 전달하여 WebView로 표시하도록 합니다.
class FavoriteFragment : Fragment() {
private var _binding: FragmentFavoriteBinding? = null
private val binding get() = _binding!!
private lateinit var bookSearchViewModel: BookSearchViewModel
private lateinit var bookSearchAdapter: BookSearchAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentFavoriteBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bookSearchViewModel = (activity as MainActivity).bookSearchViewModel
setupRecyclerView()
}
private fun setupRecyclerView() {
bookSearchAdapter = BookSearchAdapter()
binding.rvFavoriteBooks.apply {
setHasFixedSize(true)
layoutManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
addItemDecoration(
DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)
)
adapter = bookSearchAdapter
}
// 클릭리스너 설정
bookSearchAdapter.setOnItemClickListener {
val action = FavoriteFragmentDirections.actionFavoriteFragmentToBookFragment(it)
findNavController().navigate(action)
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}
그리고 favoriteBooks를 옵저빙하여 값이 변화했을 때 자동으로 RecyclerView를 갱신하도록 설정합니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
+ bookSearchViewModel.favoriteBooks.observe(viewLifecycleOwner) {
+ bookSearchAdapter.submitList(it)
+ }
}
마지막으로 데이터를 삭제하는 기능을 작성합니다. 여기서는 아이템을 왼쪽으로 스와이프하면 데이터가 삭제되도록 하겠습니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
+ setupTouchHelper(view)
}
+private fun setupTouchHelper(view: View) {
+ val itemTouchHelperCallback = object : ItemTouchHelper.SimpleCallback(
+ 0, ItemTouchHelper.LEFT
+ ) {
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean {
+ return true
+ }
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ val position = viewHolder.bindingAdapterPosition
+ val book = bookSearchAdapter.currentList[position]
+ bookSearchViewModel.deleteBook(book)
+ Snackbar.make(view, "Book has deleted", Snackbar.LENGTH_SHORT).apply {
+ setAction("Undo") {
+ bookSearchViewModel.saveBook(book)
+ }
+ }.show()
+ }
+ }
+ ItemTouchHelper(itemTouchHelperCallback).apply {
+ attachToRecyclerView(binding.rvFavoriteBooks)
+ }
+}
리사이클러뷰의 아이템을 스와이프하는데는 SimpleCallback을 사용합니다. 우선 SimpleCallback의 인스턴스를 만든 뒤 attachToRecyclerView로 RecyclerView와 연결시켜주면 스와이프나 드래그 동작을 인식할 수 있게 됩니다.
초기화할때 인식할 드래그 방향은 dragDirs, 스와이프 방향은 swipeDirs로 정해주는데 여기서는 드래그는 사용하지 않을것이니 0으로 설정했고, 스와이프 방향은 왼쪽만 인식하도록 하였습니다.
onSwiped에서는 스와이프 동작이 발생했을 때 데이터를 삭제하는 동작을 정의합니다. 터치한 ViewHolder 위치를 getBindingAdapterPosition으로 획득한 뒤 book 객체를 어댑터에 전달하여 deleteBook을 실행합니다. SnackBar로 진행상태를 표시하되 삭제한 데이터를 되돌릴 수 있도록 setAction을 써서 Undo를 수행할 수 있게 합니다. onMove는 사용하지 않을 것이므로 return true로 정의합니다.
이제 앱을 실행시켜 저장/삭제 동작을 수행해 보면 기능이 정상적으로 동작하는 것을 알 수 있습니다. Android Studio의 App Inspection > Database Inspector 기능을 사용하면 DB의 값이 변하는 모습을 실시간으로 확인할 수도 있습니다.
이 앱에서는 항목을 지웠다가 Undo 명령으로 복원하면 리사이클러뷰의 가장 아래 위치로 표시되는데 이것은 Book 아이템을 단순히 새로운 아이템으로 다시 추가하는 동작이 이루어지기 때문입니다. 만약 리사이클러뷰에서의 표시위치를 유지하고 싶다면 Book 클래스의 프라이머리 키를 현재의 isbn 대신 자동증가하는 정수값을 추가해서 지정합니다. 그러면 아이템이 지워졌다 다시 추가되도 리사이클러뷰는 정수값의 증가순으로 항목을 표시하게 됩니다.
이렇게해서 Room 기능을 구현하고 UI와 연동하는 부분에 대해 알아보았습니다.