Development record of developer who study hard everyday.

레이블이 paging 라이브러리안드로이드 paging3인 게시물을 표시합니다. 모든 게시물 표시
레이블이 paging 라이브러리안드로이드 paging3인 게시물을 표시합니다. 모든 게시물 표시
, , , ,

안드로이드 Paging3 코드랩 따라하기 - 기본 (번역)

 안드로이드 Paging3 코드랩 따라하기 기본편

안드로이드 블로그

안드로이드 Paging3 코드랩 기본편 링크

해당 글은 위 링크를 번역한 내용입니다.


1. 코드 다운받기

 git clone https://github.com/googlecodelabs/android-paging

위 명령어를 안드로이드 스튜디오 터미널에서 입력하여 베이스 코드를 내려받는다.


2. Paging3 소개

앱에서 유저들에게 정보를 제공하는 가장 보편적인 방식이 리스트를 보여주는 것이다.

리스트는 유저가 볼 수 있는 작은 창인데, 유저가 스크롤할 때 필요한 정보를 가져오게 된다.

이렇게 점진적으로 데이터를 불러오는 것은 앱의 성능에도 기여한다.

왜냐하면 앱의 메모리에 많은 양의 데이터를 저장하지 않아도 되기 때문이다.

리스트에서 유저가 스크롤 할 때마다 데이터를 불러와서 유저에게 보여주는 방식이 Paging3 라이브러리이다.


3. Paging3 라이브러리 주요 구성요소

PagingSource - 데이터 조각을 불러오는 기본 클래스이다.

데이터 레이어에 속하고 주로 DataSource 클래스에 존재한다.

이후 ViewModel의 Repository에서 사용된다.

PagingConfig - 데이터를 불러오는데 사용되는 매개변수를 정의한 클래스.

pageSize와  placeholder 유무를 정한다.

Pager - PagingData 흐름을 만드어내는 클래스이다. 

PagingSource가 있어야 만들 수 있고 Pager는 ViewModel에서 만들어져야한다.

PagingData - 불러온 데이터를 담는 클래스이다.

PagingDataAdapter - RecyclerView.Adapter의 하위 클래스이다.

리사이클러뷰와 불러온 데이터를 연결해주는 역할을 한다.

PagingDataAdapter는 Flow, LiveData, Flowable, Observable과 연결이 가능하다.

또한 데이터를 불러오는 중이나 결과(성공 유무)를 UI에 나타낼 수 있게 해준다.


4. 프로젝트 개요

코드랩에서의 앱 구조는 아래와 같다.


Data layer :

ArticleRepository: 기사들을 list에 뿌려주고 메모리에 저장하고있는 역할

Article: 데이터 모델을 나타내는 클래스

UI layer:

Activity, RecyclerView.Adapter, RecyclerView.ViewHolder: list를 보여주는 역할을 하는 클래스

ViewModel: UI 상태를 담는 클래스


ArticleRepository가 Flow에 모든 기사들을 내보낸다.

UI layer에서 ArticleViewModel이 기사들을 담고 ArticleActivity에서 StateFlow를 통해 관찰하여 UI갱신을 한다.


5. PagingSource 정의하기

페이징 라이브러리를 구현할 때 아래의 조건들을 지켜주자.

- data request를 동시에 같은 query로 하지 않는다

- 반환받은 data는 메모리에 적당한 크기로 유지한다.

- 이미 가져온 데이터를 보충할 데이터를 불러온다.


위 3가지 조건들을 PagingSource를 통해서 만족시킬 수 있다.

PagingSource를 만들기위해서 아래 3가지를 정의해주어야한다.

paging key의 타입 : 데이터를 불러오는 page query의 타입. 다시 말해서, 데이터를 불러올 때 특정 기사 ID의 앞뒤의 기사를 불러온다.

불러오는 data 타입 : 각 페이지는 기사의 list를 반환한다. 따라서 data 타입은 Article이다.

data가 어디서 오는지: 기본적으로 데이터베이스, 서버 등이 있다.


이제, PagingSource를 정의해보자

private val firstArticleCreatedTime = LocalDateTime.now()
private const val LOAD_DELAY_MILLIS = 3000L

class ArticlePagingSource: PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
//Start paging with the STARTING_KEY if this is the first load
val start = params.key ?: STARTING_KEY
//Load as many items as hinted by params.loadSize
val range = start.until(start + params.loadSize)

if(start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
return LoadResult.Page (
data = range.map { number ->
Article(
//Generate consecutive increasing numbers as the article id
id = number,
title = "Article $number",
description = "This describes article $number",
created = firstArticleCreatedTime.minusDays(number.toLong())
)
},

//Make sure we don't try to load items behind the STARTING_KEY
prevKey = when(start) {
STARTING_KEY -> null
else -> ensureValidKey(key = range.first - params.loadSize)
},
nextKey = range.last + 1
)
}

//The refresh key is used for the initial load of the next PagingSource, after invalidation
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
//In our case we grab the item closest to the anchor position
//then return its id - (state.config.pageSize / 2) as a buffer
val anchorPosition = state.anchorPosition ?: return null
val article = state.closestItemToPosition(anchorPosition) ?: return null

return ensureValidKey(key = article.id - (state.config.pageSize / 2))
}

    //Makes sure the paging key is never less than STARTING_KEY
private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)

companion object {
const val STARTING_KEY = 0
}
}

PagingSource는 load()와 getRefreshKey() 함수를 구현해야한다.

load()는 유저가 스크롤을 움직일 때 보여줄 데이터를 비동기로 가져오는 함수이다.

load() 함수의 매개변수인 LoadParams에는 여러 정보가 들어있다.

- 불러올 page의 키 : load()를 처음 호출할 때, LoadParams.key는 null이다.

이때, 첫 page의 key를 정해야한다. 여기선 STARTING_KEY를 0이라고 하자.

Load Size : 불러올 데이터의 수


load() 함수는 LoadResult를 반환한다. LoadResult의 타입은 3가지이다.

- LoadResult.Page: 성공적으로 데이터를 불러왔을 시

- LoadResult.Error: 데이터를 불러올 때 에러 발생

- LoadReuslt.Invalid: PagingSource가 무효화 되어야할 때


LoadResult.Page는 3개의 필수 매개변수가 존재한다.

- data : 불러온 아이템들의 List

- prevKey : 현재 페이지 이전에 존재할 데이터들이 필요할 때 사용하는 키

- nextKey : 현재 페이지 이후에 존재할 데이터들이 필요할 때 사용하는 키

그리고 2개의 선택 매개변수가 존재한다.

- itemsBefore : 불러온 데이터 이전에 보여줄 placeholder의 수

- itemsAfter : 불러온 데이터 이후에 보여줄 placeholder의 수


nextKey나 prevKey가 null이면 그 방향으로 더이상 불러올 데이터가 없다는 것이다.


getRefreshKey()는 PagingSource()가 변해서 데이터를 새로고침할 때 사용하는 함수이다.

이런 상황을 무효화(invalidation)라고한다. 

무효화가될 때, 페이징 라이브러리는 PagingSource를 새로 만든다. 그리고 새로운 PagingData를 보내주면서 UI에 알린다.

getRefreshKey()는 사용자가 현재 위치를 잃어버리지 않은 상태로 PagingSource가 데이터를 가져올 때 사용하는 키를 제공한다.

무효화(Invalidation)은 2가지 이유로 발생한다.

PagingAdapter에서 refresh()를 호출하거나 PagingSource에서 invalidate()을 호출할 때 이다.

반환된 key값은 다음 load() 호출에 전해진다. 

무효화 이후로 데이터를 건너뛰지않도록, 충분한 데이터를 불러와야한다.

이러한 방식으로 현재 스크롤 위치를 유지하면서 무효화된 데이터를 포함한 새로운 데이터들을 보여줄 수 있다.

getRefreshKey() 함수에서 PagingState.anchorPosition을 사용한다.

anchorPosition을 통해서 페이징 라이브러리는 어디서부터 데이터를 불러올지를 안다.


6. PagingData 생산하기

현재 코드랩 코드에서 ArticleRepository에서 Flow<List<Article>>을 사용해서 불러온 데이터를 ViewModel에 보여준다.

ViewModel은 stateIn 연산자로 보여줄 수 있는 데이터 보유한다.

Paging3 라이브러리를 사용하면서, ViewModel에서 Flow<PagingData<Article>>을 사용할 것이다.

PagingData는 우리가 불러온 데이터를 감싸는 객체이고 언제 데이터를 불러올지 두번 불러오는건 아닌지 확인하는 것을 도와준다.

PagingData를 만들기위해서, Pager 클래스의 여러 builder method 중에 하나를 사용하자.

Flow, LiveData, Flowable, Obaservable

PagingData를 만들 때, 아래와 같은 매개변수를 명시해야한다.

PagingConfig : PagingSource로 부터 어떻게 데이터를 불러올건지 알려준다.

page Size = 각 페이지에 들어가는 데이터의 수

maxSize = 메모리에 저장할 데이터의 수, 기본값으로는 불러온 데이터 모두를 저장한다.

enablePlaceholders = true일 경우, 불러오지못한 데이터의 수를 나타낼 수 있다. 기본값으로는 null을 반환한다.


✋ PagingConfig.pageSize는 충분한 크기여야한다. 

pageSize가 너무 작으면 화면을 채우지 못하고 list가 깜빡일 것이다.

첫 페이지만 다른 pageSize를 적용하려면 PagingConfig.initialLoadSize를 사용하자.

PagingConfig.maxSize는 기본값으로 세팅이 안되어있다.

page를 날리고싶다면 maxSize를 network requests를 너무 많이 할 정도가 아닌 충분히 큰 값으로 설정해라.

maxSize의 최소값은 pageSize + prefetchDistance * 2이다.


7. ArticleRepository 수정사항

class ArticleRepository {
fun articlePagingSource() = ArticlePagingSource()

}

articleStream 변수를 지우고 ArticlePagingSource를 반환하는 articlePagingSource() 함수를 만들자.


8. ViewModel에서 PagingData 요청 및 캐시

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel(
repository: ArticleRepository,
) : ViewModel() {

/**
* Stream of [Article]s for the UI.
*/
/*val items: StateFlow<List<Article>> = repository.articleStream
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = listOf()
)*/

val items: Flow<PagingData<Article>> = Pager(    //1
config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),    //2
pagingSourceFactory = { repository.articlePagingSource() } //3
).flow
.cachedIn(viewModelScope)    //4

}

1 => 서버로부터 데이터를 가져와서 저장하는 변수인 items의 타입을 StateFlow<List<Article>>에서 Flow<PagingData<Article>>로 변경한다.

2 => PagingConfig 객체에 pageSize와 enablePlaceholders 정보를 명시한다.

3 => ArticlePagingSource 객체를 전달.

4 => configuration change같은 화면전환에 대비하여 cachedIn() 메서드로 불러온 정보를 viewModelScope에 임시 저장한다.


✋ pagingSourceFactory 람다 함수는 항상 새로운 PagingSource 객체를 리턴한다.

✋ Flow<PagingData>에 stateIn() 또는 sharedIn() 메서드를 사용하지말자. 왜냐하면, Flow<PagingData>는 cold가 아니기 때문이다.

✋ 만약 Flow<PagingData>에 map이나 filter를 사용하고 싶다면 cahchedIn() 메서드를 사용하기 이전에 사용한다.


PagingData는 mutable한 데이터 스트림을 가지고있는 타입이다.

PagingData의 발산은 각각 독립적이다.

PagingSource가 invalidate된다면 여러 PagingData 객체가 발산될 수 있다.


✋ Flow<PagingData>는 일반 Flow와 다르다.

Flow<PagingData>는 독립적으로 소비된다.


9. PagingDataAdapter 만들기

PagingData를 RecyclerView에 보여줄 PagingDataAdapter를 만들자.

class ArticleAdapter : PagingDataAdapter<Article, ArticleViewHolder>(ARTICLE_DIFF_CALLBACK) {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder =
ArticleViewHolder(
ArticleViewholderBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
)
)

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
val tile = getItem(position)
if (tile != null) {
holder.bind(tile)
}
}

companion object {
private val ARTICLE_DIFF_CALLBACK = object : DiffUtil.ItemCallback<Article>() {
override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem.id == newItem.id

override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem == newItem
}
}
}


10. Activity에서 PagingData 보여주기

binding.setupScrollListener() 메소드는 Paging 라이브러리가 알아서해주기때문에 지워도된다.

ArticleAdatper 역시 PagingDataAdapter로 대체된다.


class ArticleActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityArticlesBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)

// Get the view model
val viewModel by viewModels<ArticleViewModel>(
factoryProducer = { Injection.provideViewModelFactory(owner = this) }
)

val items = viewModel.items
val articleAdapter = ArticleAdapter()

binding.bindAdapter(articleAdapter = articleAdapter)    //1

// Collect from the Article Flow in the ViewModel, and submit it to the
// ListAdapter.
lifecycleScope.launch {    //2
// We repeat on the STARTED lifecycle because an Activity may be PAUSED
// but still visible on the screen, for example in a multi window app
repeatOnLifecycle(Lifecycle.State.STARTED) {
items.collectLatest {
articleAdapter.submitData(it)
}
}
}

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
articleAdapter.loadStateFlow.collect {
binding.prependProgress.isVisible = it.source.prepend is LoadState.Loading
binding.
appendProgress.isVisible = it.source.append is LoadState.Loading
}
}
}
}
}

/**
* Sets up the [RecyclerView] and binds [ArticleAdapter] to it
*/
private fun ActivityArticlesBinding.bindAdapter(articleAdapter: ArticleAdapter) {
list.adapter = articleAdapter
list.layoutManager = LinearLayoutManager(list.context)
val decoration = DividerItemDecoration(list.context, DividerItemDecoration.VERTICAL)
list.addItemDecoration(decoration)
}

1 => RecyclerView에 PagingDataAdapter 등록하기

2 => PagingData를 collectLatest 메소드로 관찰하고 PagingDataAdpater에 submitData() 해준다.


11. PagingData 로드 상태 보여주기

Paging 라이브러리는 데이터를 불러오는 상태를CombinedLoadStates 타입으로 사용자에게 간단히 보여줄 수 있다.

CombinedLoadStates 객체는 모든 구성요소의 로딩 상태를 보여준다.

여기서는 ArticlePagingSource의 LoadState를 보여주면된다.

CombinedLoadStates.source 변수에 LoadStates를 활용해보자.

PagingDataAdapter에 PagingDataAdapter.loadStateFlow를 활용하면 CombinedLoadStates를 사용할 수 있다.


✋ CombinedLoadStates, LoadStates 그리고 LoadState 클래스들을 활용하면 로딩 상태를 보여줄 수 있다.

CombinedLoadStates는 LoadStates 를 가지고있고 LoadStates는 LoadState을 가지고있다.


CombinedLoadStates.source는 LoadStates 타입이다. 여기에는 3가지의 타입이 존재한다.

    - LoadStates.append : 사용자의 현재 위치 이후 불러올 data들의 LoadState

    - LoadStates.prepend : 사용자의 현재 위치 이전 불러올 data들의 LoadState

    - LoadStates.refresh: 처음 불러올 data들의 LoadState


각 LoadState는 3가지의 값이 될 수 있다.

    - LoadState.Loading: 데이터들을 불러오는 중이다.

    - LoadState.NotLoading: 데이터들을 불러오지 않고 있다.

    - LoadState.Error: 데이터들을 불러오는 중 에러 발생.


여기서는 LoadState.Loading인 상황에만 집중하자.

activity_articles.xml에 2개의 LinearProgressIndicator 바를 만들자.

<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.ArticleActivity">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" />

<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/prepend_progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>

<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/append_progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>

</androidx.constraintlayout.widget.ConstraintLayout>


이제, PagingDataAdapter에서 LoadStatesFlow를 참조하여 collect해준다.

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
articleAdapter.loadStateFlow.collect {
binding.prependProgress.isVisible = it.source.prepend is LoadState.Loading
binding.appendProgress.isVisible = it.source.append is LoadState.Loading
}
}
}


Share:
Read More