1. 소개
과정 내용
- Paging 3의 기본 구성요소
- Paging 3을 프로젝트에 추가하는 방법
- Paging 3 API를 사용하여 목록에 머리글 또는 바닥글을 추가하는 방법
- Paging 3 API를 사용하여 목록 구분자를 추가하는 방법
- 네트워크 및 데이터베이스에서 페이징하는 방법
빌드할 항목
이 Codelab에서는 이미 GitHub 저장소 목록을 표시하는 샘플 앱으로 시작합니다. 사용자가 표시된 목록의 끝으로 스크롤할 때마다 새로운 네트워크 요청이 트리거되고 결과가 화면에 표시됩니다.
일련의 단계를 통해 코드를 추가하여 다음과 같은 작업을 실행합니다.
- Paging 라이브러리 구성요소로 이전
- 목록에 로드 상태 머리글 및 바닥글 추가
- 모든 새 저장소 검색 사이에 로딩 진행률 표시
- 목록에 구분자 추가
- 네트워크 및 데이터베이스에서의 페이징을 위한 데이터베이스 지원 추가
앱은 최종적으로 다음과 같이 표시됩니다.
준비물
- Android 스튜디오 Arctic Fox.
- LiveData, ViewModel, 뷰 바인딩 등의 아키텍처 구성요소와 '앱 아키텍처 가이드'에 추천된 아키텍처에 관한 기본 지식
- 코루틴 및 Kotlin Flow에 관한 기본 지식
아키텍처 구성요소에 관한 소개는 뷰 Codelab이 있는 Room을 확인하세요. Flow에 관한 소개는 Kotlin Flow 및 LiveData Codelab을 사용한 고급 코루틴을 확인하세요.
2. 환경 설정
이 단계에서는 전체 Codelab을 위한 코드를 다운로드한 후 간단한 예시 앱을 실행합니다.
Google에서 준비한 시작 프로젝트를 사용하면 신속하게 빌드할 수 있습니다.
git을 설치한 경우 아래 명령어를 실행하면 됩니다. 터미널/명령줄에 git --version
을 입력하여 올바르게 실행되는지 확인할 수 있습니다.
git clone https://github.com/googlecodelabs/android-paging
초기 상태는 마스터 분기에 있습니다. 특정 단계의 솔루션은 다음과 같이 확인할 수 있습니다.
- 분기 step5-9_paging_3.0: 프로젝트에 최신 버전의 Paging을 추가하는 5~9단계의 솔루션을 찾을 수 있습니다.
- 분기 step10_loading_state_footer: 로딩 상태를 표시하는 바닥글을 추가하는 10단계의 솔루션을 찾을 수 있습니다.
- 분기 step11_loading_state: 쿼리 간 로드 상태 디스플레이를 추가하는 11단계의 솔루션을 찾을 수 있습니다.
- 분기 step12_separator: 앱에 구분자를 추가하는 12단계의 솔루션을 찾을 수 있습니다.
- 분기 step13-19_network_and_database: 앱에 오프라인 지원을 추가하는 13~19단계의 솔루션을 찾을 수 있습니다.
git이 없는 경우 다음 버튼을 클릭하여 이 Codelab을 위한 모든 코드를 다운로드할 수 있습니다.
- 코드의 압축을 푼 다음 프로젝트 Android 스튜디오를 엽니다.
- 기기 또는 에뮬레이터에서
app
실행 구성을 실행합니다.
앱이 실행되고 다음과 비슷한 GitHub 저장소의 목록이 표시됩니다.
3. 프로젝트 개요
앱을 사용하면 GitHub에서 이름 또는 설명에 특정 단어가 포함된 저장소를 검색할 수 있습니다. 저장소 목록은 별표 수의 내림차순으로 정렬된 후 이름의 가나다순으로 표시됩니다.
앱은 '앱 아키텍처 가이드'에서 권장하는 아키텍처를 따릅니다. 각 패키지에는 다음과 같은 항목이 포함됩니다.
- API: Retrofit을 사용한 GitHub API 호출
- 데이터: API 요청을 트리거하고 메모리에 응답을 캐시하는 저장소 클래스
- 모델: Room 데이터베이스의 테이블이기도 한
Repo
데이터 모델 및 UI에서 검색결과 데이터와 네트워크 오류를 관찰하기 위해 사용하는RepoSearchResult
클래스 - UI:
RecyclerView
가 포함된Activity
표시와 관련된 클래스
GithubRepository
클래스는 사용자가 목록의 끝으로 스크롤할 때마다 또는 새 저장소를 검색할 때 네트워크에서 저장소 이름 목록을 가져옵니다. 쿼리 결과 목록은 ConflatedBroadcastChannel
내 GithubRepository
의 메모리에 저장되고 Flow
로 노출됩니다.
SearchRepositoriesViewModel
에서는 GithubRepository
의 데이터를 요청하고 SearchRepositoriesActivity
에 데이터를 노출합니다. 구성 변경(예: 회전) 시 데이터를 여러 번 요청하지 않기 위해 liveData()
빌더 메서드를 사용하여 ViewModel
에서 Flow
를 LiveData
로 변환합니다. 이렇게 하면 LiveData
에서 메모리의 최신 결과 목록을 캐시하고 SearchRepositoriesActivity
가 다시 생성되면 LiveData
의 콘텐츠가 화면에 표시됩니다. ViewModel
에 다음이 노출됩니다.
LiveData<UiState>
(UiAction) -> Unit
함수
UiState
는 앱의 UI를 렌더링하는 데 필요한 모든 것을 표현한 것으로, 각기 다른 필드는 각기 다른 UI 구성요소에 해당합니다. 이 객체는 변경할 수 없습니다. 하지만 이 객체의 새 버전은 UI에 의해 생성 및 관찰 가능합니다. 여기에서는 사용자가 새 쿼리를 검색하거나 목록을 스크롤하여 더 많은 항목을 가져오는 작업의 결과로 객체의 새 버전이 생성됩니다.
사용자 작업은 적절하게 UiAction
유형으로 표현됩니다. ViewModel
로의 상호작용을 위해 API를 단일 유형으로 묶으면 다음과 같은 이점이 있습니다.
- 작은 API 노출 영역: 작업을 추가, 삭제 또는 변경할 수 있지만
ViewModel
의 메서드 서명은 변경되지 않습니다. 이렇게 하면 리팩터링이 국소화되어 추상화 또는 인터페이스 구현이 유출될 가능성이 줄어듭니다. - 더 쉬운 동시 실행 관리: Codelab의 뒷부분에서 살펴보겠지만, 특정 요청의 실행 순서를 보장하는 것이 중요합니다.
UiAction
으로 API를 강력하게 입력하면 무엇이 언제 발생할 수 있는지에 관한 엄격한 요구사항을 포함하여 코드를 작성할 수 있습니다.
사용성 측면에서 다음과 같은 문제가 있습니다.
- 사용자가 목록 로드 상태를 알 수 없습니다. 사용자가 새 저장소를 검색할 때 빈 화면이 표시되거나 동일한 쿼리의 결과가 더 로드되는 동안 갑자기 목록의 끝이 표시됩니다.
- 사용자가 실패한 쿼리를 다시 시도할 수 없습니다.
- 목록은 방향이 변경되거나 프로세스가 종료된 후 항상 맨 위로 스크롤됩니다.
구현 측면에서 다음과 같은 문제가 있습니다.
- 목록이 메모리에서 무제한으로 늘어나서 사용자가 스크롤함에 따라 메모리가 낭비됩니다.
- 결과를 캐시하려면
Flow
에서LiveData
로 변환해야 하므로 코드가 더 복잡해집니다. - 앱에서 여러 개의 목록을 표시해야 하는 경우 목록마다 많은 상용구를 작성해야 합니다.
Paging 라이브러리를 사용하여 이러한 문제를 해결하는 방법 및 Paging 라이브러리에 포함된 구성요소에 관해 살펴보겠습니다.
4. Paging 라이브러리 구성요소
Paging 라이브러리를 사용하면 앱의 UI 내 데이터를 점진적이고 매끄럽게 로드할 수 있습니다. Paging API는 페이지에서 데이터를 로드해야 할 때 수동으로 구현해야 하는 많은 기능을 지원합니다.
- 다음 및 이전 페이지를 가져오는 데 사용될 키를 추적합니다.
- 사용자가 목록의 끝으로 스크롤하면 자동으로 올바른 페이지를 요청합니다.
- 여러 요청이 동시에 트리거되지 않도록 합니다.
- 데이터를 캐시할 수 있습니다. Kotlin을 사용하는 경우
CoroutineScope
에서 이 작업이 실행되며 자바를 사용하는 경우LiveData
로 이 작업을 실행할 수 있습니다. - 로드 상태를 추적하여 UI의
RecyclerView
목록 항목 또는 다른 위치에 표시하고 실패한 로드를 쉽게 다시 시도할 수 있습니다. Flow
,LiveData
, RxJavaFlowable
또는Observable
사용 여부와 관계없이 표시되는 목록에서map
또는filter
와 같은 일반적인 작업을 실행할 수 있습니다.- 목록 구분자를 쉽게 구현할 수 있습니다.
앱 아키텍처 가이드에서는 다음과 같은 기본 구성요소가 포함된 아키텍처를 제안합니다.
- 사용자에게 표시되고 사용자가 조작하는 데이터의 단일 정보 소스로 사용되는 로컬 데이터베이스
- 웹 API 서비스
- 데이터베이스와 함께 작동하는 저장소 및 통합된 데이터 인터페이스를 제공하는 웹 API 서비스
- UI별 데이터를 제공하는
ViewModel
ViewModel
의 데이터를 시각적으로 표현하는 UI
Paging 라이브러리는 이 모든 구성요소와 함께 작동하며 구성요소 간의 상호작용을 조정하므로 데이터 소스에서 콘텐츠의 '페이지'를 로드하여 UI에 콘텐츠를 표시할 수 있습니다.
이 Codelab에서는 Paging 라이브러리와 이 라이브러리의 기본 구성요소를 소개합니다.
PagingData
: 페이지로 나눈 데이터의 컨테이너. 데이터를 새로고침할 때마다 상응하는PagingData
가 별도로 생성됩니다.PagingSource
:PagingSource
는 데이터의 스냅샷을PagingData
의 스트림으로 로드하기 위한 기본 클래스입니다.Pager.flow
: 구현된PagingSource
의 구성 방법을 정의하는 함수와PagingConfig
를 기반으로Flow<PagingData>
를 빌드합니다.PagingDataAdapter
:RecyclerView
에PagingData
를 표시하는RecyclerView.Adapter
.PagingDataAdapter
는 KotlinFlow
,LiveData
, RxJavaFlowable
또는 RxJavaObservable
에 연결할 수 있습니다.PagingDataAdapter
는 페이지가 로드될 때 내부PagingData
로딩 이벤트를 수신 대기하고 업데이트된 콘텐츠가 새로운PagingData
객체 형태로 수신될 때 백그라운드 스레드에서DiffUtil
를 사용하여 세분화된 업데이트를 계산합니다.RemoteMediator
: 네트워크 및 데이터베이스에서 페이지로 나누기를 구현하는 데 유용합니다.
이 Codelab에서는 위에 설명된 각 구성요소의 예를 구현합니다.
5. 데이터 소스 정의
PagingSource
구현에서는 데이터 소스를 정의하고 이 소스에서 데이터를 가져오는 방법을 정의합니다. PagingData
객체는 사용자가 RecyclerView
에서 스크롤할 때 생성되는 힌트가 로드되면 PagingSource
에서 데이터를 쿼리합니다.
현재 GithubRepository
는 추가된 후 Paging 라이브러리에서 처리되는 데이터 소스와 관련하여 다양한 작업을 실행합니다.
- 여러 요청이 동시에 트리거되지 않도록
GithubService
에서 데이터를 로드합니다. - 검색된 데이터의 메모리 내 캐시를 유지합니다.
- 요청될 페이지를 추적합니다.
PagingSource
를 빌드하려면 다음 항목을 정의해야 합니다.
- 페이징 키의 유형: 여기서는 GitHub API에서 페이지에 1을 기반으로 하는 색인 번호를 사용하므로 유형이
Int
입니다. - 로드된 데이터의 유형: 여기서는
Repo
항목을 로드합니다. - 데이터를 가져오는 위치:
GithubService
에서 데이터를 가져옵니다. 데이터 소스는 특정 쿼리에 한정되므로 쿼리 정보도GithubService
에 전달해야 합니다.
따라서 data
패키지에서 GithubPagingSource
라는 PagingSource
구현을 만들어 보겠습니다.
class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
TODO("Not yet implemented")
}
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
TODO("Not yet implemented")
}
}
PagingSource
에서 두 가지 함수(load()
및 getRefreshKey()
)를 구현해야 합니다.
사용자가 스크롤할 때 표시할 더 많은 데이터를 비동기식으로 가져오기 위해 Paging 라이브러리에서 load()
함수를 호출합니다. LoadParams
객체에는 다음 항목을 포함하여 로드 작업과 관련된 정보가 저장됩니다.
- 로드할 페이지의 키. 로드가 처음 호출되는 경우에는
LoadParams.key
가null
입니다. 여기서는 초기 페이지 키를 정의해야 합니다. 이 프로젝트에서는 이 키가 초기 페이지 키이므로GITHUB_STARTING_PAGE_INDEX
상수를GithubRepository
에서PagingSource
구현으로 이동해야 합니다. - 로드 크기: 로드 요청된 항목의 수
로드 함수는 LoadResult
를 반환합니다. LoadResult
가 다음 유형 중 하나를 취할 수 있으므로 이 앱에서는 RepoSearchResult
대신 사용됩니다.
LoadResult.Page
: 로드에 성공한 경우LoadResult.Error
: 오류가 발생한 경우
LoadResult.Page
를 구성할 때 상응하는 방향으로 목록을 로드할 수 없는 경우 nextKey
또는 prevKey
에 null
을 전달합니다. 예를 들어 여기서는 네트워크 응답에 성공했지만 목록이 비어 있는 경우에는 로드할 데이터가 없는 것으로 간주할 수 있습니다. 따라서 nextKey
가 null
일 수 있습니다.
이 모든 정보를 바탕으로 load()
함수를 구현할 수 있습니다.
다음으로 getRefreshKey()
를 구현해야 합니다. 새로고침 키는 PagingSource.load()
의 후속 새로고침 호출에 사용됩니다. 첫 번째 호출은 Pager
에 의해 제공되는 initialKey
를 사용하는 초기 로드입니다. 새로고침은 스와이프하여 새로고침하거나 데이터베이스 업데이트, 구성 변경, 프로세스 중단 등으로 인해 무효화되어 Paging 라이브러리가 현재 목록을 대체할 새 데이터를 로드하려고 할 때마다 발생합니다. 일반적으로 후속 새로고침 호출은 가장 최근에 액세스한 인덱스를 나타내는 PagingState.anchorPosition
주변 데이터의 로드를 다시 시작하려고 합니다.
GithubPagingSource
구현은 다음과 같습니다.
// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1
class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
val apiQuery = query + IN_QUALIFIER
return try {
val response = service.searchRepos(apiQuery, position, params.loadSize)
val repos = response.items
val nextKey = if (repos.isEmpty()) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
position + (params.loadSize / NETWORK_PAGE_SIZE)
}
LoadResult.Page(
data = repos,
prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
nextKey = nextKey
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
// The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
// We need to get the previous key (or next key if previous is null) of the page
// that was closest to the most recently accessed index.
// Anchor position is the most recently accessed index
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
6. PagingData 빌드 및 구성
현재 구현에서는 GitHubRepository
의 Flow<RepoSearchResult>
를 사용하여 네트워크에서 데이터를 가져와 ViewModel
에 전달합니다. 그런 다음 ViewModel
에서 데이터를 LiveData
로 변환하여 UI에 노출합니다. 표시된 목록의 끝에 도달하여 네트워크에서 더 많은 데이터가 로드될 때마다 Flow<RepoSearchResult>
에는 최신 데이터 외에 쿼리 결과 이전에 가져온 데이터의 전체 목록이 포함됩니다.
RepoSearchResult
에서는 성공 및 오류 사례를 모두 캡슐화합니다. 성공 사례에는 저장소 데이터가 포함됩니다. 오류 사례에는 Exception
이유가 포함됩니다. Paging 3을 사용하면 라이브러리가 LoadResult
로 성공 및 오류 사례를 모두 모델링하므로 RepoSearchResult
가 더 이상 필요하지 않습니다. RepoSearchResult
는 다음 단계에서 대체되므로 삭제해도 됩니다.
PagingData
를 구성하려면 먼저 PagingData
를 앱의 다른 레이어에 전달하는 데 사용할 API를 결정해야 합니다.
- Kotlin
Flow
-Pager.flow
사용 LiveData
-Pager.liveData
사용- RxJava
Flowable
-Pager.flowable
사용 - RxJava
Observable
-Pager.observable
사용
앱에서 이미 Flow
를 사용하고 있으므로 이 방법을 계속 사용하겠습니다. 단 Flow<RepoSearchResult>
대신 Flow<PagingData<Repo>>
를 사용합니다.
사용하는 PagingData
빌더에 관계없이 다음 매개변수를 전달해야 합니다.
PagingConfig
. 이 클래스는 로드 대기 시간, 초기 로드의 크기 요청 등PagingSource
에서 콘텐츠를 로드하는 방법에 관한 옵션을 설정합니다. 정의해야 하는 유일한 필수 매개변수는 각 페이지에 로드해야 하는 항목 수를 가리키는 페이지 크기입니다. 기본적으로 Paging은 로드하는 모든 페이지를 메모리에 유지합니다. 사용자가 스크롤할 때 메모리를 낭비하지 않으려면PagingConfig
에서maxSize
매개변수를 설정하세요. 기본적으로 Paging은 로드되지 않은 항목을 집계할 수 있고enablePlaceholders
구성 플래그가 true인 경우 아직 로드되지 않은 콘텐츠의 자리표시자로 null 항목을 반환합니다. 이와 마찬가지로 어댑터에 자리표시자 뷰를 표시할 수 있습니다. 이 Codelab의 작업을 단순화하기 위해enablePlaceholders = false
를 전달하여 자리표시자를 사용 중지하겠습니다.PagingSource
를 만드는 방법을 정의하는 함수. 여기서는 각 새 쿼리를 위해 새GithubPagingSource
을 만듭니다.
GithubRepository
를 수정해 보겠습니다.
GithubRepository.getSearchResultStream
업데이트
suspend
수정자를 삭제합니다.Flow<PagingData<Repo>>
를 반환합니다.Pager
를 구성합니다.
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { GithubPagingSource(service, query) }
).flow
}
GithubRepository
정리
Paging 3은 많은 작업을 실행합니다.
- 메모리 내 캐시를 처리합니다.
- 사용자가 목록의 끝에 가까워지면 데이터를 요청합니다.
즉, getSearchResultStream
및 NETWORK_PAGE_SIZE
를 정의한 컴패니언 객체를 제외한 GithubRepository
의 모든 값이 삭제될 수 있습니다. 이제 GithubRepository
가 다음과 같이 표시됩니다.
class GithubRepository(private val service: GithubService) {
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { GithubPagingSource(service, query) }
).flow
}
companion object {
const val NETWORK_PAGE_SIZE = 50
}
}
이제 SearchRepositoriesViewModel
에 컴파일 오류가 발생합니다. 어떻게 변경해야 하는지 알아보겠습니다.
7. ViewModel에서 PagingData 요청 및 캐시
컴파일 오류를 해결하기 전에 ViewModel
의 유형을 검토해 보겠습니다.
sealed class UiAction {
data class Search(val query: String) : UiAction()
data class Scroll(
val visibleItemCount: Int,
val lastVisibleItemPosition: Int,
val totalItemCount: Int
) : UiAction()
}
data class UiState(
val query: String,
val searchResult: RepoSearchResult
)
UiState
에서 searchResult
를 노출합니다. searchResult
는 구성 변경 이후에도 지속되는 결과 검색의 메모리 내 캐시로 사용됩니다. Paging 3을 사용하면 더 이상 Flow
를 LiveData
로 변환할 필요가 없습니다. 대신 SearchRepositoriesViewModel
은 이제 StateFlow<UiState>
를 노출합니다. 또한 searchResult
val을 완전히 드롭하여 searchResult
와 동일한 용도로 사용되는 별도의 Flow<PagingData<Repo>>
를 대신 노출하도록 합니다.
PagingData
는 RecyclerView
에 표시되는 데이터의 변경 가능한 업데이트 스트림을 포함하는 독립된 유형입니다. PagingData
의 각 내보내기는 완전히 독립적이며 하나의 쿼리에 관해 여러 개의 PagingData
를 내보낼 수 있습니다. 따라서 PagingData
의 Flows
는 다른 Flows
와 독립적으로 노출되어야 합니다.
그 외에 사용자 환경 혜택으로, 입력된 모든 새 쿼리와 관련해 목록의 상단으로 스크롤하면 첫 번째 검색결과가 표시됩니다. 하지만 페이징 데이터가 여러 번 내보내질 수 있으므로 사용자가 스크롤을 시작하지 않은 경우에만 목록 상단으로 스크롤할 수 있습니다.
이를 위해 UiState
를 업데이트하고 lastQueryScrolled
및 hasNotScrolledForCurrentSearch
의 필드를 추가해 보겠습니다. 이러한 플래그는 스크롤해서는 안 될 때 목록 상단으로 스크롤하지 못하게 합니다.
data class UiState(
val query: String = DEFAULT_QUERY,
val lastQueryScrolled: String = DEFAULT_QUERY,
val hasNotScrolledForCurrentSearch: Boolean = false
)
아키텍처를 다시 살펴보겠습니다. ViewModel
에 관한 모든 요청이 하나의 진입점((UiAction) -> Unit
으로 정의된 accept
필드)을 통과하므로 다음을 수행해야 합니다.
- 진입점을 관심 있는 유형이 포함된 스트림으로 변환합니다.
- 그러한 스트림을 변환합니다.
- 스트림을 다시
StateFlow<UiState>
로 결합합니다.
보다 실용적인 면에서 UiAction
을 UiState
로 내보내는 것을 reduce
할 예정입니다. 이 과정은 일종의 조립 라인과 같습니다. UiAction
유형은 들어오는 원자재로서 효과(변형이라고도 함)를 유발하고, UiState
는 UI에 결합할 준비가 된 완성된 출력에 해당합니다. 이는 UI를 UiState
의 함수로 만드는 과정이라고도 합니다.
ViewModel
을 다시 작성하여 서로 다른 두 스트림에서 각 UiAction
유형을 처리한 다음, 이를 몇 가지 Kotlin Flow
연산자를 사용하여 StateFlow<UiState>
로 변환해 보겠습니다.
먼저 ViewModel
에서 LiveData
대신에 StateFlow
를 사용하도록 state
의 정의를 업데이트하면서 동시에 PagingData
의 Flow
를 노출하기 위한 필드를 추가합니다.
/**
* Stream of immutable states representative of the UI.
*/
val state: StateFlow<UiState>
val pagingDataFlow: Flow<PagingData<Repo>>
그다음으로 UiAction.Scroll
서브클래스의 정의를 업데이트합니다.
sealed class UiAction {
...
data class Scroll(val currentQuery: String) : UiAction()
}
UiAction.Scroll
데이터 클래스의 모든 필드를 삭제하고 이들 필드를 단일 currentQuery
문자열로 대체했습니다. 이렇게 하면 스크롤 작업을 특정 쿼리와 연결할 수 있습니다. 또한 shouldFetchMore
확장 프로그램은 더 이상 사용되지 않으므로 삭제합니다. 이 프로그램은 프로세스가 중단되면 복원되어야 하는 항목이므로, SearchRepositoriesViewModel
에서 onCleared()
메서드를 업데이트해야 합니다.
class SearchRepositoriesViewModel{
...
override fun onCleared() {
savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
super.onCleared()
}
}
// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"
이 시점에서 GithubRepository
에서 실제로 pagingData
Flow
를 생성하는 메서드를 삽입하는 것도 좋습니다.
class SearchRepositoriesViewModel(
...
) : ViewModel() {
override fun onCleared() {
...
}
private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
repository.getSearchResultStream(queryString)
}
Flow<PagingData>
에는 CoroutineScope
에서 Flow<PagingData>
의 콘텐츠를 캐시할 수 있는 편리한 cachedIn()
메서드가 있습니다. ViewModel
에 있으므로 androidx.lifecycle.viewModelScope
를 사용합니다.
이제 ViewModel의 accept
필드를 UiAction
스트림으로 변환하는 작업을 시작할 수 있습니다. SearchRepositoriesViewModel
의 init
블록을 다음으로 바꿉니다.
class SearchRepositoriesViewModel(
...
) : ViewModel() {
...
init {
val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
val actionStateFlow = MutableSharedFlow<UiAction>()
val searches = actionStateFlow
.filterIsInstance<UiAction.Search>()
.distinctUntilChanged()
.onStart { emit(UiAction.Search(query = initialQuery)) }
val queriesScrolled = actionStateFlow
.filterIsInstance<UiAction.Scroll>()
.distinctUntilChanged()
// This is shared to keep the flow "hot" while caching the last query scrolled,
// otherwise each flatMapLatest invocation would lose the last query scrolled,
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
replay = 1
)
.onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
}
}
위의 코드 스니펫에 관해 살펴보겠습니다. 먼저 두 가지 항목으로 시작합니다. 하나는 저장된 상태 또는 기본값에서 가져온 initialQuery
String
이고, 다른 하나는 사용자가 목록과 상호작용한 마지막 검색어를 나타내는 String
인 lastQueryScrolled
입니다. 그다음으로 Flow
를 특정 UiAction
유형으로 분할합니다.
UiAction.Search
- 사용자가 특정 쿼리를 입력하는 각 경우UiAction.Scroll
- 사용자가 포커스가 지정된 특정 쿼리와 함께 목록을 스크롤하는 각 경우
UiAction.Scroll Flow
에는 몇 가지 추가 변환이 적용됩니다. 그에 관해 살펴보겠습니다.
shareIn
: 이 항목이 필요한 이유는 이Flow
가 최종적으로 사용될 때flatmapLatest
연산자를 통해 사용되기 때문입니다. 업스트림에서 내보낼 때마다flatmapLatest
는 마지막으로 작업한Flow
를 취소하고 주어진 새 흐름을 기반으로 작업하기 시작합니다. 이 경우 여기서는 사용자가 스크롤한 마지막 쿼리의 값이 손실됩니다. 따라서 새 쿼리가 수신되더라도 값이 손실되지 않도록replay
값이 1인Flow
연산자를 사용하여 마지막 값을 캐시합니다.onStart
: 캐싱에도 사용됩니다. 앱이 종료되었지만 사용자가 이미 쿼리를 스크롤했다면 목록을 다시 맨 위로 스크롤하지 않는 것이 좋습니다. 맨 위로 스크롤하면 현재의 위치를 잃게 됩니다.
아직 state
, pagingDataFlow
및 accept
필드를 정의하지 않아 컴파일 오류가 발생합니다. 이 문제를 해결해보겠습니다. 변환이 각 UiAction
에 적용된 상태에서 이제 그 변환을 사용하여 PagingData
는 물론 UiState
의 흐름도 만들 수 있습니다.
init {
...
pagingDataFlow = searches
.flatMapLatest { searchRepo(queryString = it.query) }
.cachedIn(viewModelScope)
state = combine(
searches,
queriesScrolled,
::Pair
).map { (search, scroll) ->
UiState(
query = search.query,
lastQueryScrolled = scroll.currentQuery,
// If the search query matches the scroll query, the user has scrolled
hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
accept = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
}
새로운 검색어마다 Pager
를 새로 만들어야 하므로 searches
흐름에 flatmapLatest
연산자를 사용합니다. 그런 다음 cachedIn
연산자를 PagingData
흐름에 적용하여 viewModelScope
내에서 흐름을 활성 상태로 유지하고 결과를 pagingDataFlow
필드에 할당합니다. UiState
측면에서는 결합 연산자를 사용하여 필수 UiState
필드를 채우고 결과 Flow
를 노출된 state
필드에 할당합니다. 또한 accept
를 정의할 때는 상태 머신을 제공하는 정지 함수를 시작하게 하는 람다로 정의합니다.
이제 완료됐습니다. 이제 리터럴 프로그래밍 관점과 반응형 프로그래밍 관점에서 모두 작동하는 ViewModel
이 만들어졌습니다.
8. PagingData를 사용하도록 어댑터 설정
PagingData
를 RecyclerView
에 바인딩하려면 PagingDataAdapter
를 사용하세요. PagingData
콘텐츠가 로드될 때마다 PagingDataAdapter
에서 알림을 받은 다음 RecyclerView
에 업데이트하라는 신호를 보냅니다.
PagingData
스트림을 사용하도록 ui.ReposAdapter
업데이트
- 현재는
ReposAdapter
가ListAdapter
를 구현합니다. 대신PagingDataAdapter
를 구현하도록 합니다. 클래스 본문의 나머지 부분은 변경되지 않습니다.
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}
지금까지 많은 사항을 변경했지만 이제 한 단계만 더 실행하면 앱을 실행할 수 있습니다. UI를 연결하기만 하면 됩니다.
9. 네트워크 업데이트 트리거
LiveData를 Flow로 바꾸기
Paging 3을 사용하도록 SearchRepositoriesActivity
을 업데이트하겠습니다. Flow<PagingData>
를 사용하려면 새 코루틴을 실행해야 합니다. 이 작업은 활동이 다시 생성될 때 요청을 취소하는 lifecycleScope
에서 실행됩니다.
다행히 필요한 변경이 많지 않습니다. LiveData
를 observe()
하는 대신 coroutine
을 launch()
하고 Flow
를 collect()
합니다. UiState
가 PagingAdapter
LoadState
Flow
와 결합되기 때문에, 사용자가 이미 스크롤한 경우 PagingData
를 새로 내보내며 목록이 맨 위로 다시 스크롤되지 않습니다.
우선 상태를 LiveData
가 아닌 StateFlow
로 반환하고 있기 때문에, Activity
에서 LiveData
의 모든 참조를 StateFlow
로 바꾸고 pagingData
Flow
에 관한 인수도 추가해야 합니다. 첫 번째 위치는 bindState
메서드 내입니다.
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
...
}
이 변경사항은 연쇄 효과를 지니는데, 이제 bindSearch()
및 bindList()
를 업데이트해야 하기 때문입니다. bindSearch()
의 변경이 가장 작기 때문에 이 항목부터 시작하겠습니다.
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener {...}
searchRepo.setOnKeyListener {...}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
여기에서 중요한 변경사항은 코루틴을 실행하고 UiState
Flow
에서 쿼리 변경사항을 수집해야 한다는 점입니다.
스크롤 문제 해결 및 데이터 결합
이제 스크롤 부분입니다. 먼저, 마지막 두 변경사항과 마찬가지로 LiveData
를 StateFlow
로 바꾸고 pagingData
Flow
의 인수를 추가합니다. 이 작업이 완료되면 스크롤 리스너로 이동할 수 있습니다. 이전에는 RecyclerView
에 연결된 OnScrollListener
를 사용하여 더 많은 데이터를 트리거할 시기를 파악했습니다. Paging 라이브러리가 목록 스크롤을 자동으로 처리하지만, 사용자가 현재 쿼리의 목록을 스크롤했는지를 나타내는 신호로 OnScrollListener
가 여전히 필요합니다. bindList()
메서드에서 setupScrollListener()
를 인라인 RecyclerView.OnScrollListener
로 바꾸겠습니다. setupScrollListener()
메서드도 완전히 삭제합니다.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
// the rest of the code is unchanged
}
그다음으로 shouldScrollToTop
부울 플래그를 만들도록 파이프라인을 설정합니다. 그러면 이제 collect
할 수 있는 두 흐름 즉, PagingData
Flow
및 shouldScrollToTop
Flow
가 생깁니다.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(...)
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
위에서는 pagingData
Flow
에 collectLatest
를 사용합니다. 그러면 pagingData
를 새로 내보낼 때 이전 pagingData
내보내기에 관한 수집이 취소 가능합니다. shouldScrollToTop
플래그의 경우 PagingDataAdapter.loadStateFlow
의 내보내기가 UI에 표시되는 내용과 동기화됩니다. 따라서 내보낸 부울 플래그가 true가 되는 즉시 list.scrollToPosition(0)
을 호출하는 것이 안전합니다.
LoadStateFlow의 유형은 CombinedLoadStates
객체입니다.
CombinedLoadStates
를 사용하면 세 가지 유형의 로드 작업의 로드 상태를 가져올 수 있습니다.
CombinedLoadStates.refresh
:PagingData
를 처음 로드할 때의 로드 상태를 나타냅니다.CombinedLoadStates.prepend
: 목록의 시작 부분에서 데이터를 로드하는 작업의 로드 상태를 나타냅니다.CombinedLoadStates.append
: 목록의 끝에서 데이터를 로드하는 작업의 로드 상태를 나타냅니다.
여기서는 새로고침이 완료된 때, 즉 LoadState
가 refresh
, NotLoading
인 경우에만 스크롤 위치를 재설정하려고 합니다.
이제 updateRepoListFromInput()
에서 binding.list.scrollToPosition(0)
을 삭제할 수 있습니다.
이 작업이 완료되면 활동은 다음과 같이 표시됩니다.
class SearchRepositoriesActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
// get the view model
val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this))
.get(SearchRepositoriesViewModel::class.java)
// add dividers between RecyclerView's row items
val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
binding.list.addItemDecoration(decoration)
// bind the state
binding.bindState(
uiState = viewModel.state,
pagingData = viewModel.pagingDataFlow,
uiActions = viewModel.accept
)
}
/**
* Binds the [UiState] provided by the [SearchRepositoriesViewModel] to the UI,
* and allows the UI to feed back user actions to it.
*/
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter
bindSearch(
uiState = uiState,
onQueryChanged = uiActions
)
bindList(
repoAdapter = repoAdapter,
uiState = uiState,
pagingData = pagingData,
onScrollChanged = uiActions
)
}
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
searchRepo.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
searchRepo.text.trim().let {
if (it.isNotEmpty()) {
list.scrollToPosition(0)
onQueryChanged(UiAction.Search(query = it.toString()))
}
}
}
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
}
앱이 컴파일되고 실행되지만 로드 상태 바닥글 및 오류에 표시되는 Toast
없이 실행됩니다. 다음 단계에서는 로드 상태 바닥글을 표시하는 방법을 살펴봅니다.
분기 step5-9_paging_3.0에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.
10. 바닥글에 로드 상태 표시
앱에서 로드 상태를 기반으로 바닥글을 표시하려고 합니다. 목록이 로드되는 동안 진행률 스피너를 표시하려고 합니다. 오류가 발생하면 오류와 재시도 버튼을 표시하려고 합니다.
머리글/바닥글은 표시하는 실제 항목 목록의 시작 부분에 (머리글로) 또는 끝 부분에 (바닥글로) 추가해야 하는 목록의 아이디어에 따라 작성해야 합니다. 머리글/바닥글은 하나의 요소(Paging LoadState
를 기반으로 진행률 표시줄 또는 오류와 재시도 버튼을 표시하는 뷰)만 포함된 목록입니다.
로드 상태를 기반으로 머리글/바닥글을 표시하고 재시도 메커니즘을 구현하는 것은 일반적인 작업이므로 Paging 3 API는 이 두 작업을 모두 처리하는 데 도움이 됩니다.
머리글/바닥글 구현에는 LoadStateAdapter
가 사용됩니다. 이 RecyclerView.Adapter
구현에서는 로드 상태가 변경되면 자동으로 알림을 받습니다. Loading
및 Error
상태에서만 항목이 표시되고 LoadState
에 따라 항목이 삭제, 삽입 또는 변경되면 RecyclerView
에 알립니다.
재시도 메커니즘에는 adapter.retry()
가 사용됩니다. 내부적으로 이 메서드에서는 오른쪽 페이지를 위해 PagingSource
구현을 호출합니다. 응답은 Flow<PagingData>
를 통해 자동으로 전파됩니다.
머리글/바닥글 구현의 예를 살펴보겠습니다.
다른 목록과 마찬가지로 3개의 파일을 생성해야 합니다.
- 레이아웃 파일: 진행률, 오류, 재시도 버튼을 표시하기 위한 UI 요소가 포함됩니다.
- **
ViewHolder
** **파일**: PagingLoadState
를 기반으로 UI 항목을 표시합니다. - 어댑터 파일:
ViewHolder
를 만들고 결합하는 방법을 정의합니다.RecyclerView.Adapter
를 확장하는 대신 Paging 3에서LoadStateAdapter
를 확장합니다.
뷰 레이아웃 만들기
저장소 로드 상태의 repos_load_state_footer_view_item
레이아웃을 만듭니다. 여기에는 ProgressBar
, (오류를 표시하기 위한) TextView
, 재시도 Button
이 포함되어야 합니다. 필요한 문자열과 치수는 이미 프로젝트에 선언되어 있습니다.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/error_msg"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/error_text_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
tools:text="Timeout"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/retry"/>
</LinearLayout>
ViewHolder
만들기
ui
폴더에 ReposLoadStateViewHolder
라고 하는 새 ViewHolder
를 만듭니다**.** 재시도 함수를 재시도 버튼을 누르면 호출되는 매개변수로 수신해야 합니다. LoadState
를 매개변수로 수신하고 LoadState
에 따라 각 뷰의 공개 상태를 설정하는 bind()
함수를 만듭니다. ViewBinding
을 사용하는 ReposLoadStateViewHolder
구현은 다음과 같습니다.
class ReposLoadStateViewHolder(
private val binding: ReposLoadStateFooterViewItemBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.retryButton.setOnClickListener { retry.invoke() }
}
fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
binding.errorMsg.text = loadState.error.localizedMessage
}
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.retryButton.isVisible = loadState is LoadState.Error
binding.errorMsg.isVisible = loadState is LoadState.Error
}
companion object {
fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.repos_load_state_footer_view_item, parent, false)
val binding = ReposLoadStateFooterViewItemBinding.bind(view)
return ReposLoadStateViewHolder(binding, retry)
}
}
}
LoadStateAdapter
만들기
ui
폴더의 LoadStateAdapter
도 확장하는 ReposLoadStateAdapter
를 만듭니다. 어댑터는 구성될 때 재시도 함수가 ViewHolder
에 전달되므로 재시도 함수를 매개변수로 수신해야 합니다.
다른 Adapter
와 마찬가지로 onBind()
및 onCreate()
메서드를 구현해야 합니다. LoadStateAdapter
는 두 함수 모두에서 LoadState
를 전달하므로 이 메서드를 더 쉽게 구현할 수 있습니다. onBindViewHolder()
에서 ViewHolder
를 바인딩합니다. onCreateViewHolder()
에서 상위 ViewGroup
및 재시도 함수를 기반으로 ReposLoadStateViewHolder
를 만드는 방법을 정의합니다.
class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
return ReposLoadStateViewHolder.create(parent, retry)
}
}
바닥글 어댑터와 목록 바인딩
이제 바닥글의 모든 요소가 준비되었으므로 목록에 바인딩하겠습니다. PagingDataAdapter
에 이를 위한 세 가지 유용한 메서드가 있습니다.
withLoadStateHeader
: 머리글만 표시하려는 경우, 목록의 시작 부분에만 항목을 추가할 수 있는 경우 사용해야 합니다.withLoadStateFooter
: 바닥글만 표시하려는 경우, 목록의 끝 부분에만 항목을 추가할 수 있는 경우 사용해야 합니다.withLoadStateHeaderAndFooter
: 머리글과 바닥글을 표시하려는 경우, 목록을 두 방향으로 모두 페이징할 수 있는 경우.
ActivitySearchRepositoriesBinding.bindState()
메서드를 업데이트하고 어댑터에서 withLoadStateHeaderAndFooter()
를 호출합니다. 재시도 함수로 adapter.retry()
을 호출할 수 있습니다.
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { repoAdapter.retry() },
footer = ReposLoadStateAdapter { repoAdapter.retry() }
)
...
}
무한 스크롤 목록이 있으므로 휴대전화나 에뮬레이터를 비행기 모드로 설정하고 목록을 끝까지 스크롤하면 쉽게 바닥글을 표시할 수 있습니다.
앱을 실행해 보겠습니다.
분기 step10_loading_state_footer에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.
11. 활동에서 로드 상태 표시
현재 다음과 같은 두 가지 문제가 있음을 알 수 있습니다.
- Paging 3으로 마이그레이션하는 동안 결과 목록이 비어 있으면 메시지를 표시할 수 없습니다.
- 새 쿼리를 검색할 때마다 네트워크 응답을 받을 때까지 현재 쿼리 결과가 화면에 남아 있습니다. 이러한 사용자 환경은 좋지 않습니다. 진행률 표시줄이나 재시도 버튼을 대신 표시해야 합니다.
이 두 가지 문제를 해결하기 위해서는 SearchRepositoriesActivity
에서 로드 상태 변경에 반응해야 합니다.
빈 목록 메시지 표시
먼저 빈 목록 메시지를 다시 가져와 보겠습니다. 이 메시지는 목록이 로드되고 목록의 항목 수가 0인 경우에만 표시됩니다. 목록이 로드되는 시점을 파악하기 위해 PagingDataAdapter.loadStateFlow
속성을 사용합니다. 이 Flow
는 로드 상태가 변경될 때마다 CombinedLoadStates
객체를 통해 메시지를 표시합니다.
CombinedLoadStates
에서는 정의한 PageSource
또는 네트워크 및 데이터베이스의 경우에 필요한 RemoteMediator
의 로드 상태를 제공합니다(자세한 내용은 뒷부분 참고).
SearchRepositoriesActivity.bindList()
에서는 loadStateFlow
에서 직접 수집합니다. CombinedLoadStates
의 refresh
상태가 NotLoading
및 adapter.itemCount == 0
인 경우 목록이 비어 있습니다. 그런 다음 emptyList
와 list
의 공개 상태를 각각 전환합니다.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
}
}
}
}
로드 상태 표시
재시도 버튼과 진행률 표시줄 UI 요소를 포함하도록 activity_search_repositories.xml
을 업데이트해 보겠습니다.
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.SearchRepositoriesActivity">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/search_repo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search_hint"
android:imeOptions="actionSearch"
android:inputType="textNoSuggestions"
android:selectAllOnFocus="true"
tools:text="Android"/>
</com.google.android.material.textfield.TextInputLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:paddingVertical="@dimen/row_item_margin_vertical"
android:scrollbars="vertical"
app:layoutManager="LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/input_layout"
tools:ignore="UnusedAttribute"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView android:id="@+id/emptyList"
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/no_results"
android:textSize="@dimen/repo_name_size"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
재시도 버튼을 누르면 PagingData
가 새로고침됩니다. 이렇게 하기 위해 머리글/바닥글의 경우와 마찬가지로 onClickListener
구현에서 adapter.retry()
를 호출합니다.
// SearchRepositoriesActivity.kt
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
retryButton.setOnClickListener { repoAdapter.retry() }
...
}
다음으로 SearchRepositoriesActivity.bindList
의 로드 상태 변경에 반응해 보겠습니다. 새 쿼리가 있을 때만 진행률 표시줄을 표시하려고 하므로 페이징 소스에서 로드 유형(구체적으로 CombinedLoadStates.source.refresh
) 및 LoadState
(Loading
또는 Error
)에 의존해야 합니다. 또한 이전 단계에서 주석 처리한 기능 하나가 오류 발생 시 Toast
를 표시했으므로 이 기능도 가져와 보겠습니다. 오류 메시지를 표시하려면 CombinedLoadStates.prepend
또는 CombinedLoadStates.append
가 LoadState.Error
의 인스턴스인지 확인하고 오류에서 오류 메시지를 가져와야 합니다.
이 기능을 포함하도록 SearchRepositoriesActivity
메서드의 ActivitySearchRepositoriesBinding.bindList
를 업데이트하겠습니다.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.source.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.source.refresh is LoadState.Error
// Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
val errorState = loadState.source.append as? LoadState.Error
?: loadState.source.prepend as? LoadState.Error
?: loadState.append as? LoadState.Error
?: loadState.prepend as? LoadState.Error
errorState?.let {
Toast.makeText(
this@SearchRepositoriesActivity,
"\uD83D\uDE28 Wooops ${it.error}",
Toast.LENGTH_LONG
).show()
}
}
}
}
이제 앱을 실행하고 작동 방식을 확인해 보겠습니다.
이제 완료됐습니다. 현재 설정을 사용하면 Paging 라이브러리 구성요소가 적절한 시점에 API 요청을 트리거하고 메모리 내 캐시를 처리하고 데이터를 표시합니다. 앱을 실행하고 저장소를 검색해 보세요.
분기 step11_loading_state에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.
12. 목록 구분자 추가
구분자를 추가하면 목록의 가독성을 개선할 수 있습니다. 예를 들어, 앱에서는 저장소가 별표 수의 내림차순으로 정렬되므로 별표 10,000개마다 구분자를 사용할 수 있습니다. Paging 3 API에서는 PagingData
에 구분자를 삽입할 수 있어 이 기능을 구현하는 데 도움이 됩니다.
PagingData
에 구분자를 추가하면 화면에 표시되는 목록이 수정됩니다. 더 이상 Repo
객체만 표시되지 않고 구분자 객체도 표시됩니다. 따라서 ViewModel
에서 노출하는 UI 모델을 Repo
에서 RepoItem
및 SeparatorItem
유형을 모두 캡슐화할 수 있는 다른 유형으로 변경해야 합니다. 그런 다음 구분자를 지원하도록 UI를 업데이트해야 합니다.
- 구분자를 위한 레이아웃 및
ViewHolder
를 추가합니다. - 구분자와 저장소를 모두 만들고 바인딩할 수 있도록
RepoAdapter
를 업데이트합니다.
이 과정을 단계별로 진행하고 구현된 코드를 확인하겠습니다.
UI 모델 변경
현재 SearchRepositoriesViewModel.searchRepo()
에서는 Flow<PagingData<Repo>>
를 반환합니다. 저장소와 구분자를 모두 지원하기 위해 SearchRepositoriesViewModel
를 사용하여 동일한 파일에 UiModel
봉인 클래스를 만듭니다. RepoItem
및 SeparatorItem
, 두 가지 유형의 UiModel
객체를 포함할 수 있습니다.
sealed class UiModel {
data class RepoItem(val repo: Repo) : UiModel()
data class SeparatorItem(val description: String) : UiModel()
}
별표 10,000개를 기반으로 저장소를 구분하려고 하므로 RepoItem
에 별표 수를 올림하는 확장 속성을 만들어 보겠습니다.
private val UiModel.RepoItem.roundedStarCount: Int
get() = this.repo.stars / 10_000
구분자 삽입
이제 SearchRepositoriesViewModel.searchRepo()
에서 Flow<PagingData<UiModel>>
을 반환합니다.
class SearchRepositoriesViewModel(
private val repository: GithubRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
...
fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
...
}
}
구현이 어떻게 변경되었는지 살펴보겠습니다. 현재 repository.getSearchResultStream(queryString)
에서 Flow<PagingData<Repo>>
를 반환하므로 먼저 각 Repo
를 UiModel.RepoItem
으로 변환하는 작업을 추가해야 합니다. Flow.map
연산자를 사용한 후 각 PagingData
를 매핑하여 현재 Repo
항목의 새 UiModel.Repo
를 빌드하면 Flow<PagingData<UiModel.RepoItem>>
가 생성됩니다.
...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
.map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...
이제 구분자를 삽입할 수 있습니다. Flow
이 표시될 때마다 PagingData.insertSeparators()
를 호출합니다. 이 메서드에서는 전후 요소가 지정된 경우 각 원본 요소가 포함된 PagingData
를 선택적 구분자와 함께 반환합니다. 경계 조건(목록의 시작 또는 끝)에서 각 전후 요소는 null
이 됩니다. 구분자를 만들 필요가 없는 경우 null
을 반환합니다.
PagingData
요소의 유형을 UiModel.Repo
에서 UiModel
로 변경하므로 insertSeparators()
메서드의 유형 인수를 명시적으로 설정해야 합니다.
searchRepo()
메서드는 다음과 같이 표시됩니다.
private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
repository.getSearchResultStream(queryString)
.map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
.map {
it.insertSeparators { before, after ->
if (after == null) {
// we're at the end of the list
return@insertSeparators null
}
if (before == null) {
// we're at the beginning of the list
return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
}
// check between 2 items
if (before.roundedStarCount > after.roundedStarCount) {
if (after.roundedStarCount >= 1) {
UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
} else {
UiModel.SeparatorItem("< 10.000+ stars")
}
} else {
// no separator
null
}
}
}
여러 뷰 유형 지원
SeparatorItem
객체는 RecyclerView
에 표시해야 합니다. 여기에서는 문자열만 표시하므로 res/layout
폴더에 TextView
가 포함된 separator_view_item
레이아웃을 만들어 보겠습니다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/separatorBackground">
<TextView
android:id="@+id/separator_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="@dimen/row_item_margin_horizontal"
android:textColor="@color/separatorText"
android:textSize="@dimen/repo_name_size"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>
문자열만 TextView
에 바인딩하는 ui
폴더에 SeparatorViewHolder
를 만들어 보겠습니다.
class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val description: TextView = view.findViewById(R.id.separator_description)
fun bind(separatorText: String) {
description.text = separatorText
}
companion object {
fun create(parent: ViewGroup): SeparatorViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.separator_view_item, parent, false)
return SeparatorViewHolder(view)
}
}
}
Repo
대신 UiModel
을 지원하도록 ReposAdapter
를 업데이트합니다.
PagingDataAdapter
매개변수를Repo
에서UiModel
로 업데이트합니다.UiModel
비교기를 구현하여REPO_COMPARATOR
를 대체합니다.SeparatorViewHolder
를 만들고UiModel.SeparatorItem
의 설명과 바인딩합니다.
이제 두 가지 ViewHolder를 표시해야 하므로 RepoViewHolder를 ViewHolder로 바꿉니다.
PagingDataAdapter
매개변수 업데이트onCreateViewHolder
반환 유형 업데이트onBindViewHolder
holder
매개변수 업데이트
최종 ReposAdapter
는 다음과 같이 표시됩니다.
class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return if (viewType == R.layout.repo_view_item) {
RepoViewHolder.create(parent)
} else {
SeparatorViewHolder.create(parent)
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is UiModel.RepoItem -> R.layout.repo_view_item
is UiModel.SeparatorItem -> R.layout.separator_view_item
null -> throw UnsupportedOperationException("Unknown view")
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel.let {
when (uiModel) {
is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
}
}
}
companion object {
private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
oldItem.repo.fullName == newItem.repo.fullName) ||
(oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
oldItem.description == newItem.description)
}
override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
oldItem == newItem
}
}
}
이제 완료됐습니다. 앱을 실행하면 구분자를 볼 수 있습니다.
분기 step12_separators에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.
13. 네트워크 및 데이터베이스에서 페이징
로컬 데이터베이스에 데이터를 저장하여 앱에 오프라인 지원을 추가해 보겠습니다. 이렇게 하면 데이터베이스가 앱의 정보 소스가 되고 항상 데이터베이스에서 데이터가 로드됩니다. 더 이상 데이터가 없을 때마다 네트워크에 더 많은 데이터를 요청한 다음 데이터베이스에 저장합니다. 데이터베이스가 정보 소스이므로 더 많은 데이터가 저장되면 UI가 자동으로 업데이트됩니다.
오프라인 지원을 추가하려면 다음 단계를 따르세요.
- Room 데이터베이스,
Repo
객체를 저장할 테이블,Repo
객체로 작업하는 데 사용할 DAO를 만듭니다. RemoteMediator
를 구현하여 데이터베이스에서 데이터 끝에 도달했을 때 네트워크에서 데이터를 로드하는 방법을 정의합니다.- Repos 테이블을 기반으로
Pager
를 데이터 소스로 만들고 데이터를 로드하고 저장하기 위한RemoteMediator
를 만듭니다.
각 단계를 실행해 보겠습니다.
14. Room 데이터베이스, 테이블 및 DAO 정의
Repo
객체는 데이터베이스에 저장해야 하므로 먼저 Repo
클래스를 tableName = "repos"
인 항목으로 만들어 보겠습니다. 여기서 Repo.id
는 기본 키입니다. 이렇게 하려면 Repo
클래스에 @Entity(tableName = "repos")
로 주석을 추가하고 @PrimaryKey
주석을 id
에 추가하세요. 이제 Repo
클래스가 다음과 같이 표시됩니다.
@Entity(tableName = "repos")
data class Repo(
@PrimaryKey @field:SerializedName("id") val id: Long,
@field:SerializedName("name") val name: String,
@field:SerializedName("full_name") val fullName: String,
@field:SerializedName("description") val description: String?,
@field:SerializedName("html_url") val url: String,
@field:SerializedName("stargazers_count") val stars: Int,
@field:SerializedName("forks_count") val forks: Int,
@field:SerializedName("language") val language: String?
)
새 db
패키지를 만듭니다. 여기에서 데이터베이스의 데이터에 액세스하는 클래스와 데이터베이스를 정의하는 클래스를 구현합니다.
@Dao
로 주석이 추가된 RepoDao
인터페이스를 만들어 repos
테이블에 액세스하기 위한 데이터 액세스 객체(DAO)를 구현합니다. Repo
에 다음 작업이 필요합니다.
Repo
객체의 목록을 삽입합니다.Repo
객체가 이미 테이블에 있는 경우 이 객체를 바꿉니다.
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)
- 이름 또는 설명에 쿼리 문자열이 포함된 저장소를 쿼리하고 결과를 별표 수의 내림차순으로 정렬한 후 이름의 가나다순으로 정렬합니다.
List<Repo>
를 반환하는 대신PagingSource<Int, Repo>
를 반환합니다. 이렇게 하면repos
테이블이 Paging의 데이터 소스가 됩니다.
@Query("SELECT * FROM repos WHERE " +
"name LIKE :queryString OR description LIKE :queryString " +
"ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
Repos
테이블의 모든 데이터를 지웁니다.
@Query("DELETE FROM repos")
suspend fun clearRepos()
RepoDao
는 다음과 같이 표시됩니다.
@Dao
interface RepoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)
@Query("SELECT * FROM repos WHERE " +
"name LIKE :queryString OR description LIKE :queryString " +
"ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
@Query("DELETE FROM repos")
suspend fun clearRepos()
}
Repo 데이터베이스를 구현합니다.
RoomDatabase
를 확장하는 추상 클래스RepoDatabase
를 만듭니다.- 클래스에
@Database
로 주석을 추가하고Repo
클래스를 포함하도록 항목 목록을 설정하고 데이터베이스 버전을 1로 설정합니다. 이 Codelab의 목적상 스키마를 내보낼 필요가 없습니다. ReposDao
를 반환하는 추상 함수를 정의합니다.companion object
에RepoDatabase
객체가 아직 존재하지 않는 경우 객체를 빌드하는getInstance()
함수를 만듭니다.
RepoDatabase
는 다음과 같습니다.
@Database(
entities = [Repo::class],
version = 1,
exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
abstract fun reposDao(): RepoDao
companion object {
@Volatile
private var INSTANCE: RepoDatabase? = null
fun getInstance(context: Context): RepoDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE
?: buildDatabase(context).also { INSTANCE = it }
}
private fun buildDatabase(context: Context) =
Room.databaseBuilder(context.applicationContext,
RepoDatabase::class.java, "Github.db")
.build()
}
}
이제 데이터베이스를 설정했으므로 네트워크에 데이터를 요청하고 데이터베이스에 저장하는 방법을 살펴보겠습니다.
15. 데이터 요청 및 저장 - 개요
Paging 라이브러리에서는 데이터베이스를 UI에 표시해야 하는 데이터의 정보 소스로 사용합니다. 데이터베이스에 더 이상 데이터가 없을 때마다 네트워크에 더 많은 데이터를 요청해야 합니다. 이 작업에 도움이 되도록 Paging 3에서는 구현해야 하는 load()
메서드와 함께 RemoteMediator
추상 클래스를 정의합니다. 네트워크에서 더 많은 데이터를 로드해야 할 때마다 이 메서드가 호출됩니다. 이 클래스는 MediatorResult
객체(다음 중 하나)를 반환합니다.
Error
: 네트워크에 데이터를 요청하는 동안 오류가 발생한 경우Success
: 네트워크에서 데이터를 가져온 경우. 여기에서 더 많은 데이터를 로드할 수 있는지 여부를 나타내는 신호도 전달해야 합니다. 예를 들어 네트워크 응답에 성공했지만 저장소 목록이 비어 있으면 더 이상 로드할 데이터가 없는 것입니다.
data
패키지에서 RemoteMediator
를 확장하는 GithubRemoteMediator
라고 하는 새 클래스를 만들어 보겠습니다. 이 클래스는 새 쿼리마다 다시 생성되므로 다음 항목을 매개변수로 수신합니다.
- 쿼리
String
GithubService
: 네트워크 요청을 만들 수 있습니다.RepoDatabase
: 네트워크 요청에서 가져온 데이터를 저장할 수 있습니다.
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
private val query: String,
private val service: GithubService,
private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
}
}
네트워크 요청을 작성할 수 있도록 로드 메서드에는 필요한 정보를 모두 제공하는 두 개의 매개변수가 있습니다.
PagingState
: 이전에 로드된 페이지, 목록에서 가장 최근에 액세스한 색인, 페이징 스트림을 초기화할 때 정의한PagingConfig
에 관한 정보를 제공합니다.LoadType
: 이전에 로드한 데이터의 끝 부분(LoadType.APPEND
) 또는 시작 부분(LoadType.PREPEND
)에서 데이터를 로드해야 하는지 또는 데이터를 처음으로 로드하는지(LoadType.REFRESH
)를 나타냅니다.
예를 들어 로드 유형이 LoadType.APPEND
이면 PagingState
에서 로드된 마지막 항목을 가져옵니다. 이 항목을 기반으로 로드될 다음 페이지를 계산하여 Repo
객체의 다음 배치를 로드하는 방법을 확인할 수 있습니다.
다음 섹션에서는 로드될 다음 페이지와 이전 페이지의 키를 계산하는 방법을 설명합니다.
16. 원격 페이지 키 계산 및 저장
GitHub API의 목적상 저장소의 페이지를 요청하는 데 사용하는 페이지 키는 다음 페이지를 가져올 때 증가하는 페이지 색인입니다. 즉 Repo
객체가 제공된 경우 페이지 색인 + 1을 기반으로 Repo
객체의 다음 배치를 요청할 수 있습니다. Repo
객체의 이전 배치는 페이지 색인 - 1을 기반으로 요청할 수 있습니다. 특정 페이지 응답에서 수신된 모든 Repo
객체는 다음 키와 이전 키가 동일합니다.
PagingState
에서 마지막으로 로드된 항목을 가져오면 항목이 속한 페이지의 색인을 알 수 없습니다. 이 문제를 해결하기 위해 remote_keys
라고 하는 각 Repo
의 다음 및 이전 페이지 키를 저장하는 다른 테이블을 추가할 수 있습니다. Repo
테이블에서 이 작업을 실행할 수 있지만 Repo
와 연결된 다음 및 이전 원격 키의 새 테이블을 만들면 관심사를 더 효과적으로 구분할 수 있습니다.
db
패키지에서 RemoteKeys
라고 하는 새로운 데이터 클래스를 만들고 @Entity
로 주석을 추가하고 저장소 id
(기본 키이기도 함), 이전 키와 다음 키(앞이나 뒤에 데이터를 추가할 수 없는 경우 null
일 수 있음), 세 가지 속성을 추가해 보겠습니다.
@Entity(tableName = "remote_keys")
data class RemoteKeys(
@PrimaryKey
val repoId: Long,
val prevKey: Int?,
val nextKey: Int?
)
RemoteKeysDao
인터페이스를 만들어 보겠습니다. 다음과 같은 기능이 필요합니다.
- **
RemoteKeys
**의 목록 삽입. 네트워크에서Repos
를 가져올 때마다 원격 키를 생성하기 때문에 필요합니다. Repo
id
를 기반으로 **RemoteKey
** 가져오기.- **
RemoteKeys
** 지우기. 새 쿼리가 있을 때마다 사용됩니다.
@Dao
interface RemoteKeysDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(remoteKey: List<RemoteKeys>)
@Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?
@Query("DELETE FROM remote_keys")
suspend fun clearRemoteKeys()
}
데이터베이스에 RemoteKeys
테이블을 추가하고 RemoteKeysDao
액세스 권한을 제공해 보겠습니다. 이렇게 하려면 RepoDatabase
를 다음과 같이 업데이트합니다.
- 항목 목록에 RemoteKeys를 추가합니다.
RemoteKeysDao
를 추상 함수로 노출합니다.
@Database(
entities = [Repo::class, RemoteKeys::class],
version = 1,
exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
abstract fun reposDao(): RepoDao
abstract fun remoteKeysDao(): RemoteKeysDao
...
// rest of the class doesn't change
}
17. 데이터 요청 및 저장 - 구현
이제 원격 키를 저장했으므로 GithubRemoteMediator
로 돌아가 키를 사용하는 방법을 살펴보겠습니다. 이 클래스는 GithubPagingSource
를 대체합니다. GithubRemoteMediator
의 GithubPagingSource
에서 GITHUB_STARTING_PAGE_INDEX
선언을 복사하고 GithubPagingSource
클래스를 삭제하겠습니다.
GithubRemoteMediator.load()
메서드를 구현하는 방법을 알아보겠습니다.
LoadType
을 기반으로 네트워크에서 로드해야 하는 페이지를 확인합니다.- 네트워크 요청을 트리거합니다.
- 네트워크 요청이 완료된 후 수신된 저장소 목록이 비어 있지 않으면 다음 작업을 실행합니다.
- 모든
Repo
의RemoteKeys
를 계산합니다. - 새로운 쿼리(
loadType = REFRESH
)인 경우 데이터베이스를 지웁니다. RemoteKeys
및Repos
를 데이터베이스에 저장합니다.MediatorResult.Success(endOfPaginationReached = false)
를 반환합니다.- 저장소 목록이 비어 있는 경우
MediatorResult.Success(endOfPaginationReached = true)
를 반환합니다. 데이터를 요청하는 중에 오류가 발생하면MediatorResult.Error
를 반환합니다.
코드는 전체적으로 다음과 같이 표시됩니다. 나중에 TODO를 대체합니다.
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> {
// TODO
}
LoadType.PREPEND -> {
// TODO
}
LoadType.APPEND -> {
// TODO
}
}
val apiQuery = query + IN_QUALIFIER
try {
val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)
val repos = apiResponse.items
val endOfPaginationReached = repos.isEmpty()
repoDatabase.withTransaction {
// clear all tables in the database
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
val nextKey = if (endOfPaginationReached) null else page + 1
val keys = repos.map {
RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
}
repoDatabase.remoteKeysDao().insertAll(keys)
repoDatabase.reposDao().insertAll(repos)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
LoadType
를 기반으로 로드할 페이지를 찾는 방법을 알아보겠습니다.
18. LoadType을 기반으로 페이지 가져오기
이제 페이지 키가 있을 때 GithubRemoteMediator.load()
메서드에서 어떤 일이 발생하는지 알 수 있으므로 페이지 키를 계산하는 방법을 알아보겠습니다. 방법은 LoadType
에 따라 다릅니다.
LoadType.APPEND
현재 로드된 데이터 세트의 끝 부분에서 데이터를 로드해야 하는 경우 로드 매개변수는 LoadType.APPEND
입니다. 따라서 이제 데이터베이스의 마지막 항목을 기반으로 네트워크 페이지 키를 계산해야 합니다.
- 데이터베이스에서 로드된 마지막
Repo
항목의 원격 키를 가져와야 합니다. 함수에서 이 기능을 구분해 보겠습니다.
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the last page that was retrieved, that contained items.
// From that last page, get the last item
return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
?.let { repo ->
// Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}
remoteKeys
가 null이면 새로고침 결과가 아직 데이터베이스에 없는 것입니다. RemoteKeys가 null이 아니게 되면 Paging이 이 메서드를 호출하므로endOfPaginationReached = false
와 함께 Success를 반환할 수 있습니다. remoteKeys는null
이 아니지만nextKey
는null
인 경우 추가할 수 있는 페이지로 나누기의 끝에 도달했다는 의미입니다.
val page = when (loadType) {
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
// If remoteKeys is null, that means the refresh result is not in the database yet.
// We can return Success with endOfPaginationReached = false because Paging
// will call this method again if RemoteKeys becomes non-null.
// If remoteKeys is NOT NULL but its nextKey is null, that means we've reached
// the end of pagination for append.
val nextKey = remoteKeys?.nextKey
if (nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
nextKey
}
...
}
LoadType.PREPEND
현재 로드된 데이터 세트의 시작 부분에서 데이터를 로드해야 하는 경우 로드 매개변수는 LoadType.PREPEND
입니다. 데이터베이스의 첫 번째 항목을 기반으로 네트워크 페이지 키를 계산해야 합니다.
- 데이터베이스에서 로드된 첫 번째
Repo
항목의 원격 키를 가져와야 합니다. 함수에서 이 기능을 구분해 보겠습니다.
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the first page that was retrieved, that contained items.
// From that first page, get the first item
return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
?.let { repo ->
// Get the remote keys of the first items retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}
remoteKeys
가 null이면 새로고침 결과가 아직 데이터베이스에 없는 것입니다. RemoteKeys가 null이 아니게 되면 Paging이 이 메서드를 호출하므로endOfPaginationReached = false
와 함께 Success를 반환할 수 있습니다. remoteKeys는null
이 아니지만prevKey
는null
인 경우 앞에 추가할 수 있는 페이지로 나누기의 끝에 도달했다는 의미입니다.
val page = when (loadType) {
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
// If remoteKeys is null, that means the refresh result is not in the database yet.
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
...
}
LoadType.REFRESH
LoadType.REFRESH
는 데이터를 처음 로드할 때 또는 PagingDataAdapter.refresh()
가 호출되는 경우 호출됩니다. 따라서 이제 데이터를 로드하기 위한 참조 지점은 state.anchorPosition
입니다. 첫 번째 로드인 경우 anchorPosition
는 null
입니다. PagingDataAdapter.refresh()
가 호출되면 anchorPosition
이 표시된 목록에 처음으로 표시되는 위치이므로 그 특정 항목이 포함된 페이지를 로드해야 합니다.
state
의anchorPosition
을 기준으로state.closestItemToPosition()
을 호출하여 가장 가까운Repo
항목을 그 위치로 가져올 수 있습니다.Repo
항목을 기준으로 데이터베이스에서RemoteKeys
를 가져올 수 있습니다.
private suspend fun getRemoteKeyClosestToCurrentPosition(
state: PagingState<Int, Repo>
): RemoteKeys? {
// The paging library is trying to load data after the anchor position
// Get the item closest to the anchor position
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.id?.let { repoId ->
repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
}
}
}
remoteKey
가 null이 아니면 데이터베이스에서nextKey
를 가져올 수 있습니다. GitHub API에서는 페이지 키가 순차적으로 증가합니다. 따라서 현재 항목이 포함된 페이지를 가져오려면remoteKey.nextKey
에서 1을 빼면 됩니다.RemoteKey
가null
이면 (anchorPosition
이null
이었으므로) 초기 페이지(GITHUB_STARTING_PAGE_INDEX
)를 로드해야 합니다.
이제 전체 페이지 계산은 다음과 같습니다.
val page = when (loadType) {
LoadType.REFRESH -> {
val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
}
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
val nextKey = remoteKeys?.nextKey
if (nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
nextKey
}
}
19. 페이징 흐름 만들기 업데이트
이제 ReposDao
에서 GithubRemoteMediator
및 PagingSource
를 구현했으므로 이를 사용하려면 GithubRepository.getSearchResultStream
을 업데이트해야 합니다.
이렇게 하려면 GithubRepository
에서 데이터베이스에 액세스할 수 있어야 합니다. 데이터베이스를 생성자의 매개변수로 전달하겠습니다. 또한 이 클래스에서는 GithubRemoteMediator
을 사용하므로
class GithubRepository(
private val service: GithubService,
private val database: RepoDatabase
) { ... }
Injection
파일을 업데이트합니다.
provideGithubRepository
메서드는 컨텍스트를 매개변수로 가져오고GithubRepository
생성자에서RepoDatabase.getInstance
를 호출해야 합니다.provideViewModelFactory
메서드는 컨텍스트를 매개변수로 가져와서provideGithubRepository
에 전달해야 합니다.
object Injection {
private fun provideGithubRepository(context: Context): GithubRepository {
return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
}
fun provideViewModelFactory(context: Context, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
return ViewModelFactory(owner, provideGithubRepository(context))
}
}
SearchRepositoriesActivity.onCreate()
메서드를 업데이트하고 컨텍스트를 Injection.provideViewModelFactory()
에 전달합니다.
// get the view model
val viewModel = ViewModelProvider(
this, Injection.provideViewModelFactory(
context = this,
owner = this
)
)
.get(SearchRepositoriesViewModel::class.java)
GithubRepository
로 돌아가겠습니다. 먼저 이름으로 저장소를 검색하려면 %
를 쿼리 문자열의 시작과 끝에 추가해야 합니다. 그런 다음 reposDao.reposByName
을 호출할 때 PagingSource
를 가져옵니다. 데이터베이스에서 변경할 때마다 PagingSource
가 무효화되므로 PagingSource
의 새 인스턴스를 가져오는 방법을 Paging에 알려야 합니다. 이렇게 하기 위해 데이터베이스 쿼리를 호출하는 함수를 만듭니다.
// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory = { database.reposDao().reposByName(dbQuery)}
이제 GithubRemoteMediator
및 pagingSourceFactory
를 사용하도록 Pager
빌더를 변경할 수 있습니다. Pager
는 시험용 API이므로 @OptIn
으로 주석을 추가해야 합니다.
@OptIn(ExperimentalPagingApi::class)
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
remoteMediator = GithubRemoteMediator(
query,
service,
database
),
pagingSourceFactory = pagingSourceFactory
).flow
이제 완료됐습니다. 앱을 실행해 보겠습니다.
RemoteMediator 사용 시 로드 상태에 반응
지금까지 CombinedLoadStates
에서 읽을 때는 항상 CombinedLoadStates.source
에서 읽어왔습니다. 그러나 RemoteMediator
를 사용할 경우 CombinedLoadStates.source
와 CombinedLoadStates.mediator
를 모두 확인해야만 정확한 로드 정보를 가져올 수 있습니다. 특히 현재는 source
LoadState
가 NotLoading
일 때 새 쿼리의 목록 상단으로 스크롤되도록 트리거하고 있습니다. 또한 새로 추가된 RemoteMediator
에 NotLoading
의 LoadState
도 있어야 합니다.
이를 위해 Pager
에서 가져온 목록의 표시 상태를 요약하는 enum을 정의합니다.
enum class RemotePresentationState {
INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}
그러면 위 정의에서 연속으로 내보낸 CombinedLoadStates
를 비교하고 그에 따라 목록에 있는 항목의 정확한 상태를 확인할 수 있습니다.
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
scan(RemotePresentationState.INITIAL) { state, loadState ->
when (state) {
RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
else -> state
}
RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
is LoadState.NotLoading -> RemotePresentationState.PRESENTED
else -> state
}
}
}
.distinctUntilChanged()
위 코드를 사용하여, 목록의 상단으로 스크롤할 수 있는지 확인하는 데 사용하는 notLoading
Flow
의 정의를 업데이트할 수 있습니다.
val notLoading = repoAdapter.loadStateFlow
.asRemotePresentationState()
.map { it == RemotePresentationState.PRESENTED }
이와 유사하게 초기 페이지 로드 중 로딩 스피너를 표시하는 경우(SearchRepositoriesActivity
에서 bindList
확장 프로그램 사용)에는 여전히 LoadState.source
가 사용됩니다. 이제는 RemoteMediator
의 로드에 관한 로딩 스피너만 표시하려고 합니다. 공개 상태가 LoadStates
에 종속된 다른 UI 요소에도 이 문제가 있습니다. 따라서 다음과 같이 LoadStates
의 결합을 UI 요소에 업데이트합니다.
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
...
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds, either from the the local db or the remote.
list.isVisible = loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
}
}
}
}
또한 데이터베이스를 하나의 정보 소스로 사용하므로 데이터베이스에 데이터가 있는 상태에서 앱을 실행할 수는 있습니다. 하지만 RemoteMediator
를 사용해 새로고침을 할 수는 없습니다. 이는 흥미로운 특수 사례이지만 쉽게 처리할 수 있습니다. 이렇게 하려면 새로고침 상태에 오류가 있는 경우에만 헤더 LoadStateAdapter
의 참조를 유지하고 LoadState
를 RemoteMediator의 참조가 되도록 재정의하면 됩니다. 그러지 않으면 기본값이 사용됩니다.
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
// Show a retry header if there was an error refreshing, and items were previously
// cached OR default to the default prepend state
header.loadState = loadState.mediator
?.refresh
?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
?: loadState.prepend
...
}
}
}
분기 step13-19_network_and_database에서 지금까지 완료된 단계의 전체 코드를 확인할 수 있습니다.
20. 요약정리
이제 모든 구성요소를 추가했으므로 지금까지 배운 내용을 요약해 보겠습니다.
PagingSource
는 사용자가 정의한 소스에서 데이터를 비동기식으로 로드합니다.Pager.flow
는 구성 및PagingSource
를 인스턴스화하는 방법을 정의하는 함수를 기반으로Flow<PagingData>
를 생성합니다.PagingSource
에서 새 데이터를 로드할 때마다Flow
에서 새PagingData
를 내보냅니다.- UI는 변경된
PagingData
를 관찰하고PagingDataAdapter
를 사용하여 데이터를 표시하는RecyclerView
를 업데이트합니다. - UI에서 실패한 로드를 다시 시도하려면
PagingDataAdapter.retry
메서드를 사용하세요. 내부적으로 Paging 라이브러리가PagingSource.load()
메서드를 트리거합니다. - 목록에 구분자를 추가하려면 구분자를 지원되는 유형 중 하나로 사용하여 상위 수준 유형을 만듭니다. 그런 다음
PagingData.insertSeparators()
메서드를 사용하여 구분자 생성 로직을 구현합니다. - 로드 상태를 머리글 또는 바닥글로 표시하려면
PagingDataAdapter.withLoadStateHeaderAndFooter()
메서드를 사용하고LoadStateAdapter
를 구현합니다. 로드 상태를 기반으로 다른 작업을 실행하려면PagingDataAdapter.addLoadStateListener()
콜백을 사용합니다. - 네트워크 및 데이터베이스를 사용하려면
RemoteMediator
를 구현합니다. RemoteMediator
를 추가하면LoadStatesFlow
의mediator
필드가 업데이트됩니다.