Jetpack Paging3 - 3. PagingData 구성하기

15681 단어 jetpackPaging3Paging3

⚜️ 사용할 API 결정하기

PagingData를 구성하기 위해 사용할 API를 결정하는 방법에 대해 설명합니다.

우리는 지금까지 PagingSource를 통해 데이터를 가져오기 위한 방법을 정의하였습니다. 이를 위해 페이징된 데이터를 로드하기 위해 Key를 정의하였습니다. 지금부터는 Repository Layer에서 구현된 PagingSource 객체를 통해 반응형 Stream을 구성하기 위해 사용할 방법을 결정합니다.

Codelab에서는 Flow<RepoSearchResult>를 사용합니다. 단순히 Network 통신에 따른 결과를 RepoSearchResult sealed class를 통해 필터링해서 UI에 전달합니다. 이 방법은 Paging을 사용하지 않고 Network 통신에 따른 전체 데이터를 가져오는 방식입니다.

효과적으로 Paging을 사용하기 위해 Flow<RepoSearchResult>Flow<PagingData<Repo>>형태로 변경하게 됩니다. 이렇게 사용할 경우 PagingData를 발행하는 반응형 Stream형태로 생성되며 각각의 PagingData인스턴스는 페이징된 데이터(Repo) snapshot의 container를 나타냅니다.

Paging을 사용할 경우 Network Response의 sealed class를 대체하는 LoadResult를 사용하여 Response Success/Failure를 모두 모델링하게 되므로 더이상 sealed class가 필요하지 않게 됩니다.

아래 API중 하나를 선택할 수 있습니다. 대표적으로는 Kotlin Flow를 사용하기도 합니다.

  • Kotlin Flow - Pager.flow
  • LiveData - Pager.liveData
  • RxJava Flowable - Pager.flowable
  • RxJava Observable - Pager.observable

⚜️ Pager

Flow 형태의 반응형 Stream을 구성하기 위해 사용되는 Pager 클래스와 사용되는 parameter에 대해 알아봅니다.

Flow 형태의 반응형 Stream을 구성하기 위해 Pager를 Flow 형태로 변환하게 됩니다.

Pager 클래스는 다음과 같은 주 생성자를 포함합니다.

public class Pager<Key : Any, Value : Any>
@ExperimentalPagingApi constructor(
    config: PagingConfig,
    initialKey: Key? = null,
    remoteMediator: RemoteMediator<Key, Value>?,
    pagingSourceFactory: () -> PagingSource<Key, Value>
) { ... }

✅ PageConfig

PageConfig 클래스는 데이터 로드 시 대기시간, 초기 로드할 데이터의 크기등 PagingSource에서 데이터를 로드하는 방법에 관한 옵션을 설정하는 클래스입니다.

pageSize (Int)

반드시 정의해야 할 필수 parameter는 각 페이지에서 로드해야 하는 항목의 수를 가리키는 pageSize 입니다.

pageSize는 UI에 표시되는 항목 수의 몇 배여야 합니다. pageSize가 작을수록 메모리 사용량이 향상되고 데이터 로드 대기 시간이 줄어들지만 항목이 화면 전체를 차지하지 못하기 때문에 스크롤 시 깜빡이는 현상이 발생하기도 합니다. 반면, pageSize가 클수록 로드 효율은 좋지만 가져오는 목록이 업데이트 시 대기 시간이 늘어날 수도 있습니다. 따라서 공식 문서에서는 적절한 pageSize를 고려하도록 제시하고 있습니다. 대량의 항목을 표시해야 할 경우 pageSize = 100에 가깝게, 화면 대부분을 차지하는 항목의 경우 pageSize = 10 ~ 20이 적당하다고 말합니다. (애매모호한 설명이지만 비지니스 로직에 따라서 알맞게 정해야 할 것 같습니다.)

Codelab에서는 1 페이지 당 30개의 항목을 가져옵니다.

prefetchDistance (Int)

prefetchDistance는 현재 로드된 페이지에서 다음 페이지 로딩을 트리거할 시기를 결정하는 parameter입니다. 예를 들어, prefetchDistance = 50일 경우 이미 로드된 페이지가 50개의 항목까지 디스플레이 되었을때 다음 목록을 미리 로딩하게 됩니다.

enablePlaceHolders (Boolean)

가져올 페이지가 없는 경우 (PagingSourceNull일 경우) placeholder를 표시할 것인지를 결정합니다. enablePlaceHolders = true로 설정될 경우 PagingSource에서 아직 로드되지 않은 아이템의 갯수 (Null의 갯수)에 따라 placeholder를 표시하게 됩니다.

initialLoadSize (Int)

PagingSource 가 초기에 로드할 페이지의 크기를 정의합니다. 일반적으로는 기본 pageSize보다 크기 때문에 첫번째 페이지에서 로드할 항목은 사용자의 작은 스크롤에도 포함될 정도의 충분히 넓은 범위의 항목을 로드하게 됩니다. 기본적으로, pageSize의 3배정도를 로드하게 됩니다.

public val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER 
// pageSize * 3

아래는 Codelab에서 페이지 로딩 시 확인되는 로그입니다. 기본으로 설정된 PageSize는 30이고 초기에 가져오는 항목은 90개임을 확인할 수 있습니다.

maxSize (Int)

PagingData 객체에서 로드하고 있는 항목의 최대 갯수를 정의합니다.

Paging의 컨셉은 가져올 데이터를 한번에 가져오지 않고 일정 크기의 Chunk 단위로 나누어 가져오는 것입니다. Paging을 사용하여도 사용자가 스크롤하면서 언젠가 모든 항목을 가져오게 될것인데, maxSize는 이 항목을 화면에 모두 보유하고 있을 것인지를 결정합니다.

maxSize는 최소 pageSize + (2 * prefetchDistance)여야 합니다. 기본적으로는 Integer의 최대를 가지고 있고 이 경우, 로드한 페이지가 절대 drop되지 않습니다.

public val maxSize: Int = MAX_SIZE_UNBOUNDED
// MAX_SIZE_UNBOUNDED == Int.MAX_VALUE

jumpThreshold (Int)

이 parameter는 사실 정확히 알지 못해서 작성하지 못했습니다. 조금 더 공부하고 업데이트 하도록 하겠습니다 ㅠㅠ

✅ Key

Pager로 전달된 initialKey의 Type이 지정됩니다.

✅ RemoteMediator

로컬 캐싱을 위해 RemoteMediator 클래스를 구현한 클래스가 지정됩니다.

✅ PagingSource

PagingSource를 구현한 클래스가 지정됩니다.

⚜️ Pager를 Stream 형태로 변환

UI Layer에서 사용될 Stream 형태의 PagingData로 변환하기 위한 방법을 설명합니다.

Codelab에서는 아래와 같이 구현하고 있습니다.

fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
	return Pager(
    	config = PagingConfig(
        	pageSize = NETWORK_PAGE_SIZE,
        	enablePlaceholders = false
    	),
        pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

companion object {
	const val NETWORK_PAGE_SIZE = 30
}

Flow 형태로 변환하기 위해 Pager.flow를 사용하고 있습니다.

내부 코드를 살펴보겠습니다.

@OptIn(androidx.paging.ExperimentalPagingApi::class)
public val flow: Flow<PagingData<Value>> = PageFetcher(
	pagingSourceFactory = if (
    	pagingSourceFactory is SuspendingPagingSourceFactory<Key, Value>
    ) {
    	pagingSourceFactory::create
    } else {
        // cannot pass it as is since it is not a suspend function. Hence, we wrap it in {}
        // which means we are calling the original factory inside a suspend function
        {
        	pagingSourceFactory()
        }
    },
    initialKey = initialKey,
    config = config,
    remoteMediator = remoteMediator
).flow

기존에 구현한 PagingSource 클래스가 suspend 한지 확인합니다. suspend한 경우 생성한 PagingSource를 그대로 사용하게 되고 그렇지 않을 경우, { }로 매핑하게 됩니다. 이는 suspend 함수 내에서 원래 Factory를 호출하는 것을 의미합니다.

⚜️ ViewModel에서 사용법

반응형 Stream으로 변환한 데이터를 UI에서 사용하기 위해 어떻게 ViewModel에서 처리해야 하는지 설명합니다.

기존에는 Flow형태의 데이터를 LiveData<RepoSearchResult> 형태로 노출하게 됩니다. Paging을 사용할 경우 더 이상 FlowLiveData 형태로 변환하지 않아도 됩니다. ViewModel 클래스에는 기존에 데이터를 가져오고 검색에 따른 데이터를 캐싱하기 위해 LiveData<> 형태로 저장하던 것과 같은 역할을 하는 Flow<PagingData<Repo>> 멤버 변수가 포함됩니다.

Flow<PagingData>에는 CoroutineScope에서 항목을 캐시할 수 있는 함수인 cachedIn()가 있습니다. 즉, Flow에서 map 또는 filter와 같은 작업을 실행할 경우 작업 실행 후 cachedIn() 함수를 호출하여 작업을 다시 트리거하지 않도록 해야합니다.

class SearchRepositoriesViewModel(
    private val repository: GithubRepository
) : ViewModel() {
    private var currentQueryValue: String? = null
    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepo(queryString: String) : Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
            .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

기존 데이터를 currentSearchResult로 가지고 있고 검색에 따라 변경되는 데이터를 currentSearchResult에 저장하고 newResult로 반환하는 형태입니다. 같은 작업을 트리거하지 않도록 cachedIn() 함수를 사용하는 것을 볼 수 있습니다.

⚜️ References

Android Developers 공식 문서
찰스님 블로그

좋은 웹페이지 즐겨찾기