이번 포스팅에는 이전 글에서 언급했었던 Paging2에 없던 Paging3.0만의 기능을 설명해보려고 합니다. Paging3.0의 개념과 기본적인 사용 방법은 이전 글을 참고해주세요.
1. 데이터 스트림 변환
Paging을 사용할 때 데이터를 가공해야 하는 경우가 많이 생깁니다. 예를 들어, 데이터를 화면에 표시하기 전에 먼저 필터링을 하거나 데이터를 다른 형태로 변환해야 할 수도 있습니다. Paging3.0은 기본적으로 Flow를 지원하기 때문에 이와 같은 데이터 변환이 간편합니다.
아래의 코드는 지난 글에서 사용했던 Paging3.0 샘플 코드입니다.
@HiltViewModel
class PagingViewModel @Inject constructor(
private val repository: PagingRepository
) : ViewModel() {
val pagingData = repository.getPagingData().map { pagingData ->
pagingData.map<String, SampleModel> { SampleModel.Data(it) }
.insertHeaderItem(item = SampleModel.Header("Header"))
.insertFooterItem(item = SampleModel.Header("Footer"))
.insertSeparators { before: SampleModel?, after: SampleModel? ->
if (before is SampleModel.Header || after is SampleModel.Header)
SampleModel.Separator
else
null
}
}.cachedIn(viewModelScope)
}
sealed class SampleModel(val type: SampleType) {
data class Data(val value: String): SampleModel(SampleType.DATA)
data class Header(val title: String): SampleModel(SampleType.HEADER)
object Separator: SampleModel(SampleType.SEPARATOR)
}
enum class SampleType {
HEADER, DATA, SEPARATOR
}
PagingData.map() 함수로 다른 데이터로 변환이 가능합니다. map() 뿐 아니라 filter(), flatMap()과 같은 함수도 지원합니다.
Paging3.0에서는 어댑터에 연결해주기 전에 Header와 Footer, Separator를 추가해주는 것이 간편해졌습니다. insertHeaderItem(), insertFooterItem(), insertSeparators() 함수로 페이징 데이터 중간중간에 다른 데이터를 넣는 것이 가능합니다. 개인적으로 Paging2를 사용할 때는 헤더 및 푸터를 넣는 게 쉽지 않았는데 굉장히 마음에 드는 기능이라고 생각합니다.
2. refresh(), retry()
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: PagingViewModel by viewModels()
private val adapter: PagingAdapter by lazy { PagingAdapter() }
private val recyclerView: RecyclerView by lazy { findViewById(R.id.recyclerView) }
private val swipeRefreshLayout: SwipeRefreshLayout by lazy { findViewById(R.id.swipeRefreshLayout) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
lifecycleScope.launch {
viewModel.pagingData.collectLatest {
adapter.submitData(it)
}
}
swipeRefreshLayout.setOnRefreshListener {
// refresh
adapter.refresh()
swipeRefreshLayout.isRefreshing = false
}
}
}
PagingDataAdapter에는 refresh()와 retry() 함수가 생겼습니다. refresh() 함수는 호출 시 처음부터 데이터를 다시 로드하게 됩니다.
retry() 함수는 일반적인 상황에서는 아무런 동작을 하지 않습니다. retry라는 이름에 맞게 PagingSource에서 LoadResult.Error를 리턴한 경우에 에러가 발생한 페이지부터 다시 로드하게 됩니다. retry() 함수는 아래에서 좀 더 살펴보겠습니다.
3. LoadStateAdapter
네트워크 연결이 불안정할 때 Paging에서 데이터를 로드하려고 하면 딜레이가 발생할 수 있습니다. Paging3.0에 새롭게 추가된 LoadStateAdapter는 Loading이나 Error 처리를 간편하게 할 수 있도록 도와줍니다.
LoadStateAdapter는 RecyclerView.Adapter를 상속받은 클래스이며, 구현해줘야 하는 함수는 다음과 같습니다.
abstract fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): VH
abstract fun onBindViewHolder(holder: VH, loadState: LoadState)
얼핏 보면 RecyclerView.Adapter의 onCreateViewHolder, onBindViewHolder와 같아 보이지만, 받는 인자가 다릅니다. 인자로 받는 LoadState도 Paging3.0에서 새롭게 추가된 클래스로써, Loading, NotLoading, Error 세 가지로 나뉩니다.
public sealed class LoadState(
public val endOfPaginationReached: Boolean
) {
public class NotLoading(
endOfPaginationReached: Boolean
) : LoadState(endOfPaginationReached) {
...
}
public object Loading : LoadState(false) {
...
}
public class Error(
public val error: Throwable
) : LoadState(false) {
...
}
}
endOfPaginationReached 값은 로딩 가능 여부를 나타냅니다. 해당 값이 true면 더 이상 로딩을 못하는 상태입니다.
class PagingLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<PagingLoadStateViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): PagingLoadStateViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return PagingLoadStateViewHolder(ItemLoadStateBinding.inflate(layoutInflater, parent, false), retry)
}
override fun onBindViewHolder(holder: PagingLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
}
class PagingLoadStateViewHolder(
private val binding: ItemLoadStateBinding,
private val retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(state: LoadState) {
binding.retryButton.setOnClickListener { retry() }
binding.isLoading = state is LoadState.Loading
binding.isError = state is LoadState.Error
binding.errorMessage = (state as? LoadState.Error)?.error?.message ?: ""
}
}
LoadStateAdapter를 구현해줍니다. 어댑터의 인자로 받은 retry() 함수는 위에서 설명한 PagingDataAdapter의 retry() 함수를 뜻합니다. 에러가 발생했을 때 버튼 클릭 시 에러가 난 페이지부터 새로 데이터를 호출해주기 위해 연결해줍니다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: PagingViewModel by viewModels()
private val adapter: PagingAdapter by lazy { PagingAdapter() }
private val recyclerView: RecyclerView by lazy { findViewById(R.id.recyclerView) }
private val swipeRefreshLayout: SwipeRefreshLayout by lazy { findViewById(R.id.swipeRefreshLayout) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
PagingLoadStateAdapter { adapter.retry() },
PagingLoadStateAdapter { adapter.retry() }
)
lifecycleScope.launch {
viewModel.pagingData.collectLatest {
adapter.submitData(it)
}
}
swipeRefreshLayout.setOnRefreshListener {
// refresh
adapter.refresh()
swipeRefreshLayout.isRefreshing = false
}
}
}
LoadStateAdapter를 만들었으면 PagingDataAdapter에 연결해줘야 합니다. withLoadStateFooter, withLoadStateHeader, withLoadStateHeaderAndFooter 함수로 연결해줄 수 있으며, 함수 결과로 ConcatAdapter를 반환합니다.
네트워크에서 지연이 발생한 것을 테스트해주기 위해 지난 글에서 작성했던 PagingSource의 load() 함수에서 delay(500)을 걸어주겠습니다.
class SamplePagingSource @Inject constructor(
private val service: SampleBackendService
) : PagingSource<Int, String>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
return try {
// delay 발생
delay(5000)
...
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, String>): Int? {
...
}
}
에러 발생도 테스트해주기 위해 간헐적으로 익셉션을 발생시켜 줍니다.
class SamplePagingSource @Inject constructor(
private val service: SampleBackendService
) : PagingSource<Int, String>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
return try {
delay(500)
// 에러 발생 !
if (Random.nextFloat() < 0.5) {
throw Exception("error !!!")
}
...
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, String>): Int? {
...
}
}
예제 소스
https://github.com/tkdgusl94/blog-source/tree/master/paging3
참고
https://developer.android.com/topic/libraries/architecture/paging/v3-network-db?hl=ko
'Android' 카테고리의 다른 글
[Android] BottomNavigationView에서 Fragment 전환 (3) | 2021.08.09 |
---|---|
[Android] Kotlin Coroutine 기본 개념 익히기 (0) | 2021.07.29 |
[Android] buildSrc를 통한 Dependency 관리 (0) | 2021.07.11 |
[Android] Clean Architecture in Android (12) | 2021.07.03 |
[Android] 오픈소스 라이선스 목록 보여주기 - OssLicensesMenuActivity (4) | 2021.07.03 |