
Всем известна чрезмерная многословность библиотек Jetpack от Google для разработки на Android. Однажды я спросил у chatGPT, зачем они так со мной, на что ИИ таким же многословным образом рассказал о двух китах проектирования, на которых стоит в поперечном шпагате типовой Google Developer Expert:
Строгое соблюдение чистой архитектуры при кодостроительстве с выделением слоев data - domain - UI,
Предоставление максимальной гибкости и широты горизонта на откуп разработчика.
Результатом стала необходимость писать в нескольких местах по увестистому boilerplate-шмотку кода. Также стоит помнить, что Jetpack-библиотеки на протяжении веков (ну или только лет) писались под Android target и исключительно под использование XML View в слое UI. Поскольку Java-эпоха до прибытия Kotlin еще и не такое видела с точки зрения boilerplate и многословности кода, а о Compose UI еще не задумывались даже в самом Google (что логично, ведь задуматься о нем позволил как раз Kotlin), ни у кого не вызывали раздражения килограммы шаблонных методов про DiffUtil, Refresh key, Adapter и т.д. Последнее в целом относится не только к Paging, как библиотеке, а вообще ко всему, что так или иначе подвязывалось к легендарному Recycler View, единственному монстру, на который перетащили практически весь UI в большинстве приложений, отображающих что-либо списком, табличкой, да хоть лесенкой.
Появился Kotlin, появился Compose, а потом еще и Kotlin multiplatform. Библиотеки Jetpack переписались на Kotlin, в UI слое появилась ориентация на Compose, многие библиотеки стали мульти-платформенными. Однако шаблонный код никуда не ушел, а даже вполне себе пророс как сорняк и в других местах.
Чтобы максимально приблизиться к земле скажем, что библиотека Paging3 дает всего лишь один (1) метод доставки страницы данных в Compose UI - collectAsLazyPagingItems, и ради этого одного метода надо сначала настроить кучу классов и шаблонных функций, настройки которых вполне можно было бы автоматизировать внутри библиотеки, поскольку все параметры приходят в самый первый класс и по-другому их просто бессмысленно настраивать, так что гибкость и широта горизонта тут не имеют никакого значения.
Результатом же этого самого collect'ора является громоздкий, сложный и недостаточно документированный объект класса LazyPagingItems. Да, он по-прежнему универсален и заточен под Recycler View, а возможность использования в Compose это лишь приятный бонус для бегущих впереди паровоза, так что bolierplate-церемониал, добро пожаловать в Kotlin!
Это все, конечно, сильно подбешивало до такой степени, что ленивый я решил изменить всё по красоте. Вот такое ниже мне не нравилось, особенно если учесть, что здесь сокращены блоки кода самого UI и удалены не относящиеся к отображению самих данных элементы. Однако именно так предлагают обрабатывать данные, выдаваемые пейджером, в Best Code Practices от самого Google:
@Composable
fun ArticlesPage(
pager: Pager<Int, Article>,
onAction: (NewsListAction) -> Unit,
) {
val newsListState = rememberLazyListState()
val articles = pager.flow.collectAsLazyPagingItems()
LazyColumn(
state = newsListState,
) {
if (articles.loadState.refresh == LoadState.Loading) {
item {
// UI element for loading
}
}
if (loadState.refresh is LoadState.Error) {
item {
// UI element for error
}
}
items(count = articles.itemCount) { index ->
val article = articles[index]
article?.let {
// UI element for article item
}
}
if (articles.loadState.append == LoadState.Loading) {
item {
// UI element for loading next page
}
}
if (loadState.append.endOfPaginationReached) {
item {
// UI element for last page
}
}
}
}
Чем здесь всё плохо? Во-первых, пейджер как объект (а он - результат, возвращаемый функцией репозитория или data-слоя из API или локальной база данных) дошел до параметра в этой последней функции через кучу других composable-функций, а хранится при этом в data классе, отображающим все состояние экрана внутри ViewModel.
Так быть не должно, однако иного не дано, это тот самый тип, который только и можно получить из библиотеки, и он внутри себя содержит вообще всё: и полезные данные страницы, и сложносоставное состояние статуса загрузки, наличия ошибок и т.п., но при этом где-то на уровне Composable-функции явно должен быть вызов collectAsLazyPagingItems именно от пейджера, чтобы реагировать на новые порции данных и новые статусы.
Во-вторых, сложность структуры статуса загрузки и наличия ошибок заставляет обрабатывать все возможные варианты внутри блока LazyColumn content, что никак не способствует читабельности кода, его понятности и сопровождаемости.
Представленный выше код некорретен, поскольку многие элементы могут существовать одновременно, и на экране будет мешанина. Все ветвления необходимо оборачивать хотя бы в when, однако и это не поспособствует улучшению качества кода.
Далее я покажу, как для себя решил указанные проблемы, причем только финальный вариант будет Best of Kotlin, поскольку все предыдущие с тем или иным успехом можно было бы повторить в Java и любом другом lambda-содержащем языке.
Нулевой шаг. Убираем Pager на свое место.
Pager следует убрать в тот слой, в котором ему самое место - data слой. А интерфейс вывести наружу через domain слой так:
interface NewsRepository {
fun searchNews(query: String): Flow<PagingData<Article>>
// .... other functions
}
По старой доброй традиции к репозиторию за данными будет обращаться ViewModel, тот самый collectAsLazyPagingItems при этом будет вызываться в hoist-composable функции, а в функцию, ответственную за непосредственный вывод элементов, будет передаваться уже коллекция LazyPagingItems (этот класс - сложносоставной, но дает прямой доступ по get через индекс, т.е. items[25], поэтому для удобства я напишу именно так).
Первый подход. Kotlin extensions & null-safety.
Первый и все последующие подходы будут реализовываться с помощью fluent-интерфейса. На мой взгляд такой архитектурный паттерн является смесью шаблонов проектирования Строителя и Цепочки обязанностей. Первый шаблон fluent-интерфейс напоминает по форме самим кодом, при этом не являясь порождающим паттерном, а второй схож по содержанию своей функцией - передавать объект по цепочке ровно до того единственного метода, который обязан его обработать с учетом текущего состояния.
Терминальность, т.е. свершившийся факт обработки объекта и разрыв цепочки методов будем реализовывать через null, а создание самих обработчиков - через расширения класса:
@Composable
inline fun <T : Any> LazyPagingItems<T>.onEmpty(body: @Composable () -> Unit): LazyPagingItems<T>? {
return if (loadState.refresh !is LoadState.Error && itemCount == 0) {
body()
null
} else this
}
@Composable
inline fun <T : Any> LazyPagingItems<T>.onRefresh(body: @Composable () -> Unit): LazyPagingItems<T>? {
return if (loadState.refresh is LoadState.Loading) {
body()
null
} else this
}
@Composable
inline fun <T : Any> LazyPagingItems<T>.onError(body: @Composable (DataError) -> Unit): LazyPagingItems<T>? {
return if (loadState.refresh is LoadState.Error) {
val error = when (val e = (loadState.refresh as LoadState.Error).error) {
is NoTransformationFoundException -> DataError.Remote.SERIALIZATION_ERROR
is DoubleReceiveException -> DataError.Remote.SERIALIZATION_ERROR
is SocketTimeoutException -> DataError.Remote.REQUEST_TIMEOUT
is UnresolvedAddressException -> DataError.Remote.NO_INTERNET_CONNECTION
is ResponseException -> when (e.response.status.value) {
408 -> DataError.Remote.REQUEST_TIMEOUT
429 -> DataError.Remote.TOO_MANY_REQUESTS
in 500..599 -> DataError.Remote.SERVER_ERROR
else -> DataError.Remote.UNKNOWN_ERROR
}
else -> DataError.Remote.UNKNOWN_ERROR
}
body(error)
null
} else this
}
Данный подход дает нам возможность уже писать в декларативном стиле:
@Composable
fun ArticlesPage(
articles: LazyPagingItems<Article>,
onAction: (NewsListAction) -> Unit,
) {
val newsListState = rememberLazyListState()
val scope = rememberCoroutineScope()
articles
.onRefresh { CircularProgressIndicator() }
?.onEmpty { // UI for empty results }
?.onError { error -> // UI for error view }
?.let { page ->
LazyColumn(
state = newsListState,
) {
items(count = page.itemCount) { index ->
val article = page[index]
article?.let {
// UI for article view
}
}
if (articles.loadState.append == LoadState.Loading) item {
// UI for loading next page
}
}
}
}
Что здесь бросается в глаза - игры с null, от которых Kotlin старался уйти. Да, здесь null использован как флаг, однако можно сделать лучше. Флаг - это состояние, а следовательно, его нужно где-то сохранять.
Второй подход. Заворачиваем данные в декоратор.
Логичный шаг, который напрашивается сам собой, - декоратор или wrapper над объектом. Назовем этот наш декоратор Handleable, и тогда вызывающий код будет выглядеть вот так:
articles.asHandleable()
.onRefresh { ... }
.onEmpty { ... }
.onError { error -> ... }
.onSuccess { items -> ... }
И вот утилитный код декоратора, позволяющий этого добиться:
data class PageHandleable <T : Any>(
val items: LazyPagingItems<T>,
val isHandled: Boolean = false
) {
fun markHandled() = PageHandleable(items, true)
}
@Composable
fun <T : Any> LazyPagingItems<T>.asHandleable(): PageHandleable<T> = PageHandleable(this)
@Composable
inline fun <T : Any> PageHandleable<T>.onEmpty(crossinline body: @Composable () -> Unit): PageHandleable<T> {
return if (!isHandled && items.loadState.refresh !is LoadState.Error && items.itemCount == 0) {
body()
markHandled()
} else this
}
@Composable
inline fun <T : Any> PageHandleable<T>.onRefresh(crossinline body: @Composable () -> Unit): PageHandleable<T> {
return if (!isHandled && items.loadState.refresh is LoadState.Loading) {
body()
markHandled()
} else this
}
@Composable
inline fun <T : Any> PageHandleable<T>.onError(crossinline body: @Composable (DataError) -> Unit): PageHandleable<T> {
return if (!isHandled && items.loadState.refresh is LoadState.Error) {
val error = (items.loadState.refresh as LoadState.Error).error
val result = when (error) {
is NoTransformationFoundException -> DataError.Remote.SERIALIZATION_ERROR
is DoubleReceiveException -> DataError.Remote.SERIALIZATION_ERROR
is SocketTimeoutException -> DataError.Remote.REQUEST_TIMEOUT
is UnresolvedAddressException -> DataError.Remote.NO_INTERNET_CONNECTION
is ResponseException -> when (error.response.status.value) {
408 -> DataError.Remote.REQUEST_TIMEOUT
429 -> DataError.Remote.TOO_MANY_REQUESTS
in 500..599 -> DataError.Remote.SERVER_ERROR
else -> DataError.Remote.UNKNOWN_ERROR
}
else -> DataError.Remote.UNKNOWN_ERROR
}
body(result)
markHandled()
} else this
}
@Composable
inline fun <T : Any> PageHandleable<T>.onSuccess(crossinline body: @Composable (LazyPagingItems<T>) -> Unit): PageHandleable<T> {
return if (!isHandled) {
body(items)
markHandled()
} else this
}
Как видим, всё до текущего момента не являет собой никакой Kotlin-магии и может быть реализовано на любом другом ООП-языке с дженериками и анонимными функциями (лямбдами) практически дословно. Почему нельзя было сделать такие абстракции внутри библиотеки, я не знаю, ведь с ними явно проще писать понятный с точки зрения бизнес-логики код.
Третий подход - магия DSL выходит на ринг.
Можно было бы оставить код на втором уровне, однако то, как LazyPagingItems должны обрабатываться внутри LazyList (в моем случае - Column), меня смутило своей выбивающейся из общего фона многословностью. Речь о том, что классический вариант функции items внутри LazyList не сработает с LazyPagingItems. Не сработает и itemsIndexed, поскольку оба расширения были сначала помечены устаревшими, а после удалены из библиотеки paging-compose. Причина объяснена и понятна, однако кода писать теперь приходится чуть больше, используя прямое обращение к элементу по индексу, и проверяя, сществует ли он вообще на момент обращения.
Не совсем идеально выглядела и последовательность функций, а также иерархия их видимости в зависимости от своего уровня scope, как говорится. В итоге, вдохновившись самим Compose, я пришел к следующей, прекрасной, на мой взгляд, форме:
@Composable
fun ArticlesPage(
articles: LazyPagingItems<Article>,
onAction: (NewsListAction) -> Unit,
) {
val newsListState = rememberLazyListState()
HandlePagingItems(articles) {
onRefresh { CircularProgressIndicator() }
onEmpty { // UI for empty list }
onError { error -> // UI for error }
onSuccess { items ->
LazyColumn(newsListState) {
onPagingItems(key = { it.id }) { article -> // UI for article }
onAppendItem { CircularProgressIndicator(Modifier.padding(6.dp)) }
onLastItem { // UI for end of the list }
}
}
}
}
Здесь onPagingItems, onAppendItem и onLastItem могут быть вызваны только внутри LazyList scope, причем в контектсе LazyPagingItems. Вот текст DSL, с помощью которого это реализовано:
@DslMarker
annotation class PagingDSL
@PagingDSL
class PagingHandlerScope<T : Any>(
private val items: LazyPagingItems<T>
) {
private var handled = false
private val loadState = derivedStateOf { items.loadState }.value
@Composable
fun onEmpty(body: @Composable () -> Unit) {
if (handled) return
if (loadState.refresh !is LoadState.Error && items.itemCount == 0) {
handled = true
body()
}
}
@Composable
fun onRefresh(body: @Composable () -> Unit) {
if (handled) return
if (loadState.refresh is LoadState.Loading) {
handled = true
body()
}
}
@Composable
fun onSuccess(body: @Composable (LazyPagingItems<T>) -> Unit) {
if (!handled) {
handled = true
body(items)
}
}
@Composable
fun onError(body: @Composable (DataError) -> Unit) {
if (handled) return
if (loadState.refresh is LoadState.Error) {
val error = (loadState.refresh as LoadState.Error).error
val result = when (error) {
is NoTransformationFoundException -> DataError.Remote.SERIALIZATION_ERROR
is DoubleReceiveException -> DataError.Remote.SERIALIZATION_ERROR
is SocketTimeoutException -> DataError.Remote.REQUEST_TIMEOUT
is UnresolvedAddressException -> DataError.Remote.NO_INTERNET_CONNECTION
is ResponseException -> when (error.response.status.value) {
400 -> DataError.Remote.BAD_REQUEST
401 -> DataError.Remote.UNAUTHORIZED
403 -> DataError.Remote.FORBIDDEN
404 -> DataError.Remote.NOT_FOUND
405 -> DataError.Remote.SERVER_ERROR
409 -> DataError.Remote.CONFLICT
408 -> DataError.Remote.REQUEST_TIMEOUT
429 -> DataError.Remote.TOO_MANY_REQUESTS
in 500..599 -> DataError.Remote.SERVER_ERROR
else -> DataError.Remote.UNKNOWN_ERROR
}
else -> DataError.Remote.UNKNOWN_ERROR
}
handled = true
body(result)
} else this
}
@LazyScopeMarker
fun LazyListScope.onAppendItem(body: @Composable LazyItemScope.() -> Unit) {
if (loadState.append == LoadState.Loading) {
item { body(this) }
}
}
@LazyScopeMarker
fun LazyListScope.onLastItem(body: @Composable LazyItemScope.() -> Unit) {
if (loadState.append.endOfPaginationReached) item { body(this) }
}
@LazyScopeMarker
fun LazyListScope.onPagingItems(key: ((T) -> Any)?, body: @Composable LazyItemScope.(T) -> Unit) {
items(
count = items.itemCount,
key = items.itemKey(key),
) { index ->
val item = items[index]
item?.let {
body(it)
}
}
}
}
@Composable
fun <T : Any> HandlePagingItems(
items: LazyPagingItems<T>,
content: @Composable PagingHandlerScope<T>.() -> Unit
) {
PagingHandlerScope(items).content()
}
Следует объяснить магию этой строчки отдельно:
private val loadState = derivedStateOf { items.loadState }.value
Тут всё дело в самом Compose и его видении того, какие элементы внутри должны проходить перерисовку при рекомпозиции. Поскольку loadState пейджера само по себе сложносоставное свойство, за его изменениями с целью явной рекомпозиции приходится следить с помощью derivedState. В противном случае будут неизбежны непонятные ошибки в UI, который полагается на изменения этого свойства пейджера.
Из-за того, что потенциальное состояние ошибки хардкодно зашито библиотекой внутрь возвращаемого пейджером результата через сложносоставное свойство loadState, невозможно вынести обработку ошибок в другой слой, и приходится работать на месте. К сожалению, оборачивание LazyPagingItems<T> еще в один union-тип, например, Result<LazyPagingItems<T>, Error<DataError>> на уровне репозитория будет чревато магическими неожиданностями рекомпозиции в Compose. Если у кого-то получится это сделать с гарантией корректности, буду признателен подсказкам.
Достоинства подхода с DSL помимо субъективной красоты кода заключаются в том, что теперь это не неразрывная цепочка методов, разделенных точкой и передающих один и тот же объект друг другу, а scope, хранящий этот объект внутри себя и предоставляющий интерфейс конкретных действий с этим объектом, предписанных бизнес-логикой. Кроме того, это отдельные функции, между которыми может располагаться другой код в какой угодно последовательности.
Механизм использованного DSL применен в небольшом приложении SpaceNews Explorer, на котором я собственно оттачивал самые первые и робкие навыки работы с Paging3. Приложение является мульти-платформенным, работает на Desktop (есть сборки под Windows и Ubuntu) и Android, лежит на GitHub здесь, а также опубликовано в Google Play.
Большое спасибо всем, кто прочитал статью, но особенно ценными для любого автора будут комментарии и советы по совершенствованию кода.
P.S. Две вещи, которые я узнал, создавая эту статью и приложение:
называйте свои коммиты правильно, чтобы через год, когда вам захочется найти этапы реализации конкретной фичи, для этого не пришлось бы пересматривать две сотни коммитов по хронологии;
chatGPT и в целом LLM - прекрасные помощники для отлова ошибок в коде, подсказок по архитектурным приемам. Хак с derivedState для loadState пейджера подсказал именно ИИ, также как и продающий заголовок для статьи.