Запросы с пагинацией с помощью Paging 3 и работа над ошибками. Boilerplate ч. 4

от автора

Всем привет после такого длительного перерыва возвращаем серию статей Boilerplate. Сегодня будем разбирать как облегчить пагинацию с помощью библиотеки Paging 3. За это время достаточно правок произошло в самом репозитории Boilerplate которые мы сегодня тоже разберем.

Ссылки на предыдущие статьи чтобы понимать что здесь происходит:

Мы не будем смотреть как работает библиотека 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)                     }                 }             }         }     } }

На этом все! В следующей статье разберем как сделать переход на детальную страницу и детальный запрос.


ссылка на оригинал статьи https://habr.com/ru/post/682120/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *