Paging3 в стиле Compose: секретный DSL, о котором молчат все Android-разработчики

от автора

Всем известна чрезмерная многословность библиотек Jetpack от Google для разработки на Android. Однажды я спросил у chatGPT, зачем они так со мной, на что ИИ таким же многословным образом рассказал о двух китах проектирования, на которых стоит в поперечном шпагате типовой Google Developer Expert:

  1. Строгое соблюдение чистой архитектуры при кодостроительстве с выделением слоев data — domain — UI,

  2. Предоставление максимальной гибкости и широты горизонта на откуп разработчика.

Результатом стала необходимость писать в нескольких местах по увестистому 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 пейджера подсказал именно ИИ, также как и продающий заголовок для статьи.


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


Комментарии

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

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