Android Paging3 적용하기

Paging 라이브러리 개요

  • Paging 라이브러리를 사용하면 로컬 저장소에서나 네트워크를 통해 대규모 데이터 세트의 페이지를 로드하고 표시할 수 있습니다.
  • 구글 권장 Android 앱 아키텍처에 맞게 설계되었으며, 다른 Jetpack 구성요소와 원활하게 통합됩니다.

이점

  • Paging된 데이터의 메모리 내 캐싱과 효율적인 시스템 리소스 활용이 가능합니다.
  • 요청 중복 삭제 기능이 가능하여 네트워크 대역폭과 시스템 리소스를 효율적으로 사용할 수 있습니다.
  • 로드된 데이터의 끝까지 스크롤할 경우 RecyclerView 어댑터가 자동으로 데이터를 요청합니다.
  • 코루틴, LiveData, RxJava를 지원합니다.
  • 새로고침 및 재시도 기능, 오류 처리를 지원합니다.

라이브러리 아키텍처

  • Paging 라이브러리의 구성요소는 앱의 세 가지 레이어에서 작동합니다.
    • data Layer
    • ViewModel Layer
    • UI Layer

Data Layer

  • 저장소 레이어의 기본 페이징 라이브러리 구성요소는 PagingSource입니다.
  • [PagingSource] 객체는 데이터 소스에서 데이터를 검색하는 방법을 정의합니다.
  • 네트워크 소스 및 로컬 데이터베이스를 포함한 단일 소스에서 데이터를 로드할 수 있습니다.
  • [RemoteMediator] 객체는 로컬 데이터베이스 캐시가 있는 네트워크 데이터 소스와 같은 계층화된 데이터 소스의 페이징을 처리합니다.

ViewModel Layer

  • Pager 구성요소는 PagingSource 객체 및 PagingConfig 구성 객체를 바탕으로 반응형 스트림에 노출되는 PagingData 인스턴스 구성을 위한 API를 제공합니다.
  • ViewModel 레이어를 UI에 연결하는 구성요소는 PagingData이며, 객체를 쿼리하여 결과를 저장합니다.

UI Layer

  • UI 구성요소는 페이지로 나눈 데이터를 처리하는 RecyclerView 어댑터인 PagingDataAdapter가 됩니다.
  • Compose를 사용하는 경우 collectAsLazyPagingItems()를 사용합니다.

PagingSource 정의하기

  • Data Layer를 식별하기 위해서 PagingSource의 구현을 정의해야 합니다.
  • PagingSourece<Key, Value>에는 Key와 Value의 두 유형 매개변수가 있습니다.
  • 키는 데이터를 로드하는 데 사용되는 식별자를 정의하며, 값은 데이터 자체의 유형입니다.

PagingSource 정의

  • 일반적으로 PagingSource 구현은 생성자에서 제공된 매개변수를 load() 메서드에 전달하여 쿼리에 적절한 데이터를 로드합니다.
    • [service] : 데이터를 제공하는 백엔드 서비스의 인스턴스
    • [query] : 인스턴스로 표시될 서비스에 전송할 검색어
    • [LoadParams] : 실행할 로드 작업에 관한 정보를 포함
      • 로드할 키, 로드할 항목 수
    • [LoadResult] : 로드 작업의 결과를 포함하는 sealed class
      • LoadResult.Page, LoadResult.Error 객체 반환
class ExamplePagingSource(
    val service: ExamplePagingService,
    val query: String,
) : PagingSource<Int, PagingUser>() {
    // page 키를 찾는 함수
    override fun getRefreshKey(
        state: PagingState<Int, PagingUser>
    ): Int? {
        val position = state.anchorPosition ?: return null
        val anchorPage = state.closestPageToPosition(position)
        val prevKey = anchorPage?.prevKey
        val nextKey = anchorPage?.nextKey
        // prevKey가 null이 아니라면 prevKey + 1 반환
        // prevKey가 null이고, nextKey가 null이 아니라면 nextKey - 1 반환
        // 모두 null이면 null 반환
        // 이 경우 anchorPage는 page를 초기화합니다.
        return prevKey?.plus(1) ?: nextKey?.minus(1)
    }

    override suspend fun load(
        params: LoadParams<Int>
    ): LoadResult<Int, PagingUser> {
        return try {
            // key가 null이라면 1페이지부터 로드
            val nextPageNumber = params.key ?: 1
            val users = service.searchUser(query, nextPageNumber)
            val prevPage = if (nextPageNumber > 1) nextPageNumber - 1 else null
            val nextKey = if (users.isEmpty()) null else nextPageNumber + 1

            LoadResult.Page(
                data = users,
                prevKey = prevPage,
                nextKey =nextKey
            )
        } catch (e: Exception){
            // error handle
            LoadResult.Error(e)
        }
    }
}

load() 함수 동작

  • load() 함수는 nextKey, prevKey를 중심으로 동작합니다.
  • getRefreshKey() 메서드도 반드시 구현해야 하며, 데이터가 첫 로드 후 새로고침되거나 무효화되었을 때 키를 반환하여 load() 메서드로 전달합니다.
  • Paging 라이브러리는 다음에 데이터를 새로고침할 때 자동으로 이 메서드를 호출합니다.

오류 처리

  • 데이터 로드는 네트워크 오류 등 여러 가지 이유로 실패할 수 있습니다.
  • 로드 중에 load() 메서드에서 LoadReuslt.Error를 반환하여 발생한 오류를 보고합니다.

PagingData 스트림

  • PagingSource 구현에서 페이징된 데이터의 스트림이 필요합니다.
  • ViewModel에서 데이터 스트림을 설정할 수 있습니다.

Pager

  • Pager 클래스는 PagingSource에서 PagingData 객체의 반응형 스트림을 노출하는 메서드를 제공합니다.
    • Flow, LiveData, RxJava의 Flowable
  • PagingConfig 구성 객체와 PagingSource 구현 인스턴스를 가져오는 방법을 Pager에 제공해야 합니다.
class ExamplePagingViewModel(
    service: ExamplePagingService,
) : ViewModel() {
    private var query = ""
    private val flow = Pager(
        config = PagingConfig(
            pageSize = 20
        ),
    ) {
        ExamplePagingSource(
            service = service,
            query = query,
        )
    }.flow
        .cachedIn(viewModelScope)
}
  • cachedIn() 연산자를 통해 제공된 CoroutineScope에서 로드된 데이터를 캐시합니다.
  • Pager 객체는 PagingSource 객체에서 load() 메서드를 호출하여 LoadParams 객체를 제공하고, 반환되는 LoadResult 객체를 수신합니다.

RecyclerView 어댑터

  • 데이터를 RecyclerView 목록에 수신하는 어댑터를 설정해야 합니다.
  • PagingDataAdapter를 사용할 수 있습니다.

PagingDataAdapter

  • PagingDataAdapter를 확장하는 클래스를 정의합니다.
  • 뷰 홀더를 사용하여 PagingUser 목록에 대한 RecyclerView 어댑터를 제공할 수 있습니다.
class ExamplePagingDataAdapter(
    diffCallback: DiffUtil.ItemCallback<PagingUser>
): PagingDataAdapter<PagingUser,PagingUserViewHolder > (diffCallback){
    override fun onBindViewHolder(holder: PagingUserViewHolder, position: Int) {
        val item = getItem(position) ?: return
        holder.bind(item)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PagingUserViewHolder {
        return PagingUserViewHolder(parent)
    }
}
class PagingUserViewHolder(itemView: View) : ViewHolder(itemView){
    fun bind( item: PagingUser){}
}

DiffUtil.ItemCallback

  • DiffUtil.ItemCallback을 정의하여, 데이터 변화를 감지하고 자동으로 UI를 업데이트할 수 있습니다.
object PagingUserComparator:  DiffUtil.ItemCallback<PagingUser>() {
    override fun areItemsTheSame(oldItem: PagingUser, newItem: PagingUser): Boolean {
        return oldItem.id == newItem.id
    }

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

UI에 페이징 데이터 표시

  • onCreate 또는 onViewCreated에서 다음 단계를 진행하여 UI를 표시할 수 있습니다.
    • PagingDataAdapter 클래스 인스턴스 생성
    • RecyclerView에 Adapter 전달
    • PagingData 스트림을 확인하고 생성된 각 값을 어댑터의 submitData() 메서드에 전달
class PagingFragment : Fragment() {
    private val pagingService = ExamplePagingService()
    private val viewModel = ExamplePagingViewModel(pagingService)
    private val pagingDataAdapter = ExamplePagingDataAdapter(PagingUserComparator)
    //recyclerView.adapter = pagingDataAdapter
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        lifecycleScope.launch {
            viewModel.flow.collectLatest { pagingData ->
                pagingDataAdapter.submitData(pagingData)
            }
        }
    }
}