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

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

Комментарии (0)