Всем привет после такого длительного перерыва возвращаем серию статей 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