[Kotlin] Compose Paging3.
Paging
대용량의 데이터를 작은 페이지로 분할하여 효율적으로 로드하기 위함이다.
Paging의 장점
- 메모리에 페이징된 데이터를 캐싱해둠으로써 시스템 리소스 효율적 사용
- 새로운 데이터 요청 중복 방지 기능 기본 제공
- 로드된 데이터의 끝까지 스크롤 할 경우 자동으로 다음 데이터 요청
- 새로고침(리프레시) 기능을 포함한 오류 처리 기본 지원
- 코틀린의 코루틴 및 Flow를 지원하며, 또한 LiveData 와 RxJava를 지원
MVVM에서 Paging의 흐름
-
Repository
Repository의 Pager에서 PagingSource와 PagingConfig를 통해 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를 만들어 내는 클래스
PagingConfig와 PagingSourceFactory를 통해 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에 들어가서 확인해보자.