Всем привет после такого длительного перерыва возвращаем серию статей Boilerplate. Сегодня будем разбирать как облегчить пагинацию с помощью библиотеки Paging 3. За это время достаточно правок произошло в самом репозитории Boilerplate которые мы сегодня тоже разберем.
Ссылки на предыдущие статьи чтобы понимать что здесь происходит:
Single Activity с Navigation component. Или как я мучался с графами
Запросы в сеть с Clean Architecture – Обработка ошибок с сервера
Мы не будем смотреть как работает библиотека Paging 3, а разберем как облегчить работу с ней. По этой причине вы должны обладать базовой информацией по этой библиотеке.
Сначала пойдем от слоя domain. Пропишем в нем наши сущности, запросы и сценарии использования.
class Foo(
val id: Long,
val bar: String
)
Далее нам нужно затянуть Paging 3 в слой domain чтобы у нас был доступ к классу PagingData к которому мы будем обращаться в Repository. Спорный момент - библиотека от Android то есть мы зависим от платформы, у него конечно есть альтернативная зависимость для тестов без зависимостей Android, но платформа есть платформа (слишком много слов "зависимость" и "Android", но суть надеюсь вы поняли). Тут вам уже решать как поступать, мое решение я все таки затянул common модуль.
implementation("androidx.paging:paging-common:3.1.1")
Пропишем для запроса Repository и Use case:
interface FooRepository {
fun fetchFoo(): Flow<PagingData<Foo>>
}
class FetchFooUseCase @Inject constructor(
private val repository: FooRepository
) {
operator fun invoke() = repository.fetchFoo()
}
Перейдем к слою dataи добавим уже runtime зависимость в котором уже содержится классы Android'a. Оно будет добавлено с помощью метода api() для транзитивности.
api("androidx.paging:paging-runtime-ktx:3.1.1")
И давайте поправим нашу ошибку с прошлой статьи насчет DataMapper, там не нужен extension.
interface DataMapper<T> {
fun mapToDomain(): T
}
Создаем модельку в слое data который будет имплементировать наш интерфейс для маппинга.
class FooDto(
@SerializedName("id")
val id: Long,
@SerializedName("bar")
val bar: String
) : DataMapper<Foo> {
override fun mapToDomain() = Foo(id, bar)
}
Дальше пропишем сам запрос вApiServiceи инициализируем его.
interface FooApiService {
@GET
suspend fun fetchFoo(
@Query("page") page: Int
): Response<FooPagingResponse<FooDto>>
}
FooPagingResponse - это базовая обертка для любого запроса с пагинацией. Выглядит он таким образом:
class FooPagingResponse<T>(
@SerializedName("prev")
val prev: Int?,
@SerializedName("next")
val next: Int?,
@SerializedName("data")
val data: MutableList<T>
)
Далее как мы все знаем в Paging 3 содержится класс PagingSource от которого мы наследуемся и прописываем логику пагинации, так как для каждого запроса нам приходится писать классы с одинаковой функцией мы оптимизируем это созданием базового класса:
private const val BASE_STARTING_PAGE_INDEX = 1
abstract class BasePagingSource<ValueDto : DataMapper<Value>, Value : Any>(
private val request: suspend (position: Int) -> Response<FooPagingResponse<ValueDto>>,
) : PagingSource<Int, Value>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Value> {
val position = params.key ?: BASE_STARTING_PAGE_INDEX
return try {
val response = request(position)
val data = response.body()!!
LoadResult.Page(
data = data.data.map { it.mapToDomain() },
prevKey = null,
nextKey = data.next
)
} catch (exception: IOException) {
LoadResult.Error(exception)
} catch (exception: HttpException) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, Value>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
Используем этот базовый класс и создаем FooPagingSource для нашего запроса:
class FooPagingSource(
private val service: FooApiService
) : BasePagingSource<FooDto, Foo>(
{ service.fetchFoo(it) }
)
И переходим к репозиториям, в BaseRepository нам нужно будет добавить вспомогательный метод для запросов с пагинацией.
abstract class BaseRepository {
// ...
/**
* Do network paging request with default params
*/
protected fun <ValueDto : DataMapper<Value>, Value : Any> doPagingRequest(
pagingSource: BasePagingSource<ValueDto, Value>,
pageSize: Int = 10,
prefetchDistance: Int = pageSize,
enablePlaceholders: Boolean = true,
initialLoadSize: Int = pageSize * 3,
maxSize: Int = Int.MAX_VALUE,
jumpThreshold: Int = Int.MIN_VALUE
): Flow<PagingData<Value>> {
return Pager(
config = PagingConfig(
pageSize,
prefetchDistance,
enablePlaceholders,
initialLoadSize,
maxSize,
jumpThreshold
),
pagingSourceFactory = {
pagingSource
}
).flow
}
}
В самом репозитории все будет выглядить таким образом:
class FooRepositoryImpl @Inject constructor(
private val service: FooApiService
) : BaseRepository(), FooRepository {
override fun fetchFoo() = doPagingRequest(FooPagingSource(service))
}
Инициализируем в RepositoriesModule
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoriesModule {
// ...
@Binds
abstract fun bindFooRepository(
fooRepositoryImpl: FooRepositoryImpl
): FooRepository
}
Тут уже мы подошли к слою presentation. Нам нужно добавить дополнительные методы для обработки запроса с пагинацией в BaseViewModel и BaseFragment.
abstract class BaseViewModel : ViewModel() {
// ...
/**
* Collect paging request
*/
protected fun <T : Any, S : Any> Flow<PagingData<T>>.collectPagingRequest(
mappedData: (T) -> S
) = map { it.map { data -> mappedData(data) } }.cachedIn(viewModelScope)
}
abstract class BaseFragment<ViewModel : BaseViewModel, Binding : ViewBinding>(
@LayoutRes layoutId: Int
) : Fragment(layoutId) {
// ...
/**
* Collect [PagingData] with [collectFlowSafely]
*/
protected fun <T : Any> Flow<PagingData<T>>.collectPaging(
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
action: suspend (value: PagingData<T>) -> Unit
) {
collectFlowSafely(lifecycleState) { this.collectLatest { action(it) } }
}
}
Теперь напишем ещё одну модельку для этого слоя в котором у нас будет содержаться маппинг с domain в ui.
data class FooUI(
override val id: Long,
val bar: String
) : IBaseDiffModel<Long>
fun Foo.toUI() = FooUI(
id, bar
)
IBaseDiffModel<T> - это интерфейс который нам помогает без дополнительных усилий создать Comparator (DiffUtil.ItemCallback) для использования в PagingDataAdapter или же ListAdapter. Ниже будет показан как должен выглядит этот файл.
Дополнительно класс FooUI должен быть data class'ом чтобы под капотом уже переопределился метод equals() который нужен для IBaseDiffModel<T> и DiffUtil.ItemCallback.
interface IBaseDiffModel<T> {
val id: T
override fun equals(other: Any?): Boolean
}
class BaseDiffUtilItemCallback<T : IBaseDiffModel<S>, S> : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}
}
Переходим к сбору данных, вызываем запрос в ViewModel и собираем их в Fragment'e:
@HiltViewModel
class HomeViewModel @Inject constructor(
private val fetchFooUseCase: FetchFooUseCase
) : BaseViewModel() {
fun fetchFoo() = fetchFooUseCase().collectPagingRequest { it.toUI() }
}
Для того чтобы собрать отобразить данные нам нужен Recycler и соответственно Adapter для него.
class FooPagingAdapter : PagingDataAdapter<FooUI, FooPagingAdapter.FooPagingViewHolder>(
BaseDiffUtilItemCallback()
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FooPagingViewHolder {
return FooPagingViewHolder(
ItemFooBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: FooPagingViewHolder, position: Int) {
getItem(position)?.let { holder.onBind(it) }
}
inner class FooPagingViewHolder(private val binding: ItemFooBinding) : RecyclerView.ViewHolder(
binding.root
) {
fun onBind(item: FooUI) = with(binding) {
textItemFoo.text = item.bar
}
}
}
Так как PagingDataAdapter принимает в параметр DiffUtil.ItemCallback мы туда можем уже просто передать наш базовый Comparator которым у нас является BaseDiffUtilItemCallback().
А в Fragment'e у нас все просто, создаем adapter инициализируем с recycler'ом, делаем запрос и собираем данные.
@AndroidEntryPoint
class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(
R.layout.fragment_home
) {
override val viewModel: HomeViewModel by viewModels()
override val binding by viewBinding(FragmentHomeBinding::bind)
private val fooAdapter = FooPagingAdapter()
override fun initialize() {
setupFooRecycler()
}
private fun setupFooRecycler() = with(binding) {
recyclerHomeFoo.layoutManager = LinearLayoutManager(context)
recyclerHomeFoo.adapter = fooAdapter.withLoadStateFooter(
footer = CommonLoadStateAdapter { fooAdapter.retry() }
)
fooAdapter.addLoadStateListener { loadStates ->
recyclerHomeFoo.isVisible = loadStates.refresh is LoadState.NotLoading
binding.loaderHome.isVisible = loadStates.refresh is LoadState.Loading
}
}
override fun setupRequests() {
fetchFoo()
}
private fun fetchFoo() {
viewModel.fetchFoo().collectPaging {
fooAdapter.submitData(it)
}
}
}

В результате все будет выглядить таким образом:
Работа над ошибками с прошлых частей:
Выше уже исправил, но здесь тоже упомяну в интерфейсе DataMapper не нужно создавать extension.
interface DataMapper<T> {
fun mapToDomain(): T
}
Дальше давайте добавим метод в BaseRepository для обработки данных в случае успешного ответа сервера.
abstract class BaseRepository {
//...
/**
* Get non-nullable body from request
*/
protected inline fun <T : Response<S>, S> T.onSuccess(block: (S) -> Unit): T {
this.body()?.let(block)
return this
}
}
И как теперь выглядит SignInRepositoryImpl
class SignInRepositoryImpl @Inject constructor(
private val service: SignInApiService
) : BaseRepository(), SignInRepository {
// before
override fun signIn(userSignIn: UserSignIn) = doNetworkRequest {
service.signIn(userSignIn.fromDomain()).also { data ->
data.body()?.let {
// save token
it.token
}
}
}
// after
override fun signIn(userSignIn: UserSignIn) = doNetworkRequest {
service.signIn(userSignIn.fromDomain()).onSuccess { data ->
/**
* Do something with [data]
*/
data.token
}
}
}
Далее выведим файл NetworkErrorExtensions.kt в класс BaseFragment и сольем в один все методы:
abstract class BaseFragment<ViewModel : BaseViewModel, Binding : ViewBinding>(
@LayoutRes layoutId: Int
) : Fragment(layoutId) {
//...
/**
* [NetworkError] extension function for setup errors from server side
*/
fun NetworkError.setupApiErrors(vararg inputs: TextInputLayout) = when (this) {
is NetworkError.Unexpected -> {
Toast.makeText(context, this.error, Toast.LENGTH_LONG).show()
}
is NetworkError.Api -> {
for (input in inputs) {
error[input.tag].also { error ->
if (error == null) {
input.isErrorEnabled = false
} else {
input.error = error.joinToString()
this.error.remove(input.tag)
}
}
}
}
}
}
На этом все! В следующей статье разберем как сделать переход на детальную страницу и детальный запрос.
Репозиторий где будет этот проект: github.com/TheAlisher/Boilerplate-Sample-Android. Код из статьи находиться в этой ветке.
Основной репозиторий самого Boilerplate: github.com/TheAlisher/Boilerplate-Android