
Всем известна чрезмерная многословность библиотек 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 пейджера подсказал именно ИИ, также как и продающий заголовок для статьи.
ссылка на оригинал статьи https://habr.com/ru/articles/907910/
Добавить комментарий