본문 바로가기

안드로이드/라이브러리 추천

[AOS] Paging3 적용하기

반응형

페이징은 뉴스기사나 이커머스와 같이 많은 양의 데이터를 유저에게 원활하게 보여주기 위해 사용합니다.

 

장점 

1. 중복 로딩 방지

2. 현재 데이터 페이지 넘버를 변수로 관리

3. 리스트 스크롤 특정 시점에 맞춰 데이터 로드

4. 로딩이나 에러등의 상태 관리의 용이하고 새로 고침이나 재시도 기능을 제공

5. 새로 고침된 데이터 변경시 'notifyItemChanged(position: Int)' 메소드등 호출 하지 않아도 자연 스럽게 변경 

Paging3로 마이그레이션 하면서 겪은 내용 및 작업들을 공유하겠습니다. 

※저는 MVVM 패턴과 Clean Architecture 구조에서 작업 했습니다.

 

구조

PaingSource나 RemoteMediator에서 PagingConfig 정보를 토대로 Pager를 통해 PagingData를 생성한뒤 PagingDataAdapter를 활용해 UI를 그립니다. 

 

PagingSource

class DataSource constructor(
    private val api: ApiService,
    private val paramA: String,
    private val paramB: String
): PagingSource<Int, ResponseData>() {
// Int 는 현재 페에지의 Position을 나타냅니다.

    private val STARTING_PAGE_INDEX = 1

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ResponseData> {
        val position = params.key ?: STARTING_PAGE_INDEX

        return try {

            val response = api.select(
                page = position,
                paramA = paramA,
                paramB = paramB
            ).convertData()

            LoadResult.Page(
                data = response.data?.items ?: listOf(),
                prevKey = null,
                nextKey = if(response.data?.isLast == true) null else position +1
            )

        }catch (e: IOException){
            LoadResult.Error(e)
        }catch (e: HttpException){
            LoadResult.Error(e)
        }

    }

    override fun getRefreshKey(state: PagingState<Int, ResponseData>): Int? {
    
        return state.anchorPosition?.let{ achorPosition ->
            state.closestPageToPosition(achorPosition)?.prevKey?.plus(1)
                ?:state.closestPageToPosition(achorPosition)?.nextKey?.minus(1)
        }
    }
}

실제 내부DB나 api의 데이터를 로드하는 곳으로 응답 받는 'ResponseData'를 정의하고, 데이터 로드시 필요한 파라미터(paramA, paramB)를 생성자 변수로 선언해서 사용합니다.

 

RemoteMediator

데이터 로드시 캐싱된 데이터를 사용할 수 있도록 해준다고 합니다. 저는 실제로 사용하지 않아 다루지 않겠습니다. 

 

Pager

Pager는 PagingConfig 기반으로 DataSource를 활용해 LiveData로 스트림화 해주는 클래스입니다. ( flow, rx등도 가능 )

아래 코드는 DataSource를 사용한 UseCase의 코드입니다.

class AUseCase @Inject constructor(
    private val api: ApiService
): LiveData<PagingData<ResponseData>>(){

    data class Param(
        val paramA: String,
        val paramB: String,
        val unitPerPage: Int,
    )

    fun execute(param: Param): LiveData<PagingData<ResponseData>> {
        return Pager(
            PagingConfig(pageSize = param.unitPerPage)
        ){
            DataSource(
                api = api,
                paramA = param.paramA,
                paramB = param.paramB
            )
        }.liveData
    }
}

data class Param은 UseCase를 위한 클래스입니다.

- paramA, paramB: 데이터 호출시 사용되는 파라미터 

- unitPerPage : 한번에 몇개 호출할지 

 

PagingConfig는 데이터 로드 갯수, 초기 데이터 로드 갯수 (initialLoadSize)등을 정할 수 있습니다. 

초기 데이터 로드 갯수를 정하지 않으면 설정된 unitPerPage의 배수로 호출합니다.

 

MVVM, CleanArchitecture에 적용하기

DataSource를 어디 레이어에 녹여야 하는지 많이 고민하고 관련 블로그들을 서칭했는데 테스트 코드를 다룬 블로그들뿐 깊이 고민한 블로그를 찾지 못했습니다. 프로젝트 구조 또한 달랐구요.

레이어 별로 모듈을 나눠 사용했다면 data 레이어에 사용했겠지만 저는 datsSource와 Repo 2가지 역할을 하도록 했습니다. ( 위 코드 참고 ) 

 

보통 repo에서 DataSource에서 불러온 model 기반 데이터를 data로 맵핑하는 역할을 해줬는데 PagingData로 감싸져 있어 원활한 작업에 어려움이 있었습니다. 

( 다른 경험이 있다면 댓글로 자유롭게 공유 해주시면 감사하겠습니다. ) 

 

 

PagingDataAdapter

리싸이클러뷰로 페이징 처리를 하려면 Recyclerview.Adapter 대신 사용해야 합니다. (PaingDataAdapter<A : Any,B : Recyclerview.ViewHolder> ) 

class PagingAdapter():
PagingDataAdapter<ResponseData, ItemViewHolder>(
    object : DiffUtil.ItemCallback<ResponseData>(){

        override fun areItemsTheSame(
            oldItem: ResponseData,
            newItem: ResponseData
        ): Boolean {

            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(
            oldItem: ResponseData,
            newItem: ResponseData
        ): Boolean {
            return oldItem.equals(newItem)
        }

    }
){
    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        getItem(position)?.let{
            holder.bind(it)
        }

    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        val binding = ItemBinding.inflate(LayoutInflater.from(
            parent.context), parent, false
        )

        return ItemViewHolder(binding)
    }
}

DiffUtil을 정의 해줘야 합니다.

areItemsTheSame: 같은 아이템인지 구분 하는 용도로 보통 id를 비교합니다.  중복될수 있는 '이름'등의 요소를 사용하면 새로 고침시 id가 다르지만 이름이 같으면 UI에 노출되지 않습니다. 꼭 id가 아니더라도 유니크한 값을 비교해서 사용해주세요.

 

areContentsTheSame : 데이터가 같은지 비교합니다. 1번 아이디의 아이템이 새로고침했을때 변경된 데이터를 표현할때 사용하기 때문에 id뿐만 아니라 UI표현에 중점을 둘 데이터를 비교해주세요.  

부적절하게 사용할 경우 새로고침된 데이터를 유저가 확인할 수 없습니다. 

 

Adapter 로딩등의 상태처리


    pagingAdapter.addLoadStateListener { state ->

        when(val result = state.source.refresh){
            is LoadState.Error -> {
                hidLoading()
                val error = result.error
                if( error is HttpException){
                    viewModel.handleError(error.code())
                }
            }

            is LoadState.Loading -> {
                showLoading()
            }

            else -> {
                hidLoading()
            }
        }


        binding.isEmptyList = pagingAdapter.itemCount == 0
        binding.executePendingBindings()
     }

 

Paging3 단점 

- NestedSrcollView 속 Recyclerview에선 사용하지 못합니다. 

- 초기 학습 곡선이 존재합니다.

- liveData를 사용하다 보니 viewModel에서 사용하는데 한계가 있습니다. 

단순히 로드하고, 페이징 기능만 필요한게 아니고 검색 기능이 있다고 했을때 검색 조건에 따른 결과를 보여줄때 다소 비효율?적일 것 같습니다.

    val items = aLivData.switchMap { aLivData ->
        AUseCase.execute(
            param = AUseCase.Param(
                paramA = aLivData,
                paramB = bLiveData,
                unitPerPage = 5,
            )
        ).cachedIn(viewModelScope)
    }

저의 경우 param A,B가 변경이 가능해 load 함수를 호출해 사용 했는데 paging 도입시  위의 코드 형태로밖엔 안되더라구요.

기존과 비슷하게 param A,B의 값을 이벤트를 통해 변경하고 adapter refresh 메소드를 호출하면 변경된 param A,B의 값이 적용된 결과 값이 아닌 기존에 설정된 값으로 유지되더라구요.

 

그래서 위 코드 처럼 param A,B를 라이브데이터로 변경하고 그 값을 구독해서 변경시 마다 새롭게 호출하는 방식으로 구현했습니다.

 

다른 방법이 있다면 이 역시 자유롭게 남겨주시면 감사하겠습니다.

 

후기

앞으로 더 활용하면서 제대로 익혀야겠지만 코드가 간결해지면서 가독성도 높아진건 확실한것 같습니다. 

 

 

 

반응형