07
30

무한 스크롤을 구현해야되는데, 기존에 경험해봤던 리사이클러 뷰 스크롤 감지방법 말고 페이징 라이브러리를 사용해보기로 했다. 이번에 사용한 건 Paging3다.

https://developer.android.com/topic/libraries/architecture/paging/v3-overview?hl=ko

 

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

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 페이징 라이브러리 개요 Android Jetpack의 구성요소 Paging

developer.android.com

초기 세팅같은 건 문서보면 잘 나와있으니 나는 내가 적용해보면서 막혔던 부분 위주로 적겠다.\

# Paging 라이브러리

페이징 라이브러리는 Android Jetpack에 속해있는 라이브러리이며 대량의 데이터를 작은 덩어리로 나눠서 효율적으로 로드하고 표시해준다. 스크롤이벤트를 알아서 가져가 다음 페이지를 로드하기 때문에 메모리 사용량이 최적화 되어있다고 볼 수 있다. 

 

라이브러리를 쓰면서 무조건 만나게 될 컴포넌트 5가지가 있다.

  • `PagingSource`: 데이터 소스를 정의하는 곳이다. 페이지 별로 api나 db를 호출하는 게 여기에 해당한다.
  • `PagingConfig`: 페이징 동작을 구성한다. 페이지 크기는 얼마로 할건지 정하는 곳이 여기다.
  • `Pager`: PagingSource와 PagingConfig를 사용하여 PagingData 스트림을 생성해 flow로 쏘는 곳이다.
  • `PagingData`: 페이징된 데이터를 나타내는 컨테이너다. PagingData라고 해서 단일데이터로 착각하면 안된다.
  • `PagingDataAdapter`: RecyclerView Adapter에 달아주는 건데, DiffUtil이 사용되므로 사실상 ListAdapter인데 페이징이 달린걸로 이해하면된다. paging item이 nullable 한 것과, submitList가 아니라 submitData로 메서드가 바뀐 것 빼고는 거의 동일하다.

코드로 보기전에 아키텍쳐에 어떻게 적용했는지만 말하고 가겠다.

공식문서에 쓰여있는 페이징 라이브러리 아키텍쳐 적용법이다. 나는 PagingSource를 data layer에 두고, repository에서 Pager를 처리하게 했다. 공식문서와 조금 다르지만 현재 프로젝트에서 사용하고 있는 아키텍쳐가 `API -> DataSource -> Repository -> UseCase -> ViewModel`이라서 기존 패턴을 최대한 지키기 위해 선택한 방식이다.

 

내가 작성한 PagingSource 코드는 아래와 같다.

class FeedPagingSource(private val feedDataSource: FeedDataSource) :
    PagingSource<Int, Feed>() {
    override fun getRefreshKey(state: PagingState<Int, Feed>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Feed> {
        val nextPage = params.key ?: 1

        return when (val result = safeApiCall { feedDataSource.getFeed(nextPage, params.loadSize) }) {
            is ApiResult.Success -> {
                val response = result.data
                Timber.tag("pager").d("${response.items.firstOrNull()?.feedId}")

                LoadResult.Page(
                    data = response.items,
                    prevKey = if (nextPage == 1) null else nextPage - 1,
                    nextKey = if (response.items.isEmpty() || nextPage >= response.totalPages) null else nextPage + 1
                )
            }

            is ApiResult.Error -> {
                Timber.e(result.exception, "다음 페이지 못 불러왔음 $nextPage")
                LoadResult.Error(result.exception)
            }
        }
    }
}

PagingSource를 확장하면, getRefreshKey와 load는 필수로 override해야된다. load는 실제 데이터를 로드하는 로직을 구현하는 부분이다. 페이지 번호와 로드할 아이템 수를 받아 데이터를 가져온다. getRefreshKey는 새로고침 시 시작할 페이지 키를 결정하는 메서드다.

 

두 부분에 대해 좀 설명해보겠다. load먼저 보자.

`params.key`로 로드할 페이지 번호먼저 받는다. 데이터 소스에서 호출한 api 결과가 success면 `LoadResult.Page`를 반환하고, Error면 `LoadResult.Error`를 반환한다. 우리가 필요한 건 `LoadResult.Page`다. List를 data로 받고, 이전 페이지 키와 다음 페이지 키를 받는다. 나는 현재 페이지가 전체 페이지 번호랑 같을 때를 페이지 끝이라고 명시해뒀다.

 

getRefreshKey는 어떻게 돌아가는 지만 보고 넘어가겠다.

 

 

데이터 새로고침 시 어느 페이지부터 다시 로드할지 결정하는 메서드다. 약간 savedStateInstance느낌인데, 구성변경이 발생해도 같은 스크롤 위치를 보고있는 것이다. `state.anchorPosition`을 사용하여 현재 표시된 항목의 위치를 감지하고, `closestPageToPosition`으로 해당 위치에 가장 가까운 페이지를 찾는다. 이전 페이지의 다음 페이지 또는 다음 페이지의 이전 페이지를 refreshKey로 반환한다. refresh 시점을 지정해주는 것이다. 근데 이거도 만능은 아닌게 null을 반환해버리면 처음부터 로드해버린다.

 

이렇게 만든 PagingSource를 Repository에서 Pager를 구현할 때 넣어준다. api 호출을 Datasource에서 처리하고 있어서 다른 사람의 코드랑은 조금 다르다.

class FeedRepositoryImpl @Inject constructor(
    private val feedDataSource: FeedDataSource
) : FeedRepository {
    override fun getPagedFeed(): Flow<PagingData<Feed>> {
        return Pager(
            config = PagingConfig(
                pageSize = PAGE_SIZE,
                enablePlaceholders = false,
                initialLoadSize = PAGE_SIZE
            ),
            pagingSourceFactory = { FeedPagingSource(feedDataSource) },
        ).flow
    }

    companion object {
        private const val PAGE_SIZE = 10
    }
}

Pager가 구현된 모습이다. 처음에 불러울 페이지 사이즈를 정하고, pagingSourceFactory에 아까 만든 PagingSource를 전달해서 flow로 쏘면 끝이다.

 

PagingConfig이 중요한 부분이다. 자주 쓰이는 프로퍼티들을 가져와 보면

PagingConfig(
    pageSize = PAGE_SIZE,
    enablePlaceholders = false,
    initialLoadSize = PAGE_SIZE * 2,
    prefetchDistance = PAGE_SIZE,
    maxSize = PAGE_SIZE * 5
)

이걸 다 정의해줘야 메모리를 효율적으로 관리한다고 볼 수 있다.

  • `pageSize`:
    각 페이지 로드 시 가져올 항목 수를 의미한다. 이게 작으면 작을 수록 네트워크 요청 빈도가 늘어난다.
  • `enablePlaceholders`:
    아직 로드되지 않은 항목에 대한 플레이스홀더 설정하는 부분이다.
  • `initialLoadSize`:
    처음 로드 시 가져올 항목의 수를 정의한다. 너무 많은 데이터를 초기 로드때 가져오면 로딩시간이 길어지지만 적당히 초기 로딩을 해두면 UX 측면에서 좋다. 보통 pageSize의 2배나 3배정도로 잡는 것 같았다.
  • `prefetchDistance`:
    스크롤이 끝에 닿기 전에 다음페이지를 미리 로드하는 걸 의미한다. 이건 pageSize랑 같게 가져가는 것 같다.
  • `maxSize`:
    메모리에 유지할 최대 항목수를 정의한다. 매번 불러오게 하지않고, 메모리 최대사용량을 딱 정해놓고 쓰는 방식이라 어떻게 보면 효율적이라고 할 수 있겠다.

이제 뷰모델에서 스트림을 받아줘야한다.

private val _pagedFeed = MutableStateFlow<PagingData<Feed>?>(null)
val pagedFeed: StateFlow<PagingData<Feed>?> = _pagedFeed

fun getFeed() {
    viewModelScope.launch {
        getPagedFeedUseCase().cachedIn(viewModelScope).collectLatest { pagedData ->
            _pagedFeed.value = pagedData
        }
    }
}

PagingData 컨테이너로 감싸준다. 단어가 Data라서 하나의 데이터라고 생각할 수 있는데, 컨테이너다.

getPagedFeedUseCase는 아까 만든 RepositoryImpl을 invoke해주는 Usecase다.  cachedIn은 Paging에서 최적화를 도와주는 도구 중 하나다. 페이징 데이터를 viewModelScope가 살아있을 때 캐싱해두는 역할을 한다. 이제 이걸 recyclerview의 PagingDataAdapter에 넣어주면 끝난다. 

 

어댑터부분은 따로 뭘 볼게 없어서 생략하겠다.

 

페이징을 사용하면 아무래도 로드하는 시간이 있는데, 이걸 이용해서 로딩 - 로딩완료 화면을 구현할 수 있다.

feedAdapter.loadStateFlow.flowWithLifecycle(viewLifecycleOwner.lifecycle)
    .onEach { loadStates ->
        val isLoading = loadStates.source.refresh is LoadState.Loading
        if (!isLoading) loading.dismiss() else loading.show()
	}.launchIn(viewLifecycleOwner.lifecycleScope)

이렇게 해주면, adapter에 들어간 LoadState가 Loading일때 loading dialog를 띄워주고,  아니라면 dialog를 꺼준다.

아래는 구현한 모습이다.

 

 

도움이 됐다면 댓글이나 공감 버튼 한 번씩 누르고 가주세요!

 

반응형
COMMENT