Paging

대용량의 데이터를 작은 페이지로 분할하여 효율적으로 로드하기 위함이다.

Paging의 장점

  • 메모리에 페이징된 데이터를 캐싱해둠으로써 시스템 리소스 효율적 사용
  • 새로운 데이터 요청 중복 방지 기능 기본 제공
  • 로드된 데이터의 끝까지 스크롤 할 경우 자동으로 다음 데이터 요청
  • 새로고침(리프레시) 기능을 포함한 오류 처리 기본 지원
  • 코틀린의 코루틴 및 Flow를 지원하며, 또한 LiveData 와 RxJava를 지원

MVVM에서 Paging의 흐름

image

  • Repository
    RepositoryPager에서 PagingSourcePagingConfig를 통해 PagingData를 만들고 Flow 형태로 ViewModel로 보내고 있다.

  • ViewModel
    이 후 ViewModel에서는 Flow<PagingData>의 객체를 갖고 있고

  • UI UI에서는 Flow<PagingData>collectAsLazyPagingItems()를 통해 LazyPagingItems 인스턴스를 받아온다.

Paging 관련된 기본 개념

PagingSource

네트워크나 DB로부터 데이터를 로드하는 클래스

필수적으로 load()와 getRefreshKey()를 오버라이딩 해줘야한다.

  • load() 네트워크나 DB의 데이터를 UI로 로드하는 역할

  • getRefreshKey()
    새로고침 등의 현재 PagingSource의 무효화가 일어날 때 다음 PagingSource의 초기 load를 위한 Key 제공하는 역할

PagingConfig

언제 PagingSource로부터 얼만큼 PagingData를 가져올지 설정하는 클래스

PagingData 인스턴스를 만들 때 설정하는 클래스라고 생각하자.

  • pageSize
    한 페이지의 크기를 의미한다.

  • initialLoadSize 처음에 가져오는 데이터 사이즈를 의미한다.

  • prefetchDistance 사용자가 화면 스크롤을 진행할 때 미리 데이터를 로드하여 부드러운 사용자 경험을 제공한다.

    예를 들어, prefetchDistance를 50으로 설정한다면 이미 액세스된 데이터의 가장자리로부터 50개의 항목을 미리 로드하려고 시도할 것입니다. 이는 사용자가 스크롤하여 새로운 데이터를 볼 때까지 스크롤되는 동안 부드러운 화면 전환이 가능하도록 합니다. 만약 prefetchDistance가 0으로 설정된다면, 사용자가 스크롤을 하더라도 새로운 데이터를 요청하기 전까지는 아무것도 로드되지 않습니다. 이 경우 사용자가 스크롤할 때 빈 화면이나 데이터가 없는 화면이 나타날 수 있으므로 사용자 경험에 부정적인 영향을 미칠 수 있습니다.

PagingData

PagingSource에서 한 번 load한 데이터를 담는 컨테이너 클래스

PagingSource에서 특정 page를 스냅샷 찍어 PagingData에 넣는다.

LoadResult.Page<Key,Value>

PagingSource.load 성공에 대한 결과로 반환되는 객체

Pager

실제로 Paging Source로부터 Paging Data를 만들어 내는 클래스

PagingConfigPagingSourceFactory를 통해 Flow형태로 데이터스트림을 만들어낼 수 있다.

LazyPagingItems

UI에서 생성하여 사용, 실제 데이터들이 담겨져 있다.

Flow<PagingData>collectAsLazyPagingItems()를 통해 생성된다.

  • LazyPagingItems.loadState.append
    페이징 데이터의 추가 부분(새로운 페이지)이 로드되는 상태를 나타낸다. 예를 들어, 사용자가 스크롤하여 새로운 페이지를 로드할 때 해당 상태가 활성화된다.
  • LazyPagingItems.loadState.refresh 데이터가 새로 고쳐지는(refresh) 상태를 나타낸다. 이는 일반적으로 사용자가 스와이프 또는 새로 고침 버튼을 사용하여 목록을 다시 로드하려고 할 때 발생한다.

실제 코드

Dependency

//paging 3
implementation ( "androidx.paging:paging-runtime-ktx:3.2.1")
implementation ("androidx.paging:paging-compose:3.3.0-alpha02")

//retrofit
implementation ("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.google.code.gson:gson:2.8.9")
implementation("com.squareup.retrofit2:converter-gson:2.6.0")

//coil
implementation("io.coil-kt:coil-compose:2.6.0")

//dagger hilt
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")   // Hilt compiler
implementation("androidx.hilt:hilt-navigation-compose:1.0.0") // compose에서 hilt

// okhttp
implementation("com.squareup.okhttp3:okhttp:4.8.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.8.0")

MovieApi

interface MovieApi {
    companion object {
        const val SERVER_URL = "https://api.themoviedb.org/3"
        const val API_URL = "$SERVER_URL/movie/"
    }

    @GET("popular")
    suspend fun getMovies(
        @Query("api_key") apiKey: String,
        @Query("page") pageNumber: Int
    ): ResponseDto<List<MovieResponseDto>>
}

MovieRepository

class MovieRepository @Inject constructor(
    private val movieApi: MovieApi
) {
    suspend fun getMovies(): Flow<PagingData<Movie>> {
        return Pager(
            config = PagingConfig(pageSize = Constants.MAX_PAGE_SIZE, prefetchDistance = 2),
            pagingSourceFactory = { MoviePagingSource(movieApi) }
        ).flow
    }
}

MoviePagingSource

class MoviePagingSource @Inject constructor(
    private val movieApi: MovieApi
): PagingSource<Int, Movie>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Movie> {
        return try {
            val currentPage = params.key ?: 1
            val movies = movieApi.getMovies(
                apiKey = Constants.MOVIE_API_KEY,
                pageNumber = currentPage
            )
            LoadResult.Page(
                data = movies.results!!.mapFromListModel(),
                prevKey = if (currentPage == 1) null else currentPage - 1,
                nextKey = if (movies.results.isEmpty()) null else movies.page!! + 1
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Movie>): Int? {
        return state.anchorPosition
    }
}

MovieViewModel

@HiltViewModel
class MovieViewModel @Inject constructor(private val movieRepository: MovieRepository) :
    ViewModel() {
    private val _moviesState: MutableStateFlow<PagingData<Movie>> =
        MutableStateFlow(value = PagingData.empty())
    val movieState: MutableStateFlow<PagingData<Movie>> = _moviesState

    init {
        viewModelScope.launch {
            movieRepository.getMovies().distinctUntilChanged().cachedIn(viewModelScope).collect {
                _moviesState.value = it
            }
        }
    }
}

MovieScreen

@Composable
fun MovieScreen(movieViewModel: MovieViewModel = hiltViewModel()) {
    val moviePagingItems: LazyPagingItems<Movie> =
        movieViewModel.movieState.collectAsLazyPagingItems()

    LazyColumn(modifier = Modifier.fillMaxSize()) {
        item { Spacer(modifier = Modifier.padding(4.dp)) }

        items(moviePagingItems.itemCount) { index ->
            ItemMovie(
                itemEntity = moviePagingItems[index]!!,
                onClick = {}
            )
        }
        moviePagingItems.apply {
            when {
                loadState.refresh is LoadState.Loading -> {
                    Log.d("daeYoung", "loadState.refresh is Loading")
                }

                loadState.refresh is LoadState.Error -> {
                    Log.d("daeYoung", "loadState.refresh is Error")
                }

                loadState.append is LoadState.Loading -> {
                    Log.d("daeYoung", "loadState.append is Loading")
                }

                loadState.append is LoadState.Error -> {
                    Log.d("daeYoung", "loadState.append is Error")
                }
            }
        }
        item { Spacer(modifier = Modifier.padding(4.dp)) }
    }
}

cashedIn()을 사용하는 이유

Android Jetpack Paging3의 예제를 보면 cachedIn() 메서드를 활용하여 데이터를 캐싱하는 코드가 있는것을 볼 수 있다.
cashedIn()을 사용하는 이유는 구성변경(화면 회전)을 했을 때 데이터를 다시 요청하는 api 통신을 하지 않고 미리 저장해준 cash 데이터를 통해 화면에 데이터를 보여준다. 불 필요한 api 통신을 안해도 된다는 점에서 이점이 있다. (scope 를 viewModelScope 를 전달했기 때문에 viewModelScope 가 활성화된 동안)

핵심 코드부분만 위에서 설명하였고 추가적인 코드를 확인하고 싶다면 Github에 들어가서 확인해보자.