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