[Kotlin/Android] Paging 3 + Room DB

Programming/Android 2021. 8. 18. 10:45 Posted by 생각하는로뎅
반응형

  많은 데이터를 화면에 보여주기 위해, 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
}

 

※ 추가 설명

Room DB Flow

 

 

5.  Paging Source 생성

 

    1) paging 동작시, scroll 마지막 위치에서, read more 역할을 한다.

    2) 정상적으로 paging이 되려면, prevKeynextKey가 적절하게 값이 셋팅되어야 한다.

       그렇지 않으면, 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 ModelApplication 파라미터를 넘기기 위해, 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 layouRecyvlerView를 생성한다.

 

   layoutMangerxml에서 선언했다.

 

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 dataRoom DBinsert 하도록 한다.

    2) View ModelViewModelFactory를 이용하여, 생성하도록 한다.

    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 를 이용한 에러 예외 처리 및 로딩바 같은 기법들이 존재한다.

반응형