Android/Paging3

[Android] Paging3 + Room + Flow 사용하기

O_Gyong 2023. 1. 16.
 

[Android] RecyclerView에서 페이징+삭제 처리하기 #2 (with Room)

[Android] RecyclerView에서 페이징 처리하기 #1 RecyclerView에서 리스트를 스크롤하다가 어느 순간에 로딩 화면이 뜨면서 리스트가 늘어나는 것을 본 적 있을 것이다. Adapter에서 등록된 list가 마지막에

ogyong.tistory.com

이전에 기기에 저장된 데이터를 RecyclerView와 Room을 사용해서 페이징 작업을 한 적이 있었다.

이번에는 Paging3 라이브러리를 사용해서 기기에 저장된 데이터를 표시해보려고 한다.

(삭제 기능은 다음에..)

 

참고로 Paging3는 Android Paging Basics codelabAndroid Paging Advanced codelab이 많은 도움이 됐다.


Paging3 예제

프로젝트 계층구조

Paging3가 적용된 앱 구조의 예

 

위의 구조에 맞춰서 개발을 진행했고, 최종 패키지 계층 구조는

옆의 의미지와 같다.

 

- data 패키지

data class, Dao, Database

 

- repository 패키지

 PagingData를 구하고 ViewModel에 전달

 

- 기타

Activity에서 ViewModel의 데이터를 수집해서 Adapter에 전달 


Paging3의 주 요소

• PagingSource : 데이터를 가져오는 방법을 정의하고 페이징 된 데이터를 로드
• PagingConfig : 페이지의 구성을 결정(페이지 크기 등)
• Pager : PagingData 타입의 반응형 스트림을 생성
• PagingData : 페이징 된 데이터를 가진 컨테이너
• PagingDataAdapter : RecyclerView에 PagingData를 표시하는 RecyclerView.Adapter의 서브 클래스

의존성 추가

plugins {

    ...
    
    id 'kotlin-kapt'
}
dependencies {
	
    ...
    
    // Room
    implementation "androidx.room:room-runtime:2.4.3"
    kapt "androidx.room:room-compiler:2.4.3"

    // paging3
    implementation "androidx.paging:paging-runtime-ktx:3.1.1"

    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
}

data 패키지 설정

@Dao
interface SampleDao {
    /**
     * 아이템 10개씩 호출
     */
    @Query("SELECT * FROM sample ORDER BY id ASC LIMIT 10 OFFSET (:page-1)*10")
    fun getList(page:Int): List<SampleData>
}

@Entity(tableName = "sample")
data class SampleData(
    @ColumnInfo(name = "title") val title: String
) {
    @PrimaryKey(autoGenerate = true) @ColumnInfo
    var id: Int = 0
}

@Database(entities = [SampleData::class], version = 1)
abstract class SampleDatabase: RoomDatabase() {
    abstract fun getSampleDao() : SampleDao

    companion object {
        @Volatile
        var sampleDB: SampleDatabase? = null

        fun getInstance(context: Context): SampleDatabase =
            sampleDB ?: synchronized(this) {
                sampleDB
                    ?: buildDatabase(context).also { sampleDB = it }
            }

        private fun buildDatabase(context: Context) =
            Room.databaseBuilder(
                context.applicationContext,
                SampleDatabase::class.java, "sample_database"
            ).build()
    }
}

DB에 접근하기 쉽게 싱글톤 객체로 선언했다.


PagingSource

/**
 * 데이터 식별자와 데이터 타입을 통해 DB의 데이터를 로드하는 곳
 */
class SamplePagingSource: PagingSource<Int, SampleData>() {
    /**
     * 스크롤 할 때마다 데이터를 비동기적으로 가져오는 메서드
     */
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, SampleData> {
        // 시작 페이지
        // 처음에 null 값인 것을 고려하여 시작 값 부여
        val page = params.key ?: STARTING_PAGE

        return try {
            var data: List<SampleData>? = null

            // page 값에 따른 list 호출
            // join을 사용해서 list 값을 저장
            CoroutineScope(Dispatchers.IO).launch {
                data = SampleDatabase.sampleDB!!.getSampleDao().getList(page)
            }.join()

            // 반환할 데이터
            LoadResult.Page(
                data = data!!,
                prevKey = if (page == STARTING_PAGE) null else page-1,
                nextKey = if(data.isNullOrEmpty()) null else page+1
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    /**
     * 현재 목록을 대체할 새 데이터를 로드할 때 사용
     */
    override fun getRefreshKey(state: PagingState<Int, SampleData>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}

private const val STARTING_PAGE = 1 // 초기 페이지 상수 값

◾ PagingSource의 Key-Value 타입 정하기

PagingSource에서 페이징 된 데이터를 로드하려면 먼저 PagingSource의 Key-Value 타입을 정해줘야 한다.

 

Key  값은 페이징에 쓰일 식별자로 정수 기반의 page 값인 Int,

Value는 불러올 데이터 타입인 SampleData로 지정한다.


◾ load 메서드 정의하기

load 메서드는 스크롤을 할 때 데이터를 비동기적으로 가져온다.

이곳에서 데이터를 가져오는 방법을 작성해야 한다.

 

우선, load는 LoadParams 객체를 매개변수로 받는다.

LoadParams에는 key와 loadSize 값을 갖는데, 여기서 key가 현재 페이지값으로 페이징에 사용될 page 값이다.

(loadSize는 요청된 데이터의 개수인데, 나는 쿼리에 10개씩 호출하도록 고정해서 사용하지 않는다.)

 

val page = params.key ?: STARTING_PAGE

주의할 점은 load가 처음 호출되면 key 값이 null로 넘어오기 때문에 해당 처리를 해야 한다.

 

var data: List<SampleData>? = null

CoroutineScope(Dispatchers.IO).launch {
    data = SampleDatabase.sampleDB!!.getSampleDao().getList(page)
}.join()

그리고 page 값으로 DB에서 리스트를 호출한다.

(runBlokcing-join으로 data 변수에 담기도록 처리했는데, 다른 방법들이 있는지 더 찾아봐야 할 듯..)26일 수정사항

 

데이터까지 구했으면 마지막으로 LoadResult 값을 반환해 준다.

• LoadResult.Page : 로드에 성공한 경우
• LoadResult.Error : 로드에 실패한 경우(오류 발생)
• LoadResult.Invalid : PagingSource가 무효화되어야 하는 경우(결과의 무결성을 보장할 수 없을 때?)

여기서 LoadResult.Page에 위에서 구한 data, 현재 페이지, 다음 페이지에 대한 정보를 담아준다.

주의할 점은 다음 페이지 정보가 없을 때 처리하지 않으면 무한정으로 다음 페이지를 호출하게 된다.

(null 값을 넘겨줘야 호출하지 않음)


◾ getRefreshKey 메서드 정의하기

getRefreshKey는 Paging3 라이브러리가 UI 관련 항목을 새 로고침해야 할 때 호출된다.

아직 PagingSource의 데이터가 변경된 적이 없어서(데이터 추가 제외) 해당 메서드가 호출된 것을 확인하지 못했다.

우선은 안드로이드 코드랩에 나온 코드로 작성했다.


Repository

class SampleRepository {
    /**
     * Pager를 사용하여 PagingData를 반환
     * • PagingConfig로 페이지 동작을 결정(페이지 크기)
     */
    fun getSamplePagingSource(): Flow<PagingData<SampleData>> {
        return Pager(
            config =  PagingConfig(
                pageSize = 10,
                enablePlaceholders = false
            ),
            pagingSourceFactory = { SamplePagingSource() }
        ).flow
    }
}

Repository에서는 Pager를 사용하여 PagingData 타입의 flow(반응형 스트림)를 ViewModel에 전달한다.

( 참고로 Pager().flow 말고 Pager().liveData를 사용하면 LiveData로 데이터를 전달하고 값을 유지할 수 있다. )

 

Pager에는 PagingConfig와 PagingSource 객체 정보를 넘겨줘야 한다.

PagingConfig는 페이지 크기(몇 개를 불러올지)와 같은 페이지의 구성을 결정한다.

참고로 PagingConfig의 pageSize는 PagingSource에서 load가 처음 호출될 때 loadSIze값에 3이 곱해진다.
예를 들면 pageSize를 10으로 설정하면 처음 loadSize는 30으로 나오고 그 이후부터 10으로 나온다.
첫 loadSize를 늘림으로써 충분한 양의 데이터를 미리 읽어두는 것이다. 

ViewModel

class MainViewModel: ViewModel() {
    /**
     * (고급 코드 랩 7p)
     *  cachedIn 메서드를 PagingData 흐름에 적용하여 viewModelScope 내에서 flow를 활성 상태로 유지한다.
     *
     * (기본 코드 랩 7p)
     * cachedIn 메서드에 viewModelScope를 전달하여 변경 사항에도 페이징 상태를 유지한다.
     *
     * (기타)
     * cachedIn을 사용하여 캐싱을 할 수 있다.
     */
    fun getContent() : Flow<PagingData<SampleData>> {
        return SampleRepository().getSamplePagingSource().cachedIn(viewModelScope)
    }
}

ViewModel에서는 UI에 전달만 하면 된다.

 

cachedIn(viewModelScope)을 사용해서 캐싱을 하면 페이징 상태를 유지한다고 설명이 되어있는데,

해당 메서드가 없어도 코드가 잘 동작해서 좀 더 알아봐야겠다..


 

Adapter

/**
 * (기본 코드 랩 8p)
 * PagingData 콘텐츠가 load 될 때마다 PagingDataAdapter에서 알림을 받고
 * RecyclerView에 업데이트 하라는 신호를 보냄
 *
 * (기타)
 * 백그라운드 스레드에서 DiffUtil을 사용하여 데이터를 정제하고
 * 데이터를 불러오기 때문에 UI가 부드럽게 나타난다.
 */
class SampleAdapter: PagingDataAdapter<SampleData, SampleViewHolder>(ARTICLE_DIFF_CALLBACK) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder =
        SampleViewHolder(
            ListItemMainBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )

    override fun onBindViewHolder(holder: SampleViewHolder, position: Int) {
        val item = getItem(position)
        if(item != null) {
            holder.bind(item)
        }
    }

    companion object {
        private val ARTICLE_DIFF_CALLBACK = object : DiffUtil.ItemCallback<SampleData>() {
            override fun areItemsTheSame(oldItem: SampleData, newItem: SampleData): Boolean =
                oldItem.id == newItem.id

            override fun areContentsTheSame(oldItem: SampleData, newItem: SampleData): Boolean =
                oldItem == newItem
        }
    }
}
class SampleViewHolder(private val mBinding : ListItemMainBinding): RecyclerView.ViewHolder(mBinding.root) {
    fun bind(listData: SampleData) {
        mBinding.tvItem.text = listData.title
    }
}

PagingDataAdapter를 이용해서 RecyclerView에 데이터가 표시되도록 한다.

일반적인 RecyclerView와 달리 DiffUtil을 사용해야 한다.

 

그리고 ViewHolder는 외부에 선언한다. (내용 아래 링크 참고)


Activity

override fun onCreate(savedInstanceState: Bundle?) {
   
   ...

    /**
     * collectLatest로 수집한 데이터를 Adapter에 전달 (collect를 써도 정상동작 함)
     */
    lifecycleScope.launch {
        mViewModel.getContent().collectLatest {
            mAdapter.submitData(lifecycle, it)
        }
    }
}

Flow의 collectLatest로 수집한 데이터를 Adapter의 submitData에 넘겨서 변경사항을 알린다.

 

만약 반응형 스트림을 LiveData로 할 경우 ViewModel에 LiveData 변수를 만들고

Activity에서 observe로 접근하면 될 것 같다.


전체 코드


2023.01.18 내용 수정

ViewHolder Adapter 외부에 만들지 않고 inner class로 생성함.

class SampleAdapter: PagingDataAdapter<SampleData, SampleAdapter.SampleViewHolder>(ARTICLE_DIFF_CALLBACK) {
    interface CustomListenerInterface {
        fun removeListener(position: Int, sampleData: SampleData)
    }

    inner class SampleViewHolder(private val mBinding : ListItemMainBinding): RecyclerView.ViewHolder(mBinding.root) {
        fun bind(listData: SampleData) {
            mBinding.tvItem.text = listData.title
            mBinding.ivItemRemove.setOnClickListener {
                onRemoveListener?.removeListener(bindingAdapterPosition, listData)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder =
        SampleViewHolder(
            ListItemMainBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )

    override fun onBindViewHolder(holder: SampleViewHolder, position: Int) {
        val item = getItem(position)
        if(item != null) {
            holder.bind(item)
        }
    }

    private var onRemoveListener: CustomListenerInterface? = null
    fun removeListener(pOnClick: CustomListenerInterface) {
        this.onRemoveListener = pOnClick
    }


    companion object {
        private val ARTICLE_DIFF_CALLBACK = object : DiffUtil.ItemCallback<SampleData>() {
            override fun areItemsTheSame(oldItem: SampleData, newItem: SampleData): Boolean =
                oldItem.id == newItem.id

            override fun areContentsTheSame(oldItem: SampleData, newItem: SampleData): Boolean =
                oldItem == newItem
        }
    }
}

2023.01.26 내용 수정

implementation "androidx.room:room-ktx:2.4.3"
@Query("SELECT * FROM sample ORDER BY id ASC LIMIT 10 OFFSET (:page-1)*10")
suspend fun getList(page:Int): List<SampleData>

gradle에 위의 의존성을 추가하고 Dao에 suspend 키워드를 추가한다.


SampleDatabase.sampleDB!!.withTransaction {
    data = SampleDatabase.sampleDB!!.getSampleDao().getList(page)
}

suspend 키워드를 추가하면 메인 스레드에서 해당 메서드가 동작하지 않기 때문에

별도의 서브 스레드를 만들어서 처리할 필요가 없다.

 

CoroutineScope와 join을 사용하던 부분을 위의 코드로 수정했다.

댓글