Привет! Меня зовут Георгий Бердников. Я разработчик в компании 65apps, занимаюсь созданием мобильных приложений на Android. Сегодня расскажу о том, как совместить приятное с полезным, поймать двух зайцев и журавля с неба инженеру, перед которым встала сложная задача реализовать постраничную загрузку в приложении.
Библиотека Paging3 упрощает работу с пагинацией. Она всё делает сама: вам не нужно заниматься ручной передачей данных по заветам популярных архитектур, таких как MVI, MVVM и MVP. Снаружи задумка кажется хорошей, но она может стать ложкой дёгтя в бочке мёда. Инструменты, взаимодействующие с общим состоянием (к таким относятся, например, средства отладки в фреймворке MVIKotlin), не смогут контактировать с Paging3.
В статье я покажу, как решить эту проблему. В качестве плацдарма для модификаций был выбран небезызвестный сэмпл от Google, в который мы и внедрим подставьте сюда фреймворк своей мечты (в статье используется вышеупомянутый MVIKotlin). Наш взор падёт только на Paging3, функциональность вставки и удаления предметов оставим в стороне.
Источник данных
Всё, что связано с получением данных — Entity, DAO, база данных — останется неизменным. Эта статья будет вкусной! ????????????
Слой модели
В первую очередь назначим ответственного за получение данных. Это будет CheeseSource:
interface CheeseSource {
suspend fun get(index: Int): List<CheeseListItem?>
}
Nullable-элементы в списке — способ реализации плейсхолдеров, аналогичный тому, что AsyncPagingDataDiffer.getItem() может вернуть null.
Затем создадим способ передачи данных между компонентами. Встречайте — CheeseStore:
interface CheeseStore : Store<Intent, State, Nothing> {
sealed class Intent {
class LoadMore(val index: Int) : Intent()
}
data class State(
val items: List<CheeseListItem?>
)
}
Intent один, и его название говорит само за себя: он отвечает за загрузку элементов при скроллинге на определенный индекс (о скроллинге чуть позже). Intent и State, проще говоря, являются входом и выходом метода CheeseSource.get(), вокруг которого вертится весь слой.
Совместить все входы и выходы и понять смысл жизни поможет CheeseExecutor:
class CheeseExecutor(
private val cheeseSource: CheeseSource
) : SuspendExecutor<Intent, Unit, State, State, Nothing>() {
override suspend fun executeIntent(intent: Intent, getState: () -> State) {
when (intent) {
is LoadMore -> dispatch(getState().copy(items = cheeseSource.get(index)))
}
}
override suspend fun executeAction(action: Unit, getState: () -> State) {
dispatch(getState().copy(items = cheeseSource.get(0)))
}
}
Как можно заметить, он не только отвечает на намерения, но и отдаёт первую порцию данных в начале работы Store.
Настало время для самой важной части статьи: реализация CheeseSource!
class CheeseDatabaseSource(
private val cheeseDao: CheeseDao
) : CheeseSource {
private val scope = CoroutineScope(Dispatchers.IO)
private val pager = /* The same Pager.flow as in CheeseViewModel */
private val noopListUpdateCallback = object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) = Unit
override fun onChanged(position: Int, count: Int, payload: Any?) = Unit
override fun onMoved(fromPosition: Int, toPosition: Int) = Unit
override fun onRemoved(position: Int, count: Int) = Unit
}
private val dataDiffer = AsyncPagingDataDiffer(
diffCallback = CheeseAdapter.diffCallback,
updateCallback = noopListUpdateCallback,
mainDispatcher = Dispatchers.Default,
workerDispatcher = Dispatchers.Default
)
init {
scope.launch {
pager.collectLatest {
dataDiffer.submitData(it)
}
}
}
override suspend fun get(index: Int): List<CheeseListItem?> = with(dataDiffer) {
if (index in 0 until itemCount) {
getItem(index)
loadStateFlow.first {
it.refresh !is LoadState.Loading
}
} else {
loadStateFlow.first {
it.refresh.endOfPaginationReached || snapshot().isNotEmpty()
}
}
snapshot()
}
}
CheeseDatabaseSource постоянно отслеживает Pager.flow и передаёт PagingData из него в dataDiffer, используемый нами вовсе не для измерения разницы между списками, а для получения заветных данных!
noopListUpdateCallback нужен лишь для создания dataDiffer.
Сакральный метод get() разделяется на две части, одна из которых отвечает за первичную загрузку данных, а вторая — за последующие. Если элементов в dataDiffer не оказалось, мы ждём их появления. Если они есть — передаем новый индекс и ждём, пока закончится загрузка.
ItemSnapshotList<T> из Paging3 реализует интерфейс List<T?>, поэтому мы вольны без каких-либо манипуляций вернуть snapshot().
Наконец, соберём всё в CheeseStoreFactory:
class CheeseStoreFactory(
private val storeFactory: StoreFactory,
private val dao: CheeseDao
) {
fun create(): CheeseStore {
val storeDelegate = storeFactory.create(
name = CheeseStore::class.simpleName,
initialState = State(items = emptyList()),
executorFactory = { CheeseExecutor(CheeseDatabaseSource(dao)) },
bootstrapper = SimpleBootstrapper(Unit),
reducer = { it }
)
return object : CheeseStore, Store<Intent, State, Nothing> by storeDelegate {}
}
}
Отображение данных
В первую очередь, создадим CheeseView:
interface CheeseView : MviView<State, Intent>
UI-часть сэмпла придется немного поменять. Нас, по вышеописанным причинам, не устраивает CheeseAdapter, который наследуется от PagingDataAdapter. Однако вполне устраивает уже написанный CheeseViewHolder, поэтому мы будем управлять списком сами, сделав RecyclerView.Adapter родителем CheeseAdapter и используя AsyncListDiffer:
class CheeseAdapter : RecyclerView.Adapter<CheeseViewHolder>() {
var items = emptyList<CheeseListItem>()
set(value) {
field = value
listDiffer.submitList(value)
}
private val listDiffer = AsyncListDiffer(this, diffCallback)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = CheeseViewHolder(parent)
override fun onBindViewHolder(holder: CheeseViewHolder, position: Int) {
holder.bindTo(items[position])
}
override fun getItemCount() = items.size
companion object {
/* The same as in PagingSample */
}
}
Вы можете использовать любые удобные инструменты работы со списками, например, AdapterDelegates, но во имя простоты в этой статье используется минимум библиотек.
Поскольку нам самим необходимо уведомлять слой данных о том, что мы хотим отобразить новый контент, создадим OnScrollListener, уведомляющий о каждом новом элементе в области видимости пользователя:
class CheeseScrollListener(
private val layoutManager: LinearLayoutManager,
private val loadMoreCallback: (Int) -> Unit
) : RecyclerView.OnScrollListener() {
private var previousFirstVisibleItem = 0
private var previousLastVisibleItem = 0
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy < 0) {
previousFirstVisibleItem = layoutManager.findFirstVisibleItemPosition().also {
if (previousFirstVisibleItem != it) {
loadMoreCallback(it)
}
}
} else {
previousLastVisibleItem = layoutManager.findLastVisibleItemPosition().also {
if (previousLastVisibleItem != it) {
loadMoreCallback(it)
}
}
}
}
}
Дело за малым — использовать всё в реализации CheeseView:
class CheeseViewImpl(
view: View
) : BaseMviView<State, Intent>(), CheeseView {
private val recyclerView: RecyclerView = view.findViewById(R.id.cheeses)
private val adapter = CheeseAdapter()
init {
val layoutManager = LinearLayoutManager(view.context)
val scrollListener = CheeseScrollListener(layoutManager) {
dispatch(LoadMore(it))
}
recyclerView.layoutManager = layoutManager
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(scrollListener)
}
override fun render(model: State) {
adapter.items = model.items
}
}
Череда изменений завершена. Итоговое состояние проекта можно посмотреть здесь. Он смог совместить в себе MVI, широко используемый в новейших мобильных приложениях, и Paging3 с великими возможностями, а значит ничто теперь не остановит вас на пути к молниеносному созданию производительных приложений со списками!
Что дальше
Paging3 обширна, и не все её возможности взаимодействия с MVI могли быть рассмотрены в этой статье. Если найдёте другие особенности интеграции — указывайте в комментариях или пишите мне в телеграм @berdorge.
Еще одна задача на будущее, помимо внедрения — оптимизировать быстродействие. Ну и, если тема вызовет интерес, можно подумать над тем, чтобы оформить все в виде отдельной библиотеки.
ПС. остался самый главный вопрос: какой сыр мне выбрать, чтобы отпраздновать столь яркую победу? :-)