- Реализация Unidirectional Data Flow в супераппе. Часть I
- Гайд по архитектуре приложений для Android. Часть 2: слой UI
- Разберём простой пример
- Архитектура слоя UI
- Как описывать UI-состояния
- Неизменяемость
- Правила именования в этом гайде
- Как управлять состоянием с помощью UDF
- State holder
- Виды логики
- Зачем использовать UDF
- Как предоставлять доступ к UI-состоянию
- Дополнительные рекомендации
- Как принимать UI-состояния
- Как отображать прогресс выполнения операций
- Отображение ошибок на экране
- Потоки и параллелизм
- Навигация
- Пагинация
- Анимации
Реализация Unidirectional Data Flow в супераппе. Часть I
Привет, я Антон, iOS-разработчик в inDriver. К компании я присоединился год назад, став одним из первых разработчиков в новой платформенной команде. Перед платформенными командами, в отличие от продуктовых, стоят задачи по разработке, а не по продукту как таковому. Мы выделили основные направления: создание общих компонент и стандартов разработки, а также развитие и поддержка архитектуры проекта. В этой статье остановимся на архитектуре. Разберем, с какими проблемами я столкнулся в процессе ее масштабирования, какие ошибки допустил и как исправил. Обо всем по порядку.
UPD: технические детали подробнее рассказаны во второй части.
Для начала расскажу об iOS-проекте inDriver на момент создания платформенной команды. inDriver — ride-hailing стартап, созданный в 2013 году в Якутии. За 8 лет существования компания быстро росла: запускалась в новых странах, а в приложении открывались новые фичи и модули — мы называем их вертикали. Со временем приложение inDriver превратилось в суперапп, в котором просто вызвать грузовую машину, такси по городу или за город, заказать курьера или найти специалиста для решения бытовых задач.
Разнообразие сервисов повлияло на код проекта. Изначально написанный на Objective-C, он лавинообразно расширялся, а потом стал обрастать новым кодом на Swift. Времени на тесты и детальное продумывание архитектуры у разработчиков не было — чем больше становился проект, тем сложнее его было поддерживать.
Продуктовые команды работали над разными вертикалями и не имели никаких ограничений в выборе архитектуры. Поэтому, если какая-то команда создавала переиспользуемую логику или UI, использовать ее можно было только для своей вертикали. В результате вертикалям приходилось дублировать код, даже если его уже написали в другой вертикали. Код имел классические проблемы быстрорастущего стартапа и работать с ним становилось сложнее.
Мы решили, что хватит это терпеть, и пришли к выводу, что нужна общая архитектура для приложения. Это позволило бы ускорить разработку с помощью написания общих компонент, которые можно использовать во всех вертикалях. Кроме того, это упростило бы ротацию разработчиков между вертикалями и понимание кода на код-ревью. Мы сформулировали требования для общей архитектуры:
Поддержка модуляризации. Мы делили приложение на небольшие части с точки зрения бизнес-логики, UI и фич. Модуляризация позволяет отвязать код вертикалей друг от друга. Чем меньше код одной вертикали влияет на другую, тем меньше возможность получить неожиданный баг. С другой стороны, модуляризация позволяет создать переиспользумые компоненты и подключать их в нуждающихся вертикалях. Благодаря этому пишется меньше кода и ускоряется процесс разработки.
Быстрое и эффективное тестирование кода. Любой код без должного внимания со временем становится легаси-кодом. Код в inDriver не стал исключением. Согласен с Майклом Физерсом, что легаси-код — это код, не покрытый тестами. Не знаю другого способа предотвратить превращение кода в легаси, кроме покрытия тестами (если знаете, можем обсудить их в комментариях). Но с тестами есть одна большая проблема — их бывает сложно и долго писать. По этой причине многие разработчики часто отказываются от тестов, оправдывая это тем, что бизнес не дает на них время. В результате код неминуемо превращается в легаси. Наша архитектура должна легко и быстро тестироваться.
Возможность быстро перейти на SwiftUI. На примере Objective-C мы убедились, как болезненно, когда технологии меняются, а код устаревает. Хороший код на этом языке программирования сейчас является обузой. Проблемы, уже решенные в Swift, остаются без поддержки для Objective-C. Да и найти разработчиков на Objective-C становится сложнее. Поэтому приходится тратить усилия по переписыванию проектов на Swift.
Подозреваю, что со временем такая же судьба ждет и UIKit. Apple все активнее развивает SwiftUI. Не хочется попасть в ситуацию как с Objective-C и переписывать весь код под SwiftUI. Мы пока не используем SwiftUI в продакшене, но решили подстраховаться и учесть это, чтобы наша архитектура поддерживала как UIKit, так и SwiftUI. При необходимости перехода на SwiftUI, мы бы с легкостью смогли это сделать, переписав UI-слой, но не трогая бизнес-логику.
Прежде чем вводить новую архитектуру и переписывать старый код мы посмотрели, какие подходы уже реализованы в проекте. Помимо MVC (тот, что Massive) в проекте был Clean Swift и реализация Redux в виде фреймворка Unicore. На нем была написана одна фича и самая свежая вертикаль. До этого с Redux мы не работали. Был опыт работы с RxSwift и RxFeedback, поэтому некоторые вещи из Redux оказались знакомы.
Мы решили детальнее посмотреть на Redux, так как он уже был в проекте и многие разработчики успели с ним поработать. Redux — изначально JS-библиотека, которая создана для веба и работы в связке с React. Помимо Redux, в вебе множество схожих библиотек и даже целые языки, например, Elm. Да и на Swift уже хватает похожих решений: ReSwift, TCA, RxFeedback. Их объединяет использование шаблона Unidirectional Data Flow (UDF). Чтобы понять, какой из фреймворков больше подойдет команде, разберу, что собой представляет Unidirectional Data Flow.
Основная идея Unidirectional Data Flow заключается в том, чтобы данные в приложении двигались только в одном направлении: от модели приложения к UI, но не обратно. Если в UI что-то произошло, он никак не пытается интерпретировать эти события. Все, что делает UDF — отправляет события в модель, которая решает, как обновить состояние системы.
В такой схеме мы легко добиваемся того, чтобы данные, передаваемые в UI, были иммутабельными. UI получает на вход данные и отображает их, а если надо что-то изменить, UI отправляет событие (Action) в модель и ждет, когда к нему придут уже обновленные данные.
Разные фреймворки по-разному реализуют модель приложения. Попробуем найти в них общие части. Привожу названия из Redux, в скобках — альтернативные именования:
State (Model) — состояние системы. Это неизменяемые value-типы, которые описывают текущее состояние приложения.
Action (Event/Message) — события в системе. Помогают из UI сообщить о произошедших изменений и уведомить об этом модель.
Reducer (Update) — чистая функция с сигнатурой (State, Action) -> State. Единственное место, где разрешено изменение стейта. На вход получает старый State и произошедший Action, и формирует новый State. В некоторых фреймворках имеет дополнительные параметры или возвращаемые значения.
Store (Core) — агрегирующая сущность. Хранит в себе State и запускает Reducer. В качестве интерфейса предоставляет возможность отправить Action и подписаться на обновление State. Чаще всего один на приложение.
Вместе это работает так:
В UI произошло событие, и он отправляет в Store Action.
Store вызывает Reducer и передает в качестве параметров текущий State и пришедший Action. На выходе — новый State, который сохраняется в Store вместо старого.
Store оповещает UI и передает ему обновленный State.
Может показаться, что такой подход далек от мобильной разработки и не подходит ни для iOS, ни для Android. На самом деле и Apple, и Google используют Unidirectional Data Flow в своих фреймворках. Если внимательно присмотреться к схеме работы SwiftUI, мы обнаружим много сходств с нашей схемой. Google же прямым текстом упоминает Unidirectional Data Flow в документации по Jetpack Compose.
Рассмотрим плюсы Unidirectional Data Flow:
Четкое разделение доменной логики и сайд-эффектов. Принцип не новый и давно используется в функциональном (чистые функции, монады) и объектно-ориентированном программировании (CQRS). Однако большинство мобильных архитектур не акцентируют внимание на том, как реализовывать модель приложения, и бизнес-логика часто просачивается в Controller / Presenter / Interactor или View. UDF дает четкие инструкции, как организовать доменный слой приложения и получить хорошую переиспользуемую модель.
Легкое написание тестов. Так как бизнес-логика реализована в чистых функциях, протестировать ее просто. UI зависит только от полученных данных и занимается исключительно их рендерингом. Так удобно тестировать UI через snapshot-тесты. Достаточно сконфигурировать нужный State и проверить, что UI корректно рендерит его.
Но есть и ряд минусов:
1. Сложности с модуляризацией. В нашем приложении уже были модули. Вся бизнес-логика была собрана в модуле Core и каждой фиче нужно импортировать этот модуль себе:
С одной стороны, такое разделение позволяло отделить модель приложения от UI. C другой, модель получилась монолитной и сложной. Не было возможности отделить часть логики и использовать отдельно. Каждая фича знала о модели всего приложения, а, значит, и о других фичах. С таким подходом дальнейшее масштабирование проекта лишь усугубило бы текущие проблемы.
2. Проблемы с производительностью. Большинство UDF-фреймворков предполагают наличие одного Store. Это позволяет гарантировать единый источник правды и обновлять State в одном месте. Но такой подход ведет к проблемам с производительностью. Из-за того, что в Store приходят Action со всего приложения, обновления AppState могут происходить очень часто. Это создает большую нагрузку как на Reducer, так и на UI.
Существующий в проекте Redux соответствовал 2 из 3 наших требований к общей архитектуре. Во-первых, он легко покрывается тестами, как со стороны модели, так и UI. Во-вторых, State, Action и Reducer не зависят от UIKit, и вся модель приложения легко подключается к SwiftUI. Самой большой проблемой оказалась модуляризация проекта. В следующей статье расскажу, как мы справились с модуляризацией Unidirectional Data Flow и что из этого вышло.
Гайд по архитектуре приложений для Android. Часть 2: слой UI
В конце декабря 2021-го Android обновил рекомендации по архитектуре мобильных приложений. Публикуем перевод гайда в пяти частях:
Слой UI (вы находитесь здесь)
UI отображает данные приложения на экране: это основное место взаимодействия с пользователями. Если отображаемые данные меняются — из-за взаимодействия с пользователем (например, нажатия кнопки) или внешнего воздействия (например, отклика сети) — UI должен обновиться и отразить изменения. По сути UI – это состояние приложения, полученное из слоя данных и представленное визуально.
Данные приложения из слоя данных обычно отличаются по формату от отображаемой информации. К примеру, для UI может требоваться только часть полученной информации или понадобится объединить два класса DataSource, чтобы отобразить нужную пользователю информацию. Вне зависимости от выбранной логики, пользовательскому интерфейсу нужно передать всю информацию для полной отрисовки экрана. Слой UI преобразует изменения данных приложения в формат, удобный для отображения на UI, а затем отображает эти данные на экране.
Роль слоя UI в архитектуре приложения
Разберём простой пример
Представим приложение с новостями: оно показывает экран со статьями. Если пользователь вошёл в систему, он может добавлять интересные материалы в закладки. Статей много, поэтому у читателя должна быть возможность фильтровать их по категориям.
Таким образом, приложение позволяет пользователям:
Просматривать доступные для прочтения статьи.
Фильтровать их по категориям.
Войти в систему и добавить интересные статьи в закладки.
Пользоваться премиальными фичами, если к ним есть доступ.
Пример новостного приложения для изучения UI
В следующих разделах будем использовать это приложение как пример. Вы познакомитесь с принципами Unidirectional Data Flow (UDF) и узнаете, с какими проблемами в контексте архитектуры приложения в слое UI они помогают разобраться.
Архитектура слоя UI
Под термином UI подразумеваются UI-элементы (например, Activity и Fragment), которые отображают данные: вне зависимости от того, какими API они пользуются для этой цели — View или Jetpack Compose. Цель слоя данных — хранить данные приложения, управлять ими и открывать к ним доступ. Поэтому слой UI должен:
Принимать данные приложения и преобразовывать их в данные, которые UI легко сможет отрисовать.
Принимать данные, которые может отрисовать UI, и преобразовывать их в элементы UI, которые будут показаны пользователю.
Принимать события о вводе данных пользователем от элементов UI из пункта 2 и отражать в данных UI изменения, которые они вносят.
Повторять шаги 1–3 необходимое количество раз.
В следующих разделах этого гайда вы узнаете, как реализовать слой UI, чтобы он выполнял все эти шаги. В частности, этот гайд затрагивает следующие задачи и концепции:
Как описать UI-состояние.
UDF: как с его помощью создать UI-состояние и управлять им.
Как открывать доступ к UI-состоянию с помощью данных типа observable в соответствии с принципами UDF.
Как реализовать UI, который принимает UI-состояние типа observable.
Самая базовая из этих задач — описание UI-состояния.
Как описывать UI-состояния
Вернёмся к примеру: UI показывает список статей и метаданные для каждой из них. Информация, которую приложение показывает пользователю, и есть UI-состояние.
UI – то, что пользователь видит на экране. С помощью UI state приложение управляет тем, что пользователь увидит на экране.
Два этих понятия — как две стороны одной монеты: UI — это визуальное представление UI-состояния. Любые изменения UI-состояния немедленно отражаются в UI.
UI – результат привязки UI элементов на экране к UI-состоянию
Чтобы выполнить требования новостного приложения, информацию, необходимую для полной отрисовки UI, можно инкапсулировать в класс данных NewsUiState , который описывают следующим образом:
Неизменяемость
В предыдущем примере UI-состояние описано как неизменяемое. Основное преимущество такого подхода — неизменяемые объекты гарантируют целостность состояния приложения в любой момент времени.
Благодаря этому UI может полностью сконцентрироваться на единственной задаче: считывать состояние и в соответствии с ним обновлять элементы UI. Из этого следует, что менять UI-состояние в UI напрямую нельзя — если только UI сам по себе не является единственным источником получаемых им данных.
Если нарушить этот принцип, у одной и той же информации появится несколько source of truth (источников истины), что приведёт к противоречивости данных и багам, которые трудно обнаружить.
Скажем, если бы в флаг bookmarked в объекте NewsItemUiState из UI-состояния обновлялся в классе Activity , за статус статьи «сохранена» отвечали бы параллельно и этот флаг, и слой данных. Неизменяемые классы помогают успешно предотвратить возникновение таких антипаттернов.
Ключевой момент: только классы DataSource или сущности, распоряжающиеся данными, должны отвечать за обновление данных, к которым они открывают доступ.
Правила именования в этом гайде
Классы UI-состояния названы в соответствии с описываемой ими функциональностью на экране или части экрана. Правило выглядит следующим образом:
К примеру, состояние, при котором на экране отображены новости, будет называться NewsUiState . Состояние, при котором статья отображается в списке статей, — NewsItemUiState .
Как управлять состоянием с помощью UDF
UI-состояние – это неизменяемое описание информации, необходимой для отрисовки UI. Но данные в приложениях подвержены изменениям, поэтому и состояние со временем может меняться. Причиной этому может стать взаимодействие с пользователем или другие события, которые меняют данные в приложении.
Лучше обрабатывать эти взаимодействия с помощью дополнительной сущности:
она будет описывать логику, применяемую к каждому событию,
и трансформировать данные, получаемые из источников, чтобы создать UI-состояние.
Такие взаимодействия и их логика могут располагаться непосредственно в UI, однако такой подход быстро приведёт к нагромождению кода: UI будет постепенно выходить за рамки своего определения, начнёт распоряжаться данными, производить их, преобразовывать и так далее.
Это может сказаться и на удобстве тестирования: код превратится в сплав тесно связанных элементов без чётко выраженных границ. По большому счёту UI только выигрывает от того, что нагрузка на него сокращается. Единственной ответственностью UI должно быть получение и отображение UI-состояния — за исключением случаев, когда UI-состояние элементарно.
В этом разделе рассматривается UDF — архитектурный шаблон, который помогает привести код в порядок, разделив ответственности.
State holder
State holder — классы, отвечающие за производство UI-состояния и содержащие необходимую для этого логику. State holder бывает всевозможных размеров в зависимости от масштаба соответствующих элементов UI, которыми он управляет. Так, его размер может варьироваться от одного виджета вроде нижней панели навигации до целого экрана или конечной точки навигации.
В последнем случае его реализация – это, как правило, экземпляр ViewModel. Однако, в зависимости от требований приложения, иногда можно обойтись обычным классом. Так, новостное приложение из примера использует в качестве state holder класс NewsViewModel : он производит UI-состояние для экрана, изображённого в начале статьи.
Ключевой момент: тип ViewModel — реализация, у которой есть доступ к слою данных. С помощью него рекомендуется управлять UI-состоянием в масштабе целого экрана. Помимо прочего, он автоматически переживает изменения конфигураций. Класс ViewModel определяет логику, которая применяется к событиям в приложении, и в результате производит обновлённое состояние.
Смоделировать взаимозависимость между UI и сущностью, которая производит его состояние, можно множеством способов. Так как под взаимодействием между UI и его классом ViewModel по большей части подразумевают ввод некоего события и вывод соответствующего состояния, их отношения можно изобразить следующим образом:
Схематичное изображение работы UDF в рамках архитектуры приложения
UDF — паттерн, в котором поток состояния направлен вниз, а поток событий – вверх. Вот что этот паттерн даёт архитектуре приложения:
ViewModel хранит состояние и открывает к нему доступ для UI. UI-состояние – это данные приложения, преобразованные ViewModel.
UI уведомляет ViewModel о пользовательских событиях.
ViewModel обрабатывает действия пользователя и обновляет состояние.
Обновлённое состояние возвращается обратно в UI, а тот его отрисовывает.
Все предыдущие пункты повторяются с каждым событием, меняющим состояние.
В случае с конечными точками навигации или экранами ViewModel получает данные из репозиториев или классов UseCase, а затем преобразует их в UI-состояния, применяя к ним эффекты событий, вызвавших изменение состояния. В упомянутом ранее примере есть список статей: у каждой указаны заголовок, описание, источник, имя автора, дата публикации и возможна пометка «добавлено в закладки». UI каждой статьи выглядит так:
UI статьи в приложении из примера
Когда пользователь отправляет запрос, чтобы добавить статью в закладки, он создаёт событие, которое способно изменить состояние. Так как ViewModel производит состояния, его ответственность – описывать логику, необходимую для заполнения всех полей в UI-состоянии, и обрабатывать события, без которых UI не сможет отрисоваться полностью.
Цикл событий и данных в UDF
В следующих разделах — о событиях, которые ведут к изменению состояния, и способах их обработки с помощью UDF.
Виды логики
Бизнес-логика — то, как меняется состояние. Один из примеров — добавление новостной статьи в закладки: эта фича наделяет приложение ценностью. Бизнес-логику обычно помещают в доменный слой или слой данных, но точно не в слой UI.
Логика поведения UI или логика UI — то, как мы отображаем изменения состояния на экране. Например, с помощью Android Resources получаем текст, который нужно отобразить на экране; переходим на нужный экран, когда пользователь нажимает на кнопку; или показываем пользователю сообщение на экране с помощью Toast или Snackbar.
Логика UI, в особенности когда она затрагивает такие типы UI, как Context, должна находиться в UI, а не во ViewModel. Если UI приложения усложняется, и вам хочется делегировать логику UI другому классу, чтобы разделить ответственности и легче тестировать приложение, можно создать простой класс, который будет выполнять роль экземпляра state holder. Простые классы, созданные в UI, могут получать зависимости от Android SDK: их жизненный цикл ограничен жизненным циклом UI, в то время как объекты ViewModel живут дольше.
Подробнее о state holder и том, как он помогает построить UI, можно почитать в гайде «Jetpack Compose State».
Зачем использовать UDF
UDF моделирует цикл производства состояний, как показано на рисунке выше. Также он отделяет источник изменений состояния от места их трансформации и получения. Благодаря такому разделению UI может выполнять только те действия, которые следуют из его названия: отслеживать изменения состояния и сообщать о намерениях пользователя, передавая эти изменения во ViewModel, а в результате отображать информацию.
Другими словами, UDF позволяет добиться:
Согласованности данных. У UI есть только один источник правды.
Удобства тестирования. Источник состояния изолирован, а значит, его можно тестировать отдельно от UI.
Надёжности. Изменение состояния следует чётко заданному паттерну: изменения вызываются пользовательскими событиями и источниками данных, которые они запрашивают.
Как предоставлять доступ к UI-состоянию
После того, как вы описали UI-состояние и определили, как будете управлять его производством, следующий шаг – предоставить UI доступ к готовому состоянию.
Так как вы управляете производством состояния с помощью UDF, рекомендуется сделать производимое состояние потоком — другими словами, в процессе работы программы будет произведено несколько версий этого состояния. В таком случае доступ к UI-состоянию следует предоставить с помощью observable-обёртки над данными, к примеру LiveData или StateFlow . Это нужно сделать, чтобы UI мог реагировать на все изменения, внесённые в состояние, и вам не приходилось вручную получать данные прямо из ViewModel.
Ещё одно преимущество этих типов в том, что они всегда кэшируют последнюю версию UI-состояния, что очень удобно, если вам нужно быстро восстановить состояние после изменения конфигураций.
Вводную информацию об использовании LiveData в качестве observable-обёртки над данными можно прочитать в этом кодлабе. Вводную информацию о flow в Kotlin — в статье «Flow в Kotlin на Android».
Важно: чтобы предоставить доступ к UI-состоянию в приложениях с Jetpack Compose, можно использовать его родные observable State API, например mutableStateOf или snapshotFlow. Все указанные в этом гайде типы observable-обёрток над данными, такие как StateFlow или LiveData, легко можно использовать в Compose с помощью соответствующих extension функций.
Если данные, доступ к которым открыт для UI, относительно просты, их чаще всего стоит обернуть в тип UI-состояния: он сообщает программе, как взаимосвязаны отправленный экземпляр state holder и соответствующий экран или элемент UI. Когда элемент UI станет сложнее, проще будет внести в описание UI-состояния дополнительную информацию, если она потребуется для отрисовки элемента UI.
Поток данных UiState зачастую создают, предоставляя из ViewModel доступ к приватному изменяемому потоку как к неизменяемому: например, доступ к MutableStateFlow как к StateFlow .
Благодаря этому ViewModel сможет предоставлять доступ к методам, которые изменяют состояние изнутри, публикуя обновления, которые получит UI. Возьмём, к примеру, кейс, в котором нужно выполнить асинхронное действие. В этом случае можно запустить корутину с помощью viewModelScope, а по завершении обновить изменяемое состояние.
В примере выше класс NewsViewModel пытается составить выборку статей определённой категории. Затем отражает результаты попытки — удачные или неудачные — в UI-состоянии, на которое UI реагирует соответствующим образом. Чтобы узнать больше о работе с ошибками, прочитайте раздел «Отображение ошибок на экране».
Важно: шаблон из предыдущего примера, где состояние изменяют посредством функций во ViewModel, — одна из наиболее популярных реализаций UDF.
Дополнительные рекомендации
Предоставляя доступ к UI-состоянию, следует учитывать:
Объект UI-состояния должен обрабатывать состояния, которые связаны друг с другом. В таком случае неконсистентность будет возникать реже, а код будет проще понять. Если открыть доступ к списку статей и количеству сохранённых статей в двух разных потоках данных, можно оказаться в ситуации, где одни данные обновились, а другие – нет. Если использовать один поток, оба элемента будут содержать актуальные данные.
Более того, в некоторых случаях бизнес логика может потребовать слияния источников. К примеру, вам может потребоваться показывать кнопку «добавить в закладки» только если пользователь зарегистрировался и подписан на премиум услугу. В таком случае класс UI-состояния можно описать следующим образом:
В этом описании наличие или отсутствие кнопки «добавить в закладки» — это свойство, унаследованное от двух других свойств. Чем сложнее становится бизнес-логика приложения, тем удобнее иметь один единственный класс UiState, где в вашем распоряжении есть все свойства.
UI-состояния: один поток или несколько? Если вы выбираете, открыть доступ к UI-состоянию в одном потоке или в нескольких, основным ориентиром должен стать предыдущий пункт: связь между отправляемыми элементами.
Основной плюс одного потока – удобство и консистентность данных: получатели состояния всегда принимают наиболее актуальную информацию. Однако бывают случаи, когда удобнее получать из ViewModel несколько потоков состояний:
Типы данных, не связанные друг с другом. Некоторые состояния, необходимые для отрисовки UI, могут абсолютно не зависеть друг от друга. В таких случаях результат объединения различных состояний может не оправдать затраченных ресурсов, в особенности если одно из состояний обновляется чаще второго.
Сравнение UiState. Чем больше в объекте UiState полей, тем вероятнее, что поток будет отправлять данные при обновлении одного из его полей. View обновляется в каждом случае: у него нет механизма сравнения, с помощью которого можно было бы понять, отличаются отправляемые друг за другом данные или нет. В такой ситуации для решения проблемы вам могут потребоваться Flow API или методы типа distinctUntilChanged() на LiveData .
Как принимать UI-состояния
Чтобы UI начал получать UiState из потока, используется терминальный оператор, соответствующий конкретному observable-типу данных в коде. Например, в случае с LiveData используется метод observe(), а в случае с flow в Kotlin – метод collect() или его вариации.
Когда запускаете получение observable-обёрток над данными в UI, убедитесь, что учли жизненный цикл UI. Это важно: UI не должен отслеживать UI-состояние, когда view пользователю не показывают.
Подробнее тему можно изучить в посте «A safer way to collect flows from Android UIs». Когда используется LiveData , LifecycleOwner сам решает вопрос жизненных циклов. В случае с flow лучше использовать соответствующий coroutine scope и repeatOnLifecycle API:
Важно: объекты StateFlow, использованные в этом примере, не прекращают работу, когда у них нет активных получателей данных. Но, работая с flow, вы можете и не знать, как они реализованы. Получение данных flow с учётом жизненного цикла позволяет вносить подобные изменения в потоки ViewModel позднее — без необходимости дорабатывать код получателя данных.
Как отображать прогресс выполнения операций
Проще всего отобразить состояние загрузки в классе UiState с помощью поля boolean:
Значение этого флага указывает на наличие или отсутствие прогресс-бара в UI.
Отображение ошибок на экране
Показывать ошибки в UI – то же самое, что показывать прогресс выполнения операции: оба случая легко представить с помощью Boolean-значения, которое обозначает наличие или отсутствие чего-либо.
Однако иногда в случае ошибки нужно передать пользователю сообщение или выполнить действие, которое перезапускает невыполненную операцию. Поэтому в отличие от операций, которые находятся в процессе выполнения и либо загружаются, либо не загружаются, чтобы смоделировать состояния ошибки, могут потребоваться классы данных, в которых можно держать метаданные, соответствующие контексту определённой ошибки.
Возьмём пример из предыдущего раздела, где в процессе поиска статей отображался прогресс-бар. Если в результате операции возникнет ошибка, вы, возможно, захотите показать пользователю сообщение «что пошло не так».
В таком случае сообщения об ошибке можно представить пользователю в виде таких элементов UI, как Snackbar. Так как этот пункт связан с производством и получением событий UI, вы можете почитать главу события UI, чтобы изучить тему подробнее.
Потоки и параллелизм
Любая операция, которую мы выполняем в ViewModel, должна быть main-safe: вызывать её из главного потока должно быть безопасно. За переключение работы в другой поток отвечают слои данных и предметной области.
Если ViewModel выполняет продолжительные операции, она также отвечает за перемещение этой логики в фоновый поток. Корутины в Kotlin – отличный способ управления параллельными операциями, а Jetpack Architecture Components предоставляют встроенную поддержку корутин. Вы можете подробнее узнать о том, как использовать корутины в приложениях на Android, если прочитаете статью «Корутины Kotlin в Android».
Навигация
Изменения в навигации приложения зачастую обусловлены получением событий или подобных им сущностей. К примеру, после того, как класс SignInViewModel выполняет регистрацию, поле isSignedIn в UiState может иметь значение true . Такие триггеры следует принимать так же, как и описанные в предыдущем разделе Как принимать UI-состояния. Единственное исключение состоит в том, что получатель состояния следует реализовать, полагаясь на Navigation component.
Пагинация
Paging library передает на UI-класс PagingData . Так как PagingData представляет и содержит элементы, которые могут меняться с течением времени (то есть не относятся к неизменяемому типу), его не следует представлять в неизменяемом UI-состоянии. Логичнее будет предоставить к нему доступ из ViewModel в отдельном потоке. Конкретные примеры можно посмотреть в кодлабе Android Paging.
Анимации
Чтобы анимации переходов были качественными и плавными, иногда приходится подождать, пока загрузятся данные со следующего экрана, прежде чем стартовать анимацию. В Android view фреймворке есть методы, с помощью которых можно отсрочить переход между фрагментами с postponeEnterTransition() API и startPostponedEnterTransition() API. Эти API помогают убедиться, что элементы UI на следующем экране (как правило, изображения, которые загружаются из сети) готовы к отрисовке, прежде чем UI анимирует переход на этот экран. Подробную информацию и способы применения вы найдёте в Android Motion sample.