개요

페이징 라이브러리를 사용하면 로컬 저장소 또는 네트워크를 통해 큰 데이터를 잘게 쪼개어 로드하고 표시 할 수 있다. 이 방식을 사용하면 앱에서 네트워크 대역폭과 시스템 리소스를 더 효율적으로 사용할 수 있다. Paging3 라이브러리 컴포넌트 Android 앱 아키텍처에 맞게 설계되고 다른 Jetpack 구성 요소와 깔끔하게 통합되며 Kotlin 우선 지원을 제공한다.

장점

Paging 라이브러리에는 다음 기능이 포함된다.
  • 페이징 된 데이터에 대한 인-메모리 캐싱을 지원한다. 앱이 페이징 된 데이터로 작업하는 동안 시스템 리소스를 효율적으로 사용할 수 있게 된다.
  • 내장된 요청 중복 제거를 통해 앱이 네트워크 대역폭과 시스템 리소스를 효율적으로 사용하도록 한다.
  • 사용자가 로드 한 데이터의 끝으로 스크롤 할 때 데이터를 자동으로 요청하는 구성이 가능한 RecyclerView 어댑터를 제공한다.
  • 코틀린 코루틴과 Flow를 우선적으로 지원하며, LiveData 및 RxJava를 지원한다.
  • 새로 고침 및 재시도 기능을 포함하여 오류 처리를 위한 기본적인 방법을 제공한다.

설정하기

모듈 레벨의 build.gradle 에 다음의 내용을 추가한다.

Paging3라이브러리는 아직 alpha단계이며 최신버전을 확인하여 적용하자.

dependencies {
  def paging_version = "3.0.0-alpha11"

  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 - RxJava3 support
  implementation "androidx.paging:paging-rxjava3:$paging_version"

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

  // Jetpack Compose Integration
  implementation "androidx.paging:paging-compose:1.0.0-alpha04"
}

라이브러리 구조

Paging 라이브러리는 다음과 같이 3가지 계층의 컴포넌트로 구성된다.

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

Repository 계층

Repository 계층의 기본이되는 구성 요소는 PagingSource다. 각 PagingSource 개체는 데이터 소스와 해당 소스에서 데이터를 검색하는 방법을 정의한다. PagingSource 개체는 네트워크 소스 및 로컬 데이터베이스를 포함하여 전체 데이터로부터 부분적으로 데이터를 로드 할 수 있다.

또 다른 구성요소로 RemoteMediator가 있다. RemoteMediator 개체는 네트워크로 부터 받은 데이터를 로컬 데이터베이스를 통해 캐시 하는 경우 페이징하는데 함께 사용할 수 있다.

ViewModel 계층

Pager는 PagingSource 개체 및 PagingConfig 개체를 기반으로 반응형 스트림에서 사용되는 PagingData 인스턴스를 구성하기 위한 공용 API를 제공한다. ViewModel 계층을 UI에 연결하는 구성 요소는 PagingData다. PagingData 개체는 페이지가 매겨진 데이터의 스냅 샷을 위한 컨테이너로, PagingSource 개체를 쿼리하고 결과를 저장한다.

UI계층

UI 계층의 기본 Paging 라이브러리 구성 요소는 PagingDataAdapter로 페이지가 매겨진 데이터를 처리한다. 만약 PagingDataAdapter가 아닌 RecyclerView.Adapter 등을 확장하는 커스텀 어댑터를 구현하려면 AsyncPagingDataDiffer를 사용할 수 있다.

데이터를 불러오고 표시하기

페이징 라이브러리는 페이징 된 데이터를 로드하고 표시하는 강력한 기능을 제공한다. Paging 라이브러리를 사용하여 네트워크로 부터 페이징 된 데이터 스트림을 설정하고 RecyclerView에 표시하는 방법을 알아보자.

데이터 소스 정의하기

가장 먼저 할 일은 데이터 소스를 식별하기 위해 PagingSource 구현하는 것이다. 확장할 PagingSource 클래스에서는 load() 메소드를 재정의하여 데이터 소스로부터 페이징 된 데이터를 가져온다.

비동기 로드를 위한 Kotlin 코루틴을 사용하기 위해서는 PagingSource 클래스를 직접적으로 사용하자. Paging 라이브러리는 다른 비동기 프레임워크를 지원하는 클래스도 제공한다.

  • RxJava를 사용하기 위해서는 RxPagingSource를 사용하자.
  • Guava의 ListenableFuture를 사용하기 위해서는 ListenableFUtrePagingSource를 사용하자.

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 {
      // 정의 되지 않았다면, page1에서 리프레시 한다.
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber)
      return LoadResult.Page(
        data = response.users,
        prevKey = null, // Only paging forward.
        nextKey = response.nextPageNumber
      )
    } catch (e: Exception) {
      // 네트워크 에러 등과 같은 내용을 처리하고 LoadResult.Error 반환한다.
    }
  }
}

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

  • backend: 백엔드 서비스 인스턴스로 데이터를 제공한다.
  • query: backend 에 검색할 데이터를 질의하기 위해 사용된다.

LoadParams 객체는 로드 작업에 대한 정보를 포함한다. 여기에는 로드 할 Key와 로드 할 항목의 사이즈가 포함된다. 

LoadResult 개체에는 로드 작업의 결과가 포함된다. LoadResult는 load () 호출의 성공 여부에 따라 두 가지 형식 중 하나를 취하는 sealed 클래스다.

  • 만약 로드에 성공했다면 LoadResult.Page 객체를 반환한다.
  • 만약 로드에 실패했다면 LoadResult.Error 객체를 반환한다.

다음 나올 그림은 예제 속 load() 함수가 전달 받은 key를 통해 어떻게 데이터를 불러오는지 보여준다.

 

에러 처리하기

데이터 로드 요청은 특히 네트워크를 통해로드 할 때 여러 가지 이유로 실패 할 수 있습니다. load () 메서드에서 LoadResult.Error 객체를 반환하여 로드 중에 발생한 오류를 보고한다.

예를 들어 load () 메서드에 다음을 추가하여 이전 예제의 ExamplePagingSource에서 로드 오류를 포착하고 보고 할 수 있다.

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)
}

Retrofit 오류 처리에 대한 자세한 내용은 PagingSource API 샘플을 참조하자.

PagingSource는 사용자가 조치를 취할 수 있도록 LoadResult.Error 개체를 수집하여 UI에 전달한다. UI에서 로드 상태를 노출하는 방법에 대한 자세한 내용은 아래에 나올 ‘로드 상태 표시하기’를 참조하자.

페이징 데이터 스트림 설정하기

다음으로 구현해야 할 내용은 PagingSource로부터 페이징 된 데이터 스트림을 설정하는 것이다. 일반적으로 ViewModel에서 데이터 스트림을 설정한다. Pager 클래스는 PagingSource에서 PagingData 개체의 반응형 스트림을 가져오는 메서드를 제공한다. Paging 라이브러리는 Flow, LiveData, RxJava의 Flowable 및 Observable 타입을 포함한 여러 스트림 타입 사용을 지원한다.

반응형 스트림을 설정하기 위해 Pager 인스턴스를 만들 때 PagingConfig 개체와 PagingSource 구현의 인스턴스를 가져 오는 방법을 Pager에 알려주는 함수를 인스턴스에 제공해야한다.

val flow = Pager(
  // 어떻게 데이터를 가져올건지 추가적인 속성을 사용하자.
  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를 확장하는 클래스를 만들면 된다. 이 예제에서는 PagingDataAdapter를 확장하여 UserAdapter라는 어댑터를 만들어 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)
    // item이 null일 수 있으니 이를 적절히 처리하자
    holder.bind(item)
  }
}

일반적으로 어댑터에서 onCreateViewHolder () 및 onBindViewHolder () 메서드를 재정의하게 되는데 추가적으로 DiffUtil.ItemCallback을 지정해야 한다. 이것은 RecyclerView 목록 어댑터를 정의 할 때 일반적으로 수행하는 것과 동일하게 작동한다.

object UserComparator : DiffUtil.ItemCallback<User>() {
  override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
    // Id is unique.
    return oldItem.id == newItem.id
  }

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

UI에서 페이징 된 데이터 표시하기

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

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

  1. PagingDataAdapter 클래스의 인스턴스를 만든다.
  2. 페이징 된 데이터를 표시하려는 RecyclerView 목록에 PagingDataAdapter 인스턴스를 전달한다.
  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 목록은 이제 데이터 소스에서 페이징 된 데이터를 표시하고 필요한 경우 다른 페이지를 자동으로 로드하게 된다.

로드 상태 표시하기

Paging 라이브러리는 LoadState 개체를 통해 UI에서 사용할 로드 상태를 노출한다. LoadState는 현재로드 상태에 따라 세 가지 형식 중 하나를 사용한다.

  • 데이터를 불러오는 상태가 아니고 에러가 없다면 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일 때 로딩 스피너를 나타낸다.
// 그리고 LoadState.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 () 메서드를 호출한다.

네트워크 및 데이터베이스로부터 페이징하기

네트워크 연결이 불안정하거나 사용자가 오프라인 일 때 앱을 사용할 수 있도록 한다. 이를 가능하게 하는 방법은 네트워크와 로컬 데이터베이스에서 동시에 페이징하는 것이다. 이렇게 하면 앱이 로컬 데이터베이스 캐시에서 직접 데이터를 로드하고 데이터베이스에 더 이상 데이터가 없을 때만 네트워크에 요청한다.
Paging 라이브러리는 이 유즈케이스를 위해 RemoteMediator 컴포넌트를 제공하고 있다. RemoteMediator는 네트워크에서 로컬 데이터베이스로 데이터를 로드하는 프로세스를 관리한다.

기본적인 사용법

앱이 User 아이템을 데이터 소스로부터 아이템 키를 기반으로 로드하여 Room 데이터베이스에 저장하고 로컬 캐시로 이를 로드하도록 하려고 한다. 다음 그림은 RemoteMediator와 PagingSource가 이 유즈케이스를 충족하기 위해 함께 작동하는 방법을 보여주고 있다.

The RemoteMediator loads data from the network into the database and
    the PagingSource loads data from the database. A Pager uses both the
    RemoteMediator and the PagingSource to load paged data.

RemoteMediator 구현은 네트워크에서 데이터베이스로 페이징 된 데이터를 로드하는 것을 관리하지만 데이터를 UI로 직접로드 하지는 않는다. 대신 앱은 데이터베이스를 데이터 소스로 사용한다. 즉, 앱은 데이터베이스에 캐시 된 데이터로만 나타낸다. PagingSource 구현 (예 : Room에서 생성 된 구현)은 데이터베이스에서 UI로 캐시 된 데이터로드를 처리한다.

Room 엔터티 생성하기

첫 번째 단계는 Room 라이브러리를 사용하여 네트워크 데이터 소스에서 페이징 된 데이터의 로컬 캐시를 보유하는 데이터베이스를 정의하는 것이다.

그런 다음 Room 엔터티를 사용하여 데이터 정의에 설명 된대로 목록 항목 테이블을 나타내는 Room 엔터티를 정의한다. id 필드를 기본키로 제공하고 목록 항목에 포함 된 기타 정보에 대한 필드를 제공한다.

@Entity(tableName = "users")
data class User(val id: String, val label: String)

또한 Room DAO를 사용하여 데이터 액세스에 설명 된대로이 Room 엔터티에 대한 데이터 액세스 개체 (DAO)를 정의해야합니다. 목록 항목 엔터티에 대한 DAO에는 다음 메서드가 포함되어야 한다.

  • insertAll ()은 아이템 목록을 테이블에 삽입하는 메서드다
  • pagingSource(query)는 쿼리 문자열을 매개 변수로 사용하여 결과 목록에 대한 PagingSource 개체를 반환하는 메서드다. 이런 식으로 Pager 개체는 이 테이블을 페이징 된 데이터의 소스로 사용할 수 있다.
  • clearAll () 메서드는 테이블의 모든 데이터를 삭제한다.
@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertAll(users: List<User>)

  @Query("SELECT * FROM users WHERE label LIKE :query")
  fun pagingSource(query: String): PagingSource<Int, User>

  @Query("DELETE FROM users")
  suspend fun clearAll()
}

RemoteMediator 구현하기

RemoteMediator는 PagingSource와 유사하다. 여기에는 로드 동작을 정의하기 위해 재정의해야하는 load() 메서드가 포함되어 있다. 차이점은 데이터 소스에서 RecyclerView 목록으로 페이징 된 데이터를로드하는 대신 RemoteMediator 개체는 네트워크 데이터 소스에서 페이징 된 데이터를 로드하여 로컬 데이터베이스에 저장한다는 것이다.

전형적인 RemoteMediator 구현은 다음과 같은 매개변수를 포함한다.

  • query: 백엔드 서비스에서 검색 할 데이터를 정의하는 쿼리 문자열
  • database: 로컬 캐시 역할을 하는 Room 데이터베이스
  • networkService: 백엔드 서비스 용 API 인스턴스(일반적으로 Retrofit 사용)

RemoteMediator <Key, Value> 구현을 만들고, Key 타입 및 Value 타입은 동일한 네트워크 데이터 소스에 대해 PagingSource를 정의하는 경우와 동일해야한다. 

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    // ...
  }
}

load () 메서드는 데이터베이스를 업데이트하고 PagingSource를 무효화한다. 페이징을 지원하는 일부 라이브러리 (예 : Room)는 구현하는 PagingSource 개체를 무효화하는 처리를 자동으로 한다.

load() 메서드는 두가지 매개변수를 취한다.

  • PagingState : 지금까지 로드 된 페이지, 가장 최근에 액세스 한 인덱스 및 페이징 스트림을 초기화하는 데 사용한 PagingConfig 개체에 대한 정보가 포함 된다.
  • LoadType : 로드 유형을 나타낸다,(REFRESH, APPEND, PREPEND)

load () 메서드의 반환 값은 MediatorResult 객체다. MediatorResult는 MediatorResult.Error  (오류 설명 포함) 또는 MediatorResult.Success (로드 할 데이터가 더 있는지 여부를 나타내는 신호 포함) 가 될 수 있다.

load() 메서드는 반드시 다음 내용을 수행해야한다.

  1. 로드 유형 및 지금까지 로드 된 데이터에 따라 네트워크에서 로드 할 페이지 결정
  2. 네트워크 요청 트리거
  3. 로드 작업의 결과에 따라 작업 수행

    로드에 성공하고 받은 아이템 리스트가 비어 있지 않으면 리스트를 데이터베이스에 저장하고 MediatorResult.Success (endOfPaginationReached = false)를 반환한다.
    – 로드가 성공하고 받은 아이템 리스트가 비어 있으면 MediatorResult.Success (endOfPaginationReached = true)를 반환한다.
    – 요청 시 에러가 발생하면 MediatorResult.Error를 반환한다.

override suspend fun load(
  loadType: LoadType,
  state: PagingState<Int, User>
): MediatorResult {
  return try {
    // 네트워크 로드 메서드는 선택적으로 after=<user.id> 매개변수를 취한다.
    // 첫번째 페이지 이후 모든 페이지에 대해 마지막 User ID를 전달하여
    // 마지막 요청한 지점부터 지속적으로 데이터를 가져올 수 있도록 한다
    // REFRESH(새로고침)을 하기 위해서는 null을 전달하여 첫번째 페이지를 불러오자.
    val loadKey = when (loadType) {
      LoadType.REFRESH -> null
      // 이 예제에서는 REFRESH가 항상 첫번째 페이지를 로드하기 때문에 
      // 이전 데이터를 불러올 필요가 없다. 즉시 페이징의 끝을 알리자
      LoadType.PREPEND ->
        return MediatorResult.Success(endOfPaginationReached = true)
      LoadType.APPEND -> {
        val lastItem = state.lastItemOrNull()

        // null을 네트워크 서비스에 전달하는 것은 초기 로드에만 유효하기 때문에
        // appending시 분명하게 마지막 항목이 null인지 확인하자.
        // 만약 마지막 항목이 null이면 초기 REFRESH 이후 아이템이 로드되지 않았고,
        // 더 이상 로드할 아이템도 없음을 의미한다.
        if (lastItem == null) {
          return MediatorResult.Success(
            endOfPaginationReached = true
          )
        }

        lastItem.id
      }
    }

    // Retrofit을 통해 네트워크 로드시 suspending 되는 것에 대해 
    // withContext(Dispatcher.ID)로 감쌀 필요가 없다.
    // Retrofit의 코루틴 CallAdapter가 worker 스레드로 처리하기 떄문이다.
    val response = networkService.searchUsers(
      query = query, after = loadKey
    )

    database.withTransaction {
      if (loadType == LoadType.REFRESH) {
        userDao.deleteByQuery(query)
      }

      // 새로운 users를 삽입하고, 현재 PagingData를 무효화 한다.
      // 그러면 페이징 컴포넌트는 데이터 베이스 내용을 자동으로 갱신하여 보여주게 된다.
      userDao.insertAll(response.users)
    }

    MediatorResult.Success(
      endOfPaginationReached = response.nextKey == null
    )
  } catch (e: IOException) {
    MediatorResult.Error(e)
  } catch (e: HttpException) {
    MediatorResult.Error(e)
  }
}

Pager 생성하기

마지막으로 페이징 된 데이터의 스트림을 설정하려면 Pager 인스턴스를 만들어야 한다. 이것은 단순한 네트워크 데이터 소스에서 Pager를 만드는 것과 비슷하지만 다르게 수행해야하는 두 가지 작업이 있다.

  • PagingSource생성자를 직접적으로 넘기는 대신 DAO로부터 PagingSource객체를 반환하는 query메서드를 제공한다.
  • RemoteMediator의 구현체의 인스턴스를 매개변수로 제공해야 한다.
val userDao = database.userDao()
val pager = Pager(
  config = PagingConfig(pageSize = 50)
  remoteMediator = ExampleRemoteMediator(query, database, networkService)
) {
  userDao.pagingSource(query)
}

Remote key 관리하기

원격 키(Remote key)는 RemoteMediator 구현에서 다음에 로드 할 데이터를 백엔드 서비스에 알리는 데 사용하는 키다. 가장 간단한 방법은 페이징 된 데이터의 각 항목에 쉽게 참조 할 수있는 원격 키를 포함시켜 이를 참조하는 것이다. 그러나 원격 키가 개별 항목에 해당하지 않는 경우 별도로 저장하고 load () 메서드에서 관리해야 한다. 

원격 키 테이블 추가하기

원격 키가 목록 항목과 직접 연결되지 않은 경우 로컬 데이터베이스의 별도 테이블에 저장하는 것이 가장 좋다. 원격 키 테이블을 나타내는 Room 엔터티를 정의하자.

@Entity(tableName = "remote_keys")
data class RemoteKey(val label: String, val nextKey: String?)
RemoteKey 엔터티에 대한 DAO도 정의해야합니다.
@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertOrReplace(remoteKey: RemoteKey)

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  suspend fun remoteKeyByQuery(query: String): RemoteKey

  @Query("DELETE FROM remote_keys WHERE label = :query")
  suspend fun deleteByQuery(query: String)
}

원격 키로 로드하기

load () 메서드가 원격 키를 관리해야하는 경우 RemoteMediator의 기본 사용법과 비교하여 다음과 같은 방식으로 다르게 정의해야 한다.

  • 원격 키 테이블에 액세스 할 수 있는 DAO를  추가 속성으로 포함한다.
  • PagingState를 사용하는 대신 원격 키 테이블을 쿼리하여 다음에 로드 할 키를 결정한다.
  • 페이징 된 데이터 자체 외에도 네트워크 데이터 원본에서 반환 된 원격 키를 삽입하거나 저장한다.
@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()
  val remoteKeyDao = database.remoteKeyDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        
        // remoteKeyDao에 다음 원격 키를 요청한다.
        LoadType.APPEND -> {
          val remoteKey = database.withTransaction {
            remoteKeyDao.remoteKeyByQuery(query)
          }

          if (remoteKey.nextKey == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          remoteKey.nextKey
        }
      }

      val response = networkService.searchUsers(query, loadKey)

      // 불러온 데이터와 next key를 저장하여 영구적으로 관리하자.
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          remoteKeyDao.deleteByQuery(query)
          userDao.deleteByQuery(query)
        }

        // 원격 키를 갱신한다.
        remoteKeyDao.insertOrReplace(
          RemoteKey(query, response.nextKey)
        )

        userDao.insertAll(response.users)
      }

      MediatorResult.Success(
        endOfPaginationReached = response.nextKey == null
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

제자리에서 새로 고침

앱이 이전 예제에서와 같이 목록 맨 위에서 네트워크 새로 고침만 지원해야하는 경우 RemoteMediator는 로드 동작을 앞에 추가 할 필요가 없다.

그러나 앱이 네트워크에서 로컬 데이터베이스로의 점진적 로드를 지원해야하는 경우 사용자의 스크롤 위치 인 앵커에서 시작하는 페이지 재개 지원을 제공해야 한다. Room의 PagingSource 구현이 이를 처리하지만 Room을 사용하지 않는 경우 PagingSource.getRefreshKey ()를 재정의하여 이를 수행 할 수 있다.

// Item-keyed기반
override fun getRefreshKey(state: PagingState): String? {
  return state.anchorPosition?.let { anchorPosition ->
    state.getClosestItemToPosition(anchorPosition)?.id
  }
}

// Positional기반
override fun getRefreshKey(state: PagingState): Int? {
  return state.anchorPosition
}

다음 그림은 먼저 로컬 데이터베이스에서 데이터를 로드 한 다음 데이터베이스에 데이터가 없으면 네트워크에서 로드하는 프로세스를 보여준다.

The PagingSource loads from the database into the UI until the database
    is out of data. Then the RemoteMediator loads from the network into the
    database, and afterward the PagingSource continues loading.

데이터 스트림 변환하기

페이징 된 데이터로 작업 하고, 로드 할 때 데이터 스트림을 변환해야 하는 경우가 많다. 예를 들어 항목 목록을 필터링하거나 항목을 UI에 표시하기 전에 다른 유형으로 변환해야 할 수 있다. 데이터 스트림 변환의 또 다른 일반적인 유즈케이스는 목록 구분 기호를 추가하는 것이다.

일반적으로 데이터 스트림에 직접 변환을 적용하면 리포지토리 구성과 UI 구성을 별도로 유지할 수 있다. 

기본적인 변환 적용하기

PagingData는 반응 스트림에 캡슐화되어 있기 때문에 데이터를 로드하고 표시하는 사이에 점진적으로 데이터에 변환 작업을 적용 할 수 있다.

스트림의 각 PagingData 객체에 변환을 적용하려면 스트림의 map() 연산자 내에 변환을 배치한다.

pager.flow // 타입은 Flow<PagingData<User>> 이다.
  // 바깥쪽 스트림을 매핑하여 새로운 PagingData변환을 적용할 수 있다.
  .map { pagingData ->
    // 이 블럭안 변환은 페이징 된 데이터의 항목들에 적용된다.
}

래 예제는 페이지 된 데이터에 다음 변환을 적용하여이 패턴을 보여준다.

  1. 데이터 스트림에 대한 외부 map () 연산자는 해당 블록 내부의 모든 작업을 스트림에서 PagingData 객체의 각 후속 생성에 적용한다.
  2. filter 연산자는 UI에 표시되지 않는 모든 항목을 삭제한다.
  3. 또 다른 맵 작업은 목록의 각 User 객체를 UiModel 타입으로 변환한다.
pager.flow // 타입은 Flow<PagingData<User>> 이다.
    .map { pagingData ->
        pagingData.filter { user -> !user.hiddenFromUi }
            .map { user -> UiModel(user) }
    }
    .cachedIn(viewModelScope)

cachedIn() 연산자는 이전에 발생한 모든 변환 결과를 캐시한다. 따라서 cachedIn ()은 ViewModel에서 마지막 호출이어야 한다. 

목록 구분자 추가하기

Paging 라이브러리는 동적인 목록 구분자를 지원한다. RecyclerView 목록 항목으로 데이터 스트림에 직접 구분 기호를 삽입하여 목록 가독성을 향상시킬 수 있다. 결과적으로 구분자는 모든 기능을 갖춘 ViewHolder 개체이며, 상호 작용, 접근성 포커스 및 View에서 제공하는 기타 모든 기능을 활성화한다.

페이지 목록에 구분자를 삽입하는 데는 세 단계가 있다.

  1. 구분자를 수용하도록 UI 모델을 변환한다.
  2. 데이터 스트림을 변환하여 데이터 로드와 데이터 표시 사이에 구분자를 동적으로 추가한다.
  3. 구분 항목을 처리하도록 UI를 업데이트 한다.

Note : 인터렉티브하거나 포커스를 갖는 구분자가 필요없다면 간단히 RecyclerView.ItemDecoration을 사용하여 정적인 구분자를 만드는 것도 방법이다.

UI 모델 변환하기

Paging 라이브러리는 구분자를 실제 목록 항목으로 RecyclerView에 삽입하지만 구분자 UI가 다른 목록 항목의 UI와 다를 가능성이 높기 때문에 구분자 아이템은 목록의 다른 항목과 구분할 수 있어야 한다. 해결책은 데이터와 구분자를 나타내는 하위 클래스가있는 Kotlin 봉인 클래스를 만드는 것이다. 또는 목록 항목 클래스와 구분자 클래스에 의해 확장되는 기본 클래스를 만들 수 있다.

사용자 항목의 페이지 된 목록에 구분 기호를 추가한다고 가정해보자. 다음 스니펫은 인스턴스가 UserModel 또는 SeparatorModel 일 수있는 기본 클래스를 만드는 방법을 보여준다.

sealed class UiModel {
  class UserModel(val id: String, val label: String) : UiModel() {
    constructor(user: User) : this(user.id, user.label)
  }

  class SeparatorModel(val description: String) : UiModel()
}

데이터 스트림 변환하기

데이터 스트림을 로드 한 후 표시하기 전에 변환을 데이터 스트림에 적용해야 한다. 변환은 다음을 수행해야 한다.

  • 로드한 목록 아이템들을 새로운 기본 아이템 타입을 반영하도록 변환한다.
  • PagingData.insertSeparators () 메서드를 사용하여 구분 기호를 추가한다.

다음 예제는 PagingData <User> 스트림을 구분자가 추가 된 PagingData <UiModel> 스트림으로 업데이트하는 변환 작업을 보여준다.

pager.flow.map { pagingData: PagingData<User> ->
  // 바깥 스트림을 매핑하여 각 페이징 데이터 대한 변환을 수행한다.
  pagingData
  .map { user ->
    // 스트림에 있는 아이템들을 UiModel.UserModel로 변환한다.
    UiModel.UserModel(user)
  }
  .insertSeparators<UiModel.UserModel, UiModel> { before, after ->
    when {
      before == null -> UiModel.SeparatorModel("HEADER")
      after == null -> UiModel.SeparatorModel("FOOTER")
      shouldSeparate(before, after) -> UiModel.SeparatorModel(
        "BETWEEN ITEMS $before AND $after"
      )
      // Return null to avoid adding a separator between two items.
      else -> null
    }
  }
}

UI에서 구분자 다루기

마지막 단계는 구분자 타입을 수용하도록 UI를 변경하는 것이다. 구분자 항목에 대한 레이아웃 및 뷰 홀더를 만들고, 둘 이상의 뷰 홀더 유형을 처리 할 수 ​​있도록 RecyclerView.ViewHolder를 뷰 홀더 유형으로 사용하도록 목록 어댑터를 변경한다. 또는 항목 및 구분자보기 홀더 클래스가 모두 확장하는 공통 기본 클래스를 정의 할 수 있다.
또한 목록 어댑터를 다음과 같이 변경해야 한다.
  • onCreateViewHolder () 및 onBindViewHolder () 메서드에 케이스를 추가하여 구분자 목록 항목을 계산한다.
  • 새로운 comparator를 구현한다.
class UiModelAdapter :
  PagingDataAdapter<UiModel, RecyclerView.ViewHolder>(UiModelComparator) {

  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ) = when (viewType) {
    R.layout.item -> UserModelViewHolder(parent)
    else -> SeparatorModelViewHolder(parent)
  }

  override fun getItemViewType(position: Int) = when (getItem(position)) {
    is UiModel.UserModel -> R.layout.item
    is UiModel.SeparatorModel -> R.layout.separator_item
    null -> throw IllegalStateException("Unknown view")
  }

  override fun onBindViewHolder(
    holder: RecyclerView.ViewHolder,
    position: Int
  ) {
    val item = getItem(position)
    if (holder is UserModelViewHolder) {
      holder.bind(item as UserModel)
    } else if (holder is SeparatorModelViewHolder) {
      holder.bind(item as SeparatorModel)
    }
  }
}

object UiModelComparator : DiffUtil.ItemCallback<UiModel>() {
  override fun areItemsTheSame(
    oldItem: UiModel,
    newItem: UiModel
  ): Boolean {
    val isSameRepoItem = oldItem is UiModel.UserModel
      && newItem is UiModel.UserModel
      && oldItem.id == newItem.id

    val isSameSeparatorItem = oldItem is UiModel.SeparatorModel
      && newItem is UiModel.SeparatorModel
      && oldItem.description == newItem.description

    return isSameRepoItem || isSameSeparatorItem
  }

  override fun areContentsTheSame(
    oldItem: UiModel,
    newItem: UiModel
  ) = oldItem == newItem
}

Paging3을 사용하는 예제

찰스의 안드로이드 앱 : 이 블로그를 안드로이드 네이티브 앱으로 만든 예제

Pickle : 빠르게 목록을 불러오는 안드로이드 이미지 피커 라이브러리 

PagingSample : 구글의 기본적인 페이징 예제

PagingWithNetworkSample : RemoteMediator를 사용하여 로컬 데이터베이스에 캐시하고, 오프라인 모드를 지원하는 페이징 예제

 

 

Buy me a coffeeBuy me a coffee
카테고리: Android

0개의 댓글

답글 남기기

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