이동식 저장소

Paging 구현 (기초) 본문

Primary/Android

Paging 구현 (기초)

해스끼 2023. 4. 13. 12:50

코드랩으로 연습해 보자. 이 글은 아래의 코드랩을 요약하여 작성하였다.

 

Android Paging 기본사항  |  Android Developers

이 Codelab에서는 목록을 표시하는 앱에 Paging 라이브러리를 통합합니다. Paging 라이브러리를 사용하면 로컬 저장소에서나 네트워크를 통해 대규모 데이터 세트의 데이터 페이지를 로드하고 표시

developer.android.com

의존성 정의

Paging 라이브러리 의존성을 정의하자.

// architecture components
implementation("androidx.paging:paging-common-ktx:3.1.0")
implementation("androidx.paging:paging-runtime-ktx:3.1.0")

Data source 정의

페이징은 PagingSource에서 시작된다. 각 페이지를 어떻게 받아올지 PagingSource에서 정의해야 한다. 

 

코드랩에서 사용하는 데이터 객체는 Article이다. Article을 불러오는 PagingSource를 정의해 보자.

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

페이징을 구현하기 전에, 다음 세 가지를 결정해야 한다.

  1. 각 페이지의 key: 페이지에서 불러올 데이터의 범위를 어떻게 결정할 지 생각해야 한다. 코드랩에서는 연속된 정수로 주어지는 Article.id를 key로 사용하여, 특정 id값부터 50개를 불러오도록 구현하였다.
  2. 로드할 데이터: 말 그대로이다. 여기서는 Article이 되겠다.
  3. 데이터를 가져올 곳: 로컬 DB, 서버 내지는 가능한 모든 source가 될 수 있다. 여기서는 페이징 기초를 연습하고 있으므로 간단하게 in-memory 데이터로 구현하자.

PagingSource를 implement하는 ArticlePagingSource를 정의하자.

class ArticlePagingSource : PagingSource<Int, Article>() { 
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? { 
        TODO("Not yet implemented")
    }
}

PagingSource 인터페이스에는 위와 같이 두 개의 함수가 있다. 

 

load() 함수는 매개변수 params에 상응하는 데이터를 불러오는 함수이다. params에는 페이지의 key, 불러올 데이터의 개수 등이 포함된다. 

  • 페이지의 Key: load() 함수가 처음 호출된다면 LoadParams.keynull이다. 따라서 key가 null일 때에는 맨 처음 키 값을 직접 정의해야 한다. 코드랩에서는 맨 처음 id값, 즉 0으로 정의했다.
  • 불러올 데이터의 개수: 말 그대로.

load() 함수는 sealed class인 LoadResult를 반환한다. LoadResult는 다음의 세 하위 타입 중 하나가 될 수 있다.

  1. LoadResult.Page: 페이지를 정상적으로 불러온 경우에 반환한다.
  2. LoadResult.Error: 페이지를 불러오던 중 에러가 발생했을 때 반환한다.
  3. LoadResult.Invalid: PagingSource가 더 이상 데이터의 무결성을 보장할 수 없을 때 반환한다. 갑자기 DB 스키마가 바뀌었다던가.. 물론 흔한 경우는 아니다.

LoadResult.Page를 만들려면 아래의 세 가지 값이 필요하다.

  1. data: 데이터를 담고 있는 List이다.
  2. prevKey: 이 페이지의 이전 페이지를 나타내는 key이다.
  3. nextKey: 이 페이지의 다음 페이지를 나타내는 key이다.

아래의 두 개는 optional argument이다.

  1. itemsBefore: 이 페이지의 이전 페이지를 로딩할 때 보여줘야 할 placeholder의 개수이다.
  2. itemsAfter:이 페이지의 다음 페이지를 로딩할 때 보여줘야 할 placeholder의 개수이다.

prevKeynextKey의 값은 더 이상 데이터가 없을 때 null이다. 페이지당 불러올 데이터의 수를 pageSize라고 하자.

 

key0인 페이지는 첫 번째 페이지이므로 prevKeynull이고, 그렇지 않다면 prevKeykey에서 pageSize를 뺀 값과 같다. 물론 이 값이 0보다 작으면 안 된다.

 

nextKey는 현재 keypageSize를 더한 값과 같다.

 

따라서 load() 함수를 다음과 같이 구현할 수 있다.

private const val STARTING_KEY = 0
private val firstArticleCreatedTime = LocalDateTime.now()

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
    val start = params.key ?: STARTING_KEY
    val range = start.until(start + params.loadSize)

    return LoadResult.Page(
        data = range.map { number ->
            Article(
                id = number,
                title = "Article $number",
                description = "This describes article $number",
                created = firstArticleCreatedTime.minusDays(number.toLong())
            )
        },

        prevKey = when (start) {
            STARTING_KEY -> null
            else -> ensureValidKey(key = range.first - params.loadSize)
        },
        nextKey = range.last + 1
    )
}

이제 getRefreshKey()를 구현하자. 이 함수는 PagingSource의 데이터가 바뀌어 페이지를 다시 불러와야 할 때 호출된다. 예를 들어 데이터베이스에서 값이 삭제되는 경우가 있다. 이렇게 페이지를 다시 불러오는 과정을 invalidation이라고 한다. Invalidate가 발생하면 PagingSource가 새로 만들어지고, 새 데이터가 PagingData의 형태로 UI에 전달된다. Invalidate는 밑에서 더 공부할 것이다.

 

PagingSource로부터 데이터를 가져와야 할 때, 데이터가 로드된 후에도 스크롤 위치가 바뀌지 않도록 getRefreshKey()를 호출하여 적절한 key를 얻는다. 반환된 key 값이 다음 load() 호출에 사용된다. 이제 적절한 key를 반환하는 코드를 작성해 보자.

 

Invalidate 이후 처음으로 페이지를 불러올 때, 이전 스크롤 위치와 너무 먼 페이지를 로드하면 스크롤 위치가 바뀔 수도 있다. 스크롤 위치를 유지하기 위해 적당히 가까우면서 화면을 가득 채울 정도로 충분한 아이템을 로드할 key값을 반환해야 한다.

 

대략 마지막 위치에서 페이지의 절반 정도 올라가면 될 것 같다.

override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
    val anchorPosition = state.anchorPosition ?: return null
    val article = state.closestItemToPosition(anchorPosition) ?: return null
    return (article.id - (state.config.pageSize / 2)).coerceAtLeast(0)
}

state.anchorPosition은 마지막으로 로드된 아이템의 인덱스를 의미한다. 이 위치에서 가장 가까이 있는 아이템을 고르고, 해당 아이템에서 pageSize의 절반 정도 올라간 값을 key로 반환한다. 이렇게 하면 데이터가 새로고침된 후에도 스크롤 위치가 바뀌지 않을 것 같다.

 

이제 데이터를 UI에 연결해 보자.

Repository, ViewModel 정의

ViewModel에서 페이징 데이터를 제공할 때는 PagingData<Article>의 stream으로 제공해야 한다. Stream이란 데이터의 흐름을 나타낼 수 있는 객체로, Flow, LiveData, RxJava Flowable 등이 있다. 여기서는 Flow를 사용해 보자.

 

Pager 클래스를 통해 PagingData의 스트림을 만들 수 있다. Pager를 만들려면 다음 매개변수가 필요하다.

  • PagingConfig: PagingSource로부터 데이터를 불러올 때 사용하는 매개변수이다. 필수 파라미터는 불러올 데이터의 개수 하나뿐이고, 필요에 따라 enablePlaceholder나 다른 매개변수를 사용할 수 있다. 개인적으로는 enablePlaceholder를 제외하면 데이터가 어지간히 크지 않은 이상 사용하지 않을 것 같다.
    • 불러올 데이터의 크기는 화면을 가득 채울 정도로 충분해야 한다. 스마트폰 뿐 아니라 태블릿이나 크롬북 같이 큰 화면도 가득 채울 정도로 넉넉해야 한다. 동시에 로딩 성능을 고려하여 적당히 작은 값으로 설정해야 한다.
    • Paging 라이브러리는 기본적으로 로드된 데이터를 모두 메모리에 저장한다. 메모리를 절약하고 싶다면 데이터를 저장할 개수(PagingConfig.maxSize)를 지정하면 되지만, 데이터가 자주 삭제되면 잠재적으로 더 많은 요청을 보내야 한다. 웬만하면 꽤 큰 값으로 설정하는 편이 좋다.
  • PagingSource를 생성하는 람다식: ArticlePagingSource를 생성하는 코드를 작성하면 된다.

Repository 정의

ArticlePagingSource를 반환하는 Repository를 정의하자. 

class ArticleRepository() {
    fun articlePagingSource() = ArticlePagingSource()
}

Paging 라이브러리에서 로드된 데이터를 메모리에 모두 저장하고, 상황에 따라 데이터를 불러온다. Repository는 이 정도면 충분하고, 이제 ViewModel로 가자.

ViewModel에서 페이징 데이터 정의

우선 불러올 데이터의 개수를 정의하자.

private const val ITEMS_PER_PAGE = 50

이제 PagingData<Article>의 스트림을 정의하자. PagingDataPager로부터 얻을 수 있다. Pager를 생성하려면 위에서 말했던 두 개의 매개변수가 필요하다.

class ArticleViewModel(...) : ViewModel() {

   val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    ).flow
}

기초 실습이므로 placeholder는 생략한다. pagingSourceFactory에는 ArticlePagingSource를 반환하는 코드를 작성하면 된다. PagingSource는 재사용되지 않기 때문에 매번 새로운 객체를 반환해야 한다. 

 

재사용 불가능?

 

페이지를 요청할 때마다 매번 새로운 PagingSource가 만들어진다. 로드할 데이터의 상태를 쉽게 관리하기 위해, 하나의 PagingSource가 하나의 페이지만을 로드하도록 강제하기 위해서이다. 

 

맨 마지막 flowPagingData의 스트림을 Flow로 반환하라는 의미이다. liveDataflowable도 사용할 수 있다. 

 

Configuration change가 발생했을 때에도 데이터를 보존하기 위해 cachedIn() 함수를 호출하자. 참고로 코드랩에 따르면 stateIn()이나 shareIn()을 사용해서는 안 된다고 한다. 그런데 그 이유가 Flow<PagingData>는 cold flow가 아니기 때문이라는데, 공식 문서에는 cold flow라고 나와있다. 뭐지?

뭐.. 쓰지 말라면 쓰지 말아야지..

 

Flow<PagingData>map이나 filter 같은 연산을 할 수도 있지만, cachedIn을 반드시 마지막에 호출해야 한다. 같은 연산을 반복하지 않기 위해서이다. 원문에서는 다음과 같이 언급하고 있다.

 

Also if you're doing any operations on the Flow, like map or filter, make sure you call cachedIn after you execute these operations to ensure you don't need to trigger them again. In other words, cachedIn should be used as a terminal operator.

 

완성된 ViewModel 코드는 다음과 같다.

class ArticleViewModel(
    private val repository: ArticleRepository,
) : ViewModel() {

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        .cachedIn(viewModelScope)
}

마지막으로 Flow<PagingData를 다른 Flow와 combine하면 안 된다. PagingData는 다른 Flow와 독립적으로 consume되어야 하기 때문이다.

RecyclerViewAdapter가 PagingData를 받아들일 수 있게 수정

다른 코드는 그대로 두고, `RecyclerView의 adapter를 PagingDataAdapter로만 바꾸면 된다. 참 쉽죠?

class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
    // body is unchanged
}

UI에서 PagingData를 collect하기

Activity에서 Flow<PagingData>를 collect해 보자. collect 대신 collectLatest를, submitList() 대신 submitData()를 호출하면 된다.

class ArticleActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                items.collectLatest {
                    articleAdapter.submitData(it)
                }
            }
        }
    }
}

완료!

번외: 데이터 로딩 상태를 표시하기

유저에게 페이징 데이터를 불러오고 있다는 사실을 알려주는 편이 좋다. Paging 라이브러리를 사용하면 로딩 상태를 편리하게 보여줄 수 있다.

 

모든 로딩 상태는 CombinedLoadStates 객체로부터 얻을 수 있다. 지금은 ArticlePagingSource의 로딩 상태만 사용할 거라  LoadStates 타입의 CombinedLoadStates.source 필드만 보면 된다.

 

LoadStates는 세 가지 LoadState(로딩 상태)를 제공한다.

  • LoadStates.append: 현재 위치 뒤의 데이터를 가져오고 있는지?
  • LoadStates.prepend: 현재 위치 앞의 데이터를 가져오고 있는지?
  • LoadStates.refresh: 최초(initial)로 데이터를 가져오고 있는지?

LoadState는 세 가지 값을 가질 수 있다.

  • LoadState.Loading: 로딩 중이다.
  • LoadState.NotLoading: 로딩 중이 아니다.
  • LoadState.Error: 에러가 발생했다.

지금은 로딩 중인지만 확인하면 되므로 현재 값이 LoadState.Loading일 때 사용자에게 로딩 중이라는 UI를 보여주면 된다. 

 

CombinedLoadStatePagingDataAdapter로부터 얻을 수 있다. PagingDataAdapter로부터 얻는 이유는 RecyclerView와의 상호작용에서 페이지 로딩 작업이 만들어지기 때문이다. 스크롤을 끝까지 내렸을 때 페이지를 더 가져와야 하는데, 스크롤 위치를 PagingDataAdapter에서 관리하기 때문이다.

 

다음과 같이 로딩 상태를 가져올 수 있다. LoadState는 sealed class이기 때문에 is 연산자로 비교해야 한다.

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

    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            articleAdapter.loadStateFlow.collect {
                binding.prependProgress.isVisible = it.source.prepend is LoadState.Loading
                binding.appendProgress.isVisible = it.source.append is LoadState.Loading
            }
        }
    }
}

binding.preprendProgressbinding.appendProgress는 모두 LinearProgressIndicator이다. 

 

이제 ArticlePagingSource에서 실제로 데이터를 로드하는 것처럼 delay를 주면...

private const val LOAD_DELAY_MILLIS = 3_000L

class ArticlePagingSource : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = startKey.until(startKey + params.loadSize)

        if (start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
        
        // load...
    }
}

LoadingIndicator가 제대로 보인다!

요약

  • PagingSource, Pager, PagingData를 정의했다.
  • ViewModel에서 Flow<PagingData>cachedIn(viewModelScope)했다.
  • Flow<PagingData>를 UI에서 collectLatest했다.
  • PagingDataAdapter.loadStateFlow로부터 로딩 상태를 추적하였다.

끝! 

'Primary > Android' 카테고리의 다른 글

Android 개발 과정에서 Java가 사용되는 곳  (0) 2023.09.23
Parcelable vs. Serializable  (0) 2023.07.22
Paging overview  (0) 2023.04.12
lifecycleOwner vs. viewLifecycleOwner  (0) 2023.03.17
Android Studio Giraffe 신기능 정리  (0) 2023.02.18
Comments