[Kotlin/Android] Paging 3 + Room DB
많은 데이터를 화면에 보여주기 위해, Paging 은 정말 중요하다. 모든 데이터를 그리게되면, 어플리케이션 성능이 저하되어, 사용자 이탈이 생길 뿐만 아니라 어플리케이션이 중단되는 현상까지 발생한다.
이를 해결하기 위해, 자체적으로 Paging을 구현해서 사용했지만, Google I/O 에서 보다 안정적인 Paging 라이브러리를 소개하였고, 정말 간단하게 구현해보았다.
- Paging 3 + Room DB
- Paging 3 + Network
1. build.gradle : app
plugins {
...
id 'kotlin-kapt'
}
android {
...
buildFeatures {
viewBinding = true
dataBinding = true
}
}
dependencies {
...
// room
implementation "androidx.room:room-runtime:2.3.0"
implementation "androidx.room:room-ktx:2.3.0"
kapt "androidx.room:room-compiler:2.3.0"
// paging
implementation "androidx.paging:paging-runtime-ktx:3.0.1"
// lifecycle
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
implementation"androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"
}
2. [Room DB] 사용할 Entity data class생성
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Test(
@PrimaryKey(autoGenerate = true) val _id : Int
)
3. [Room DB] DAO class 생성
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface TestDAO {
@Query("select * from Test order by _id DESC LIMIT :loadSize OFFSET :index * :loadSize")
fun getPage(index : Int, loadSize : Int) : List<Test>
@Insert
fun insertData(data : Test)
}
4. [Room DB] Database class 생성
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = arrayOf(Test::class), version = 1)
abstract class AppDatabase : RoomDatabase(){
abstract fun testDAO() : TestDAO
}
※ 추가 설명
5. Paging Source 생성
1) paging 동작시, scroll 마지막 위치에서, read more 역할을 한다.
2) 정상적으로 paging이 되려면, prevKey와 nextKey가 적절하게 값이 셋팅되어야 한다.
그렇지 않으면, scroll 마지막 위치에서 paging이 정상적으로 이루어지지 않는다.
3) scroll 마지막에 위치 할 때 마다 Dao 를 통해, 해당 페이지에 맞게 데이터를 가져와서 load 한다.
import androidx.paging.PagingSource
import androidx.paging.PagingState
open class PagingSourc(private val testDAO : TestDAO) : PagingSource<Int, Test>() {
private companion object {
const val INIT_PAGE_INDEX = 0
}
override fun getRefreshKey(state: PagingState<Int, Test>): Int? {
return state.anchorPosition?.let { achorPosition ->
state.closestPageToPosition(achorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(achorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Test> {
val position = params.key ?: INIT_PAGE_INDEX
val loadData = testDAO.getPage(position, params.loadSize)
return LoadResult.Page(
data = loadData,
prevKey = if (position == INIT_PAGE_INDEX) null else position -1,
nextKey = if (loadData.isNullOrEmpty()) null else position + 1
)
}
}
6. Page View Model 생성
view model에서 paging config를 설정한다.
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.room.Room
class PageViewModel(private var app : Application) : ViewModel() {
val dao = Room.databaseBuilder(app, AppDatabase::class.java, "myDb")
.build()
.testDAO()
val data = Pager(
config = PagingConfig(
pageSize = 10,
enablePlaceholders = false
), pagingSourceFactory = { PagingSourc(dao) }
).flow
}
7. View Holder 에서 사용할 View Model 생성
LiveData를 이용하여, ViewHolder에 있는 TextView를 갱신하도록 한다.
해당 View Model은 캡슐화 되어 있다.
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class ListItemViewModel() : ViewModel() {
/* data */
private var _idText = MutableLiveData<String>()
/* live data */
val idText : LiveData<String> get() = _idText
fun setIdText(value : String) {
_idText.value = value
}
}
8. Page View Model Factory 생성
View Model의 Application 파라미터를 넘기기 위해, View Model Factory를
이용하여 파라미터를 전달 후, ViewModel을 생성한다.
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
class ViewModelFactory(private var app : Application?) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(PageViewModel::class.java)) {
return PageViewModel(app!!) as T
} else if (modelClass.isAssignableFrom(ListItemViewModel::class.java)) {
return ListItemViewModel() as T
}
throw IllegalAccessException("unknow view model class")
}
}
9. RecyclerView 에 표시할 아이템 Layout을 생성한다
해당 layout은 7번에서 생성한 ListItemViewModel(VIewModel)과 연결되어 있다.
ListItemViewModel의 idText(LiveData) 의 value가 변경되면, 해당 layout에 반영된다.
list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="model"
type="com.example.testapp.model.ListItemViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/tv_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{model.idText}"
android:textColor="@color/black"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Dummy data" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
10. Main layou에 RecyvlerView를 생성한다.
layoutManger은 xml에서 선언했다.
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
<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=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_list"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
11. 아이템을 표시할 View Holder을 생성한다.
import androidx.recyclerview.widget.RecyclerView
class PageViewHolder(private var binding : ListItemBinding, var viewModel: ListItemViewModel) : RecyclerView.ViewHolder(binding.root) {
fun bindTo(pageInfo: Test) {
this.binding.model = viewModel
this.viewModel.setIdText(pageInfo._id.toString())
}
}
12. RecyclerView에 연결할 adapter을 생성한다.
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.*
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
class PageListAdapter : PagingDataAdapter<Test, PageViewHolder>(DIFF_COMPARATOR){
companion object {
private val DIFF_COMPARATOR = object : DiffUtil.ItemCallback<Test>(){
override fun areItemsTheSame(oldItem: Test, newItem: Test): Boolean {
return oldItem._id == newItem._id
}
override fun areContentsTheSame(oldItem: Test, newItem: Test): Boolean {
return oldItem == newItem
}
}
}
override fun onBindViewHolder(holder: PageViewHolder, position: Int) {
getItem(position)?.let {
holder.bindTo(it)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder {
var binding : ListItemBinding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
var viewModel : ListItemViewModel = ViewModelProvider(ViewModelStore(), ViewModelFactory(null)).get(ListItemViewModel::class.java)
return PageViewHolder(binding, viewModel)
}
}
13. 메인 동작을 위해, Main Activity 를 수정한다.
1) apdater 생성전에 dummy data를 Room DB에 insert 하도록 한다.
2) View Model은 ViewModelFactory를 이용하여, 생성하도록 한다.
3) 최초 데이터 갱신시, 반드시 submitData(it)을 이용하여 반영을 알려야한다.
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.room.Room
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var viewModel : PageViewModel
private lateinit var pageListAdapter : PageListAdapter
private val scope = CoroutineScope(Dispatchers.Default)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
this.binding =
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
this.binding.lifecycleOwner = this
this.viewModel = ViewModelProvider(this, ViewModelFactory(application)).get(PageViewModel::class.java)
this.pageListAdapter = PageListAdapter()
this.binding.rvList.adapter = this.pageListAdapter
this.scope.launch {
addData()
showData()
}
}
suspend fun showData(){
viewModel.data.collectLatest {
pageListAdapter.submitData(it)
}
}
suspend fun addData() = withContext(Dispatchers.IO) {
var dao = Room.databaseBuilder(application, AppDatabase::class.java, "myDb")
.build()
.testDAO()
var data: Test
for (i in 0..10) {
data = Test(0)
dao.insertData(data)
}
}
}
14. 변경점 알림
만약 중간에 DB insert 후, view를 갱신시키려면, 아래와 같이
apdater.refresh() 으로 변경점을 알려야 갱신이 된다.
viewModel.viewModelScope.launch {
pageListAdapter.refresh()
}
15. 그외 LoadState 를 이용한 에러 예외 처리 및 로딩바 같은 기법들이 존재한다.