선생님, 개발을 잘하고 싶어요.

[페이징] android Paging2 + RxJava2로 페이징 구현하기 본문

개발/android 개발

[페이징] android Paging2 + RxJava2로 페이징 구현하기

알고싶은 승민 2020. 12. 7. 23:55

Gradle 세팅하기

//rxjava
implementation "io.reactivex.rxjava2:rxjava:2.2.9"
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation "io.reactivex.rxjava2:rxkotlin:2.3.0"

//paging
implementation "androidx.paging:paging-runtime-ktx:2.1.2"
implementation "androidx.paging:paging-rxjava2-ktx:2.1.2"

 

오늘 샘플 데이터, 샘플 레포지토리

data class News(
    val id: String,
    val page: Int
)

interface NewsRepository {

    fun loadNews(page: Int, perPage: Int): Single<List<News>>
}

class SampleActivity : AppCompatActivity() {

    lateinit var recyclerView: RecyclerView

    // 의존성 주입을 통해서 retrofit 구현체를 받아오세요.
    lateinit var repository: NewsRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sample)

        recyclerView = findViewById(R.id.recycler_view)
    }
}

NewsRepository 인터페이스의 실제 구현체는 이번 포스팅에 다루지 않습니다. page와 perPage를 가진 요소로 상정했습니다.

 

PagedListAdapter 구현

class SampleActivity : AppCompatActivity() {

    private val newsAdapter by lazy { NewsAdapter() }

    private fun initNewsList() {
        recyclerView.adapter = newsAdapter
        recyclerView.layoutManager = LinearLayoutManager(this)
    }

    // PagedListAdapter 를 사용해서 Adapter를 구현했다.
    private class NewsAdapter : PagedListAdapter<News, NewsViewHolder>(SimpleDiffUtilCallback()) {

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
            val view = LayoutInflater.from(parent.context).inflate(
                R.layout.item_news,
                parent,
                false
            )
            return NewsViewHolder(view)
        }

        override fun onBindViewHolder(holder: NewsViewHolder, position: Int) {
            getItem(position)?.let { holder.onBind(it) }
        }
    }

    private class NewsViewHolder(view: View) : RecyclerView.ViewHolder(view) {

        fun onBind(item: News) {
            // News 로 화면을 초기화 합니다.
        }
    }

    companion object {
        private const val PAGE_SIZE = 10
    }
}

Paging 라이브러리를 사용하기 위해 우리가 할 첫 번째는 PagedListAdapter를 구현하는 것입니다.

submitList(PagedList <News>)라는 함수를 제공합니다. 해당 함수를 호출하면 내부 적으로 DiffUtil을 통해 아이템 갱신을 파악합니다.

이 구현체로 인해 Paging라이브러리가 뽑아내는 PagedList를 다루기 위해 우리가 할 일은 단순히 아이템을 PagedListAdapter에 건네는 것뿐입니다.

제가 사용한 SimpleDiffUtilCallback 구현체는 아래와 같습니다.

class SimpleDiffUtilCallback<T : Any> : DiffUtil.ItemCallback<T>() {
    override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
        return oldItem == newItem
    }

    override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
        return oldItem == newItem
    }
}

diffUtil을 좀 더 커스텀하면 데이터에 최적화된 리스트를 효율적으로 그릴 수 있습니다. 오늘은 DiffUtil을 다루는 포스팅이 아니므로 간단한 기본 구현체를 사용합니다.

 

DataSource 구현

class NewsDataSource(
    private val repository: NewsRepository,
    private val compositeDisposable: CompositeDisposable
) : ItemKeyedDataSource<Int, News>() {

    override fun getKey(item: News): Int {
        return item.page
    }

    override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<News>) {
        repository.loadNews(
            page = 1,
            perPage = params.requestedLoadSize
        )
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(onSuccess = {
                callback.onResult(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }

    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<News>) {
        repository.loadNews(
            page = params.key + 1,
            perPage = params.requestedLoadSize
        )
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(onSuccess = {
                callback.onResult(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }

    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<News>) {
        repository.loadNews(
            page = params.key - 1,
            perPage = params.requestedLoadSize
        )
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(onSuccess = {
                callback.onResult(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }
}

DataSource는 Paging 라이브러리에서 가장 중요한 요소입니다. DataSource 구현체 중 ItemKeyedDataSource를 사용하면 page-perPage 모델로 데이터를 받아올 수 있습니다.

 

최초에 loadInitial이 실행됩니다. page를 1로 세팅했는데 이는 작업할 때 page의 시작이 어디로 정의되어 있느냐에 따라서 변경해주시면 됩니다.

page의 증가나 감소가 Int타입이기 때문에 1 증가, 1 감소로 표현해서 loadAfter, loadBefore를 구현했습니다.

 

params.requestedLoadSize를 사용하면 우리가 데이터를 받아오기로 세팅한 PerPage값을 받아쓸 수 있습니다. 따라서 하나의 DataSource로 여러 개의 Config에 따른 통신을 진행할 수 있습니다. (1페이지당 30개의 요소를 받아오기, 1페이지당 50개의 요소를 받아오기를 하나의 DataSource로 하는 것이 가능한 것이지요.)

 

또한 DataSource내부의 구독들의 파괴를 적절한 타이밍에 하기 위해 인자로 compositeDisposable를 주입받도록 구현했습니다. 이는 Activity와 같이 라이프사이클이 존재하는 곳에서 생성해서 주입해 줄 것입니다.

 

DataSourceFactory 구현

class NewsDataSourceFactory(
    private val repository: NewsRepository,
    private val compositeDisposable: CompositeDisposable
) : DataSource.Factory<Int, News>() {

    override fun create(): DataSource<Int, News> {
        return NewsDataSource(repository, compositeDisposable)
    }
}

위에서 만든 DataSource를 코드 상에서 실제로 인스턴스화 하는 것은 아닙니다. 이를 인스턴스화 하기 위해서 DataSource.Factory를 구현해야 합니다. create를 오버라이드 해서 방금 만든 DataSource를 생성해줍니다.

 

RxPagedListBuilder를 이용한 연결

class SampleActivity : AppCompatActivity() {

    ...
    
    private lateinit var compositeDisposable: CompositeDisposable

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
    
        compositeDisposable = CompositeDisposable()
    }
    
    override fun onDestroy() {
        super.onDestroy()
        compositeDisposable.clear()
    }

    private fun initNewsList() {
        ...

        val config = PagedList.Config.Builder()
            .setPageSize(PAGE_SIZE)
            .setInitialLoadSizeHint(PAGE_SIZE)
            .setEnablePlaceholders(false)
            .build()

        val factory = NewsDataSourceFactory(repository, compositeDisposable)

        RxPagedListBuilder(factory, config)
            .setFetchScheduler(Schedulers.io())
            .setNotifyScheduler(AndroidSchedulers.mainThread())
            .buildFlowable(BackpressureStrategy.LATEST)
            .subscribeBy(onNext = {
                newsAdapter.submitList(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }

    ...

    companion object {
        private const val PAGE_SIZE = 10
    }
}

이제 남은 건 DataSourceFactory에서 생성하는 데이터를 PagedListAdapter에 붙이는 일입니다. 그리고 이를 하기 위해서 두 가지만 하면 됩니다.

 

  1. DataSourceFactory가 생성하는 데이터 받아오기
  2. 생성한 PagedList를 Adapter에 전달하기

RxJava 스트림을 사용하기 위해서는 RxPagedListBuilder를 사용하면 됩니다. buildFlowable을 사용해서 다음 페이지가 갱신될 때마다 Flowable 형태로 적절한 PagedList를 구독받을 수 있습니다.

 

그렇게 Flowable 형태로 onNext에서 전달받은 PagedList는 PagedListAdapter가 제공하는 submitList 함수에 전달만 해주면 됩니다. 그러면 Adapter가 내부적으로 스마트하게 리스트를 갱신하고 새로 추가되거나 변경된 데이터에 대해서만 화면을 다시 그려줍니다.

 

activity가 파괴될 때 기존의 데이터를 받아오는 구독은 무가치해집니다. 따라서 별도의 CompositeDisposable를 정의하고 onCreate와 onDestroy에서 각각 생성-파괴를 해줍니다. 그리고 이를 아까 만들었던 factory에 주입해줍니다.

전체 코드

data class News(
    val id: String,
    val page: Int
)

interface NewsRepository {

    fun loadNews(page: Int, perPage: Int): Single<List<News>>
}

class SampleActivity : AppCompatActivity() {

    lateinit var recyclerView: RecyclerView

    // 의존성 주입을 통해서 retrofit 구현체를 받아오세요.
    lateinit var repository: NewsRepository

    private val newsAdapter by lazy { NewsAdapter() }
    private lateinit var compositeDisposable: CompositeDisposable

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sample)

        recyclerView = findViewById(R.id.recycler_view)
        compositeDisposable = CompositeDisposable()
    }

    private fun initNewsList() {
        recyclerView.adapter = newsAdapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        val config = PagedList.Config.Builder()
            .setPageSize(PAGE_SIZE)
            .setInitialLoadSizeHint(PAGE_SIZE)
            .setEnablePlaceholders(false)
            .build()

        val factory = NewsDataSourceFactory(repository, compositeDisposable)

        RxPagedListBuilder(factory, config)
            .setFetchScheduler(Schedulers.io())
            .setNotifyScheduler(AndroidSchedulers.mainThread())
            .buildFlowable(BackpressureStrategy.LATEST)
            .subscribeBy(onNext = {
                newsAdapter.submitList(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }

    override fun onDestroy() {
        super.onDestroy()
        compositeDisposable.clear()
    }

    private class NewsAdapter : PagedListAdapter<News, NewsViewHolder>(SimpleDiffUtilCallback()) {

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
            val view = LayoutInflater.from(parent.context).inflate(
                R.layout.item_news,
                parent,
                false
            )
            return NewsViewHolder(view)
        }

        override fun onBindViewHolder(holder: NewsViewHolder, position: Int) {
            getItem(position)?.let { holder.onBind(it) }
        }
    }

    private class NewsViewHolder(view: View) : RecyclerView.ViewHolder(view) {

        fun onBind(item: News) {
            // News 로 화면을 초기화 합니다.
        }
    }

    companion object {
        private const val PAGE_SIZE = 10
    }
}

class NewsDataSourceFactory(
    private val repository: NewsRepository,
    private val compositeDisposable: CompositeDisposable
) : DataSource.Factory<Int, News>() {

    override fun create(): DataSource<Int, News> {
        return NewsDataSource(repository, compositeDisposable)
    }
}

class NewsDataSource(
    private val repository: NewsRepository,
    private val compositeDisposable: CompositeDisposable
) : ItemKeyedDataSource<Int, News>() {

    override fun getKey(item: News): Int {
        return item.page
    }

    override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<News>) {
        repository.loadNews(
            page = 1,
            perPage = params.requestedLoadSize
        )
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(onSuccess = {
                callback.onResult(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }

    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<News>) {
        repository.loadNews(
            page = params.key + 1,
            perPage = params.requestedLoadSize
        )
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(onSuccess = {
                callback.onResult(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }

    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<News>) {
        repository.loadNews(
            page = params.key - 1,
            perPage = params.requestedLoadSize
        )
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(onSuccess = {
                callback.onResult(it)
            }, onError = {
                Log.e("seungmin", it.message, it)
            })
            .addTo(compositeDisposable)
    }
}

 

참고 링크

Paging2: https://developer.android.com/topic/libraries/architecture/paging

 

페이징 라이브러리 개요  |  Android 개발자  |  Android Developers

페이징 라이브러리 개요 Android Jetpack의 구성요소. 페이징 라이브러리를 사용하면 작은 데이터 청크를 한 번에 로드하여 표시할 수 있습니다. 요청에 따라 일부 데이터를 로드하면 네트워크 대역

developer.android.com

Comments