안드로이드 Paging3 컴포넌트 정복하기 – Part1

Paging3는 Jetpack 라이브러리 중 하나로 다양한 데이터 소스로 부터 데이터를 나누어 효과적으로 로딩할 수 있게 한다. Paging3는 네트워크 또는 로컬 데이터베이스에서 쉽게 데이터를 불러올 수 있도록 도와주기 때문에 개발 시간을 단축시켜 준다. Paging3는 구글에서 추천하는 안드로이드 앱 설계방식과 그리고 다른 Jetpack 컴포넌트와 잘 동작 할 수 있도록 설계되었다. 코틀린으루 우선 개발되고, 코루틴 및 Flow와 같은 새로운 방식의 비동기 작업으로 동작한다. 물론 RxJava 및 LiveData도 지원한다.

Paging3 라이브러리의 장점

  • 페이징 된 데이터를 메모리 내에 캐싱한다. 이렇게 하면 앱이 페이징 된 데이터를 사용하기 때문에 시스템 리소스를 효율적으로 사용하게 된다.
  • 기본적으로 제공하는 요청 중복 방지를 통해 앱이 네트워크 대역폭과 시스템 리소스를 효율적으로 사용한다.
  • 사용자가 로드 된 데이터의 끝으로 스크롤 할 때 데이터를 자동으로 요청하도록 하는 RecyclerView 어댑터를 구성할 수 있다.
  • 코틀린의 코루틴 및 Flow를 지원하며, 또한 LiveData 와 RxJava를 지원한다.
  • 기본적으로 새로고침 및 재시도를 포함하여 에러까지도 다룰수 있는 방법을 제공한다.

Paging3 의존성 추가

페이징 컴포넌트를 추가하기위해 , app모듈 build.gradle에 다음과 같은 코드를 추가 한다.

Note: Paging 3.0은 현재 Alpha 단계입니다.

dependencies {
  def paging_version = "3.0.0-alpha07"

  implementation "androidx.paging:paging-runtime:$paging_version"

  // alternatively - without Android dependencies for tests
  testImplementation "androidx.paging:paging-common:$paging_version"

  // optional - RxJava2 support
  implementation "androidx.paging:paging-rxjava2:$paging_version"

  // optional - Guava ListenableFuture support
  implementation "androidx.paging:paging-guava:$paging_version"
}

라이브러리 내부 구조

페이징 라이브러리는 총 3개의 계층으로 구성된다.

  • Repository 계층
  • ViewModel 계층
  • UI 계층

Repository 계층

리파지토리 계층에서 주된 페이징 라이브러리 구성요소는 바로 PagingSource다. 각 PagingSource 객체는 데이터 소스 및 소스에서 데이터를 가져오는 방법에 대해 정의한다. PagingSource객체는 단일 소스로부터 네트워크 소스 및 로컬 데이터를 데이터를 로드할 수 있다.

페이징 라이브러리에서 사용할 만한 또 다른 구성요소로 RemoteMediator가 있다. RemoteMediator 객체는 로컬 데이터베이스를 캐시로 사용하는 네트워크 데이터 소스와 같은 계층화 된 데이터 소스로부터 페이징을 다룬다.

ViewModel 계층

Pager 구성요소는 PagingSource 객체 및 PagingConfig 구성 객체를 기반으로, 반응형 스트림으로 노출되는 PagingData 인스턴스를 생성하기 위한 public API를 제공한다.

ViewModel 계층을 UI에 연결하는 구성 요소는 PagingData다. PagingData 객체는 페이징 된 데이터의 스냅샷을 위한 컨테이너다. 이는 PagingSource 객체를 쿼리하고 결과를 저장한다.

UI 계층

UI 계층의 주된 페이징 라이브러리 구성요소는 바로 PagingDataAdapter이다. RecyclerView.Adapter의 하위 클래스로 페이징 된 데이터를 다룬다.

PagingDataAdapter를 사용하는 대신, AsyncPagingDataDiffer를 사용하여 커스텀 Adapter를 만들 수도 있다.

Paging3 주요 클래스 요약

PagingSource

네트워크 또는 데이터베이스에서 페이징 된 데이터를 로드를 담당하는 추상 클래스로 이를 구현하려면 페이지 키 타입을 정의해야 한다.. PagingSource에서 로드 된 데이터는 PagingData 인스턴스로 관리된다.

PagingData는 데이터가 추가적으로 로드 됨에 따라 커질 수 있다. 그러나 기존에 로드 된 데이터가 갱신되지는 않는다. 원본 데이터들이 수정된다면 이를 반영하기 위해 반드시 새로운 PagingSource / PagingData 쌍이 생성되어야 한다.

RemoteMediator

RemoteMediator(원격 중재자)라는 이름에서 유추할 수 있듯이 로컬 데이터베이스 및 네트워크로부터 페이징 된 데이터를 로드하는 책임이 있다. 로컬 데이터베이스를 메인 데이터 소스로 활용하는 경우에 페이징을 구현하는 대표적인 좋은 방법이다. 이 방법은 좀 더 신뢰할 수 있고 낮은 오류를 기대할 수 있다.

RemoteMediator는 Pager를 생성할 때 선택적으로 추가하여 다음과 같은 이벤트들을 제어할 수 있다.

  • 스트림 초기화
  • UI에서 전달받는 LoadType.REFRESH 신호
  • PagingSource는 현재 데이터의 경계를 알려주는 신호인 PagingSource.LoadResult 반환한다. 예를 들면, 첫번째 페이지에 도달하면 LoadType.PREPEND을 반환하고, 마지막 페이지에 도달하면 LoadType.APPEND를 반환한다.

Pager

페이징에서 주된 진입점으로 PagingData를 발행하는 반응형 스트림을 생성한다. 각 PagingData는 페이징 된 데이터 스냅샷을 나타내며, Pager로부터 Flow, Observable, LiveData 형태로 반환된다. 원본 데이터가 변경된다면 PagingData도 새로운 인스턴스로 나타내야 한다.

PagingSource.invalidate()AsyncPagingDataDiffer.refresh() 또는 PagingDataAdapter.refresh() 를 호출 하는 것은 원본 데이터 셋이 변경되었으니 새로운 PagerData와 PagingSource 쌍을 생성할 것을 알려준다.

PagingData

페이징 된 데이터를 담아두는 역할 을 한다. 최종적으로 반환되는 데이터 타입으로 일반적으로 PagingDataAdapter가 이를 전달받는다.

PagingConfig

Pager 객체를 생성하는데 필요한 필수 요소로, 페이징에 관한 설정을 담당한다. 페이징 하는 데이터 크기 및 placeholder 사용 유무 등 PagingSource를 구성하는 방법을 정의한다.

PagingDataAdapter

주된 UI 구성요소로 RecyclerView에 데이터를 나타내는 책임을 갖는다. 또한 PagingData를 입력 받아 내부적으로 언제 데이터를 추가적으로 로드해야 할 지 관찰한다. PagingDataAdapter는 백그라운드에서 DiffUtil을 사용하여 데이터를 정제 한 뒤에 데이터를 불러오기 때문에 최종적으로 UI 쓰레드에서 새로운 아이템들을 추가할 때 끊김없이 부드럽게 나타난다.

페이징 된 데이터 로드하여 보여주기

페이징 라이브러리는 큰 데이터 셋으로부터 페이징 된 데이터를 화면에 나타내는 효율적이고 강력한 방법을 제공한다. 다음 예제를 통해 어떻게 페이징 라이브러리가 스트림을 구축하고 네트워크 데이터 소스로부터 페이징 된 데이터를 가져와 RecyclerView에 나타내는지 살펴보자.

데이터 소스 정의하기

앞에서 살펴본 내용 가장 먼저 해야할 일은 데이터 소스를 식별하기 위해 PagingSource 구현을 정의하는 것이다. PagingSource API 클래스에는 해당 데이터 소스에서 페이징 된 데이터를 검색하는 방법을 나타내기 위해 재정의해야하는 load() 메서드가 포함되어 있다.

코틀린 코루틴을 통해 비동기 로딩을 사용하기 위해 PagingSource 클래스를 직접 사용하자. 페이징 라이브러리는 다른 비동기 프레임워크의 클래스들을 지원한다.

Key-Value 타입 지정하기

PagingSource<Key, Value>에는 Key 및 Value의 두 가지 타입 매개 변수가 있다. Key는 데이터를 로드하는 데 사용되는 식별자를 정의하며, Value는 데이터 자체의 타입이다. 예를 들어 Int타입을 갖는 페이지 번호를 Retrofit에 전달하여, 네트워크에서 페이징 된 User 객체를 로드하는 경우 Key 타입으로 Int를 선택하고 Value 타입으로 User를 지정할 수 있다.

PagingSource 정의하기

다음 예제는 페이지 번호별로 페이징 된 아이템을 로드하는 PagingSource를 구현하는 모습을 보여준다. Key 타입은 Int이고 Value 타입은 User다.

class ExamplePagingSource(
    val backend: ExampleBackendService,
    val query: String
) : PagingSource<Int, User>() {
  override suspend fun load(
    params: LoadParams<Int>
  ): LoadResult<Int, User> {
    try {
      // key가 정의되지 않았다면 첫번째 페이지에서 시작한다
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber)
      return LoadResult.Page(
        data = response.users,
        prevKey = null, // 이전 페이지는 불러오지 않음
        nextKey = response.nextPageNumber
      )
    } catch (e: Exception) {
      // 네트워크 에러 등이 발생하는 경우 
      // 블록 내에서 에러를 핸들링하고, LoadResult.Error를 반환한다.
    }
  }
}

일반적인 PagingSource 구현은 생성자에 제공된 매개 변수를 load() 메서드에 전달하여 쿼리에 적합한 데이터를 로드하게 된다. 위의 예에서 나타내는 매개 변수의 의미는 다음과 같다.

  • backend: 데이터를 제공하는 백엔드 서비스 인스턴스
  • query: backend 서비스에 전송할 검색어

LoadParams 객체는 로드 작업이 수행되는 것에 대한 정보들을 포함하고 있따. 이는 로딩되는데 필요한 키 그리고 로드할 아이템의 갯수가 포함된다.

LoadResult 객체는 로드 작업의 결과를 포함한다. LoadResult는 sealed class로, load() 콜이 성공적으로 호출되냐에 따라 다음 둘 중 하나를 취한다.

  • 로드가 성공적으로 이루어진 경우 LoadResult.Page 객체를 반환한다.
  • 로드가 실패한 경우 LoadResult.Error 객체를 반환한다.

다음 나오는 그림은 예제에서 load() 기능이 어떻게 Key를 전달 받고, 후속 데이터를 로드 하기 위한 키를 제공하는지 보여준다.

에러 다루기

데이터를 로드하기 위한 요청들은 몇가지 이유들로 인해 실패할 수 있다. 특히 네트워크를 통해 로드할 때 더욱 그렇다. load() 메서드에서 LoadResult.Error 객체를 반환하여 로드 중에 발생한 오류를 보고하자.

예를 들면, 앞에서 다룬 ExamplePagingSource에서는 다음과 같이 load메서드에서 로드시 발생하는 에러들을 잡아내어 보고 할 수 있다.

catch (e: IOException) {
  // IOException for network failures.
  return LoadResult.Error(e)
} catch (e: HttpException) {
  // HttpException for any non-2xx HTTP status codes.
  return LoadResult.Error(e)
}

PagingSource는 LoadResult.Error 객체를 수집하고 UI에게 전달하기 때문에, 상황에 맞춰 구현할 수 있다.

PagingData 스트림 설정하기

 PagingSource 구현에서는 페이징 된 데이터 스트림이 필요하다. 일반적으로 ViewModel에서 데이터 스트림을 설정한다. Pager 클래스는 PagingSource에서 PagingData 객체의 반응형 스트림을 노출하는 메서드를 제공한다. Paging 라이브러리는 Flow, LiveData, RxJava의 Flowable 및 Observable 타입을 포함한 여러 스트림 타입 사용을 지원한다.

Pager 인스턴스를 생성하여 반응형 스트림을 설정할 때, 반드시 PagingConfig 인스턴스 그리고 Pager에게 어떻게 PagingSource 구현을 얻을지에 대한 함수도 함께 제공해야 한다.

val flow = Pager(
  // Configure how data is loaded by passing additional properties to
  // PagingConfig, such as prefetchDistance.
  PagingConfig(pageSize = 20)
) {
  ExamplePagingSource(backend, query)
}.flow
  .cachedIn(viewModelScope)

cachedIn() 연산자는 데이터 스트림을 공유 가능하게 만들고, 제공된 CoroutineScope를 사용하여 로드 된 데이터를 캐시한다. 이 예제는 Lifecycle lifecycle-viewmodel-ktx 아티팩트에서 제공하는 viewModelScope를 사용한다.

Pager 객체는 PagingSource 객체에서 load() 메서드를 호출하여 LoadParams 객체를 제공하고LoadResult 객체를 반환한다.

RecyclerView 어댑터 정의하기

RecyclerView 에서 페이징 된 데이터를 받으려면 어댑터도 함께 설정해야 한다. Paging 라이브러리는 이를 위해 PagingDataAdapter 클래스를 제공한다.

먼저 PagingDataAdapter를 확장하는 클래스를 만들자. 이 예제의 UserAdapter는 PagingDataAdapter를 확장하여 User 타입의 리스트에 대한 RecyclerView 어댑터를 제공하고 UserViewHolder를 뷰 홀더로 사용한다.

class UserAdapter(diffCallback: DiffUtil.ItemCallback<User>) :
  PagingDataAdapter<User, UserViewHolder>(diffCallback) {
  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ): UserViewHolder {
    return UserViewHolder(parent)
  }

  override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
    val item = getItem(position)
    // Note that item may be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item)
  }
}

UI에서 페이징 된 데이터 나타내기

이제 PagingSource를 정의하고, 앱에서 PagingData 스트림을 생성하는 방법을 만들고 PagingDataAdapter를 정의 했으므로 이러한 요소를 함께 연결하고 Activity에 페이징 된 데이터를 표시 할 준비가 끝났다.

다음 단계를 Activity의 onCreate() 또는 Fragment의 onViewCreated 메서드에서 수행하자

  1. 자신만의 PagingDataAdapter 클래스 인스턴스를 생성하자
  2. PagingDataAdapter 인스턴스를 RecyclerView에게 제공하여, 페이징 된 데이터가 화면에 나올 수 있도록 한다.
  3. PagingData 스트림을 관찰하고 각 생성된 값들을 어댑터에게 submitData() 메서드로 전달한다.
val viewModel by viewModels<ExampleViewModel>()

val pagingAdapter = UserAdapter(UserComparator)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter

// Activity는 lifecycleScope를 바로 사용할 수 있지만, 
// Fragment는 viewLifecycleOwner.lifecycleScope를 사용해야 한다.
lifecycleScope.launch {
  viewModel.flow.collectLatest { pagingData ->
    pagingAdapter.submitData(pagingData)
  }
}

자동으로 필요할때 데이터 소스로부터 다른 페이지를 로드하여, 페이징 된 데이터들이 RecyclerView 목록에 나타나게 된다.

로딩 상태 나타내기

페이징 라이브러리는 LoadState 객체를 통해 UI에서 로딩 상태를 나타낼 수 있다. LoadState는 다음 나오는 3개의 상태중 하나의 형식만 취한다.

  • 활성화 된 로딩 작업이 없고 에러도 없다면, LoadState는 LoadState.NotLoading이 된다.
  • 활성화 된 로드 작업이 있다면, LoadState는 LoadState.Loading이 된다.
  • 에러가 발생한다면, LoadState는 LoadState.Error가 된다.

UI에서 LoadState를 사용하는 두가지 방법이 있다. 하나는 리스너를 사용하는 것이고, 다른 한가지는 특벽한 목록 어댑터를 사용하여 로딩 상태를 직접적으로 RecyclerView 목록에서 표현하는 것이다.

리스너를 사용하여 로딩 상태를 얻기

UI에서 일반적인 방법으로 로딩 상태를 얻기 위해서는, PagingDataAdapter에서 addLoadStateListener() 메서드를 포함하는 것이다.

// Activity는 lifecycleScope를 바로 사용가능 하지만
// Fragment는 viewLifecycleOwner.lifecycleScope를 사용해야 한다.
lifecycleScope.launch {
  pagingAdapter.loadStateFlow.collectLatest { loadStates ->
    progressBar.isVisible = loadStates.refresh is LoadState.Loading
    retry.isVisible = loadState.refresh !is LoadState.Loading
    errorMsg.isVisible = loadState.refresh is LoadState.Error
  }
}

어댑터를 사용하여 로딩상태를 표현하기

페이징 라이브러리는 페이징 된 데이터가 보여지는 목록에서 로딩 상태를 직접적으로 보여주기 위해 LoadStateAdapter라고 불리는 어댑터를 제공하고 있다.

먼저, LoadStateAdapter를 구현한 클래스를 생성하고 onCreateViewHolder()와onBindViewHolder() 메서드를 정의하자.

class LoadStateViewHolder(
  parent: ViewGroup,
  retry: () -> Unit
) : RecyclerView.ViewHolder(
  LayoutInflater.from(parent.context)
    .inflate(R.layout.load_state_item, parent, false)
) {
  private val binding = LoadStateItemBinding.bind(itemView)
  private val progressBar: ProgressBar = binding.progressBar
  private val errorMsg: TextView = binding.errorMsg
  private val retry: Button = binding.retryButton
    .also {
      it.setOnClickListener { retry() }
    }

  fun bind(loadState: LoadState) {
    if (loadState is LoadState.Error) {
      errorMsg.text = loadState.error.localizedMessage
    }

    progressBar.isVisible = loadState is LoadState.Loading
    retry.isVisible = loadState is LoadState.Error
    errorMsg.isVisible = loadState is LoadState.Error
  }
}

// 로딩 상태가 LoadState.Loading 일 때 로딩 스피너를 보여주고,
// LoadingState.Error 일 때는 에러메시지, 재시작 버튼을 보여주는 어댑터다.
class ExampleLoadStateAdapter(
  private val retry: () -> Unit
) : LoadStateAdapter<LoadStateViewHolder()> {

  override fun onCreateViewHolder(
    parent: ViewGroup,
    loadState: LoadState
  ) = LoadStateViewHolder(parent, retry)

  override fun onBindViewHolder(
    holder: LoadStateViewHolder,
    loadState: LoadState
  ) = holder.bind(loadState)
}

그런 뒤, PagingDataAdapter 객체로부터 withLoadStateHeaderAndFooter() 메서드를 호출하자.

pagingAdapter
  .withLoadStateHeaderAndFooter(
    header = ExampleLoadStateAdapter(adapter::retry),
    footer = ExampleLoadStateAdapter(adapter::retry)
  )

헤더나 푸터에서 로딩상태를 표시하는 RecyclerView를 원한다면 withLoadStateHeader() 또는 withLoadStateFooter()를 대신 호출하자.

카테고리: Alpha

2개의 댓글

정현우 · 2022년 9월 8일 6:48 오후

혹시 PagingAdapter에 collect한 데이터를 submitData할 때 collect가 아닌 collectLatest로 하신 이유가 있을까요? (collect로 수행하면 제대로 작동하지 않아서요!)

    Charlezz · 2022년 9월 10일 12:00 오후

    collectLatest를 사용 한 이유는 가장 최근 데이터 셋만 RecyclerView에 보여주면 되기 때문입니다.
    제대로 동작하지 않는 다는 것이 어떤 부분을 말씀하시는 것인지는 잘모르겠지만, 일반적인 경우면 collect로 호출해도 collectLatest로 호출한 것과 동일한 결과를 나타냅니다.

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.