Источник: Nuances of Programming
Вступление
Рассмотренные в этой статье идеи основаны на лучших описаниях особенностей реализации приложений Android, которые были успешно проверены на практике при создании реального приложения для онлайн торговли.
Одной из основных задач команды разработчиков Android в немецкой компании HSE было обновление старого приложения. Требовалось заменить исходный код. Это задачка из разряда повторяющихся временами кошмаров для разработчика. При изучении старого кода сразу возникла пара вопросов, которыми я задавался и раньше:
- Как описать в понятной форме все возможные реакции на ввод данных пользователем, чтобы исходный код программы был легко читаемым, надежным и адаптируемым?
- Как избежать нескольких флагов в Activities и Fragments, которые визуально определяют пользовательский интерфейс (UI) в данный момент времени?
Эти задачи были решены с помощью фрагментов кода, созданных на основе изучения специальной литературы.
Для точного отображения информации и реагирования на входные воздействия от пользователей есть несколько способов. Платформа Android развивалась экспоненциальными темпами во всех направлениях, свидетельством этому является библиотека Lifecycle, в частности шаблоны ViewModels и LiveData.
Шаблоны ViewModels часто используются в качестве прямых посредников между событиями в ПИ и источниками данных из-за их тесной связи с Lifecycle (жизненным циклом) Fragments и Activities хоста.
При всех достоинствах шаблонов ViewModels, которые принимают вводимые пользователем данные и предоставляют новые данные как результат взаимодействия, сгенерированный код часто бывает неупорядоченным, его трудно преобразовать в четко дифференцированные состояния, которые должен иметь UI. Типичное взаимодействие с пользователем в приложении Android часто выглядит таким образом:
- Пользователь переходит к Fragment или Activity.
- Данные начинают загружаться из какого-либо источника.
- На экране демонстрируется индикатор выполнения.
- Данные загружены.
- Выполняется обновление (рендеринг) UI.
- Пользователь взаимодействует с элементами интерфейса в компоненте view.
- Ввод при взаимодействии (прямом или косвенном) связан с источником данных для управления и получения новой информации.
- На экране демонстрируется индикатор выполнения … .
И так далее, и тому подобное. Из этого перечня легко выделить общую закономерность. И она является настолько стандартной, что именно по этой причине существуют модели LCE (Life Cycle Engineering).
Компоненты
Однонаправленный цикл взаимодействия с пользователем, получение данных и рендеринг информации позволяют моделировать 3 основных элемента.
- State (Состояние): Представляет собой отдельное состояние пользовательского интерфейса. Оно может быть коротким или длительным.
- Action (Действие): Реализует метод, который принимает: вводимые данные от события и содержимое предыдущего состояния, затем комбинирует их в другое состояние с помощью некоторой функциональной логики.
- UI Event (Событие UI): Представляет прямой или косвенный ввод от пользователя.
Обратите внимание на порядок этих компонентов в списке. Как правило, предпочтительным вариантом разработки является реализация в указанной выше последовательности.
Также важное значение имеет модификатор suspend функции perform, указывающий на асинхронное выполнение транзакции.
Обработка события/публикация состояния
В этом случае мы используем Channel, который отправляет UI Events (события UI), воспринимаемые как Flow, затем они могут быть преобразованы сначала в Action Flow, потом в State Flow, выполняя каждое происходящее действие. Этот результирующий Flow может накапливаться для изменения UI в соответствии с обновленным состоянием.
Механизм конечного автомата состоит из функции расширения на поле UI Events Channel. Кратко сформулировать реализацию можно следующим образом:
- Канал событий (Channel) воспринимается, как поток (Flow).
- Преобразуйте поток событий (Events Flow) в поток действий (Actions Flow), вызывая toAction() для каждого исполненного события.
- Преобразуйте поток действий (Actions Flow) в поток состояний (States Flow), вызывая perform() для каждого исполненного действия.
- Соберите полученные состояния, передав каждое из них в лямбда-параметр collectionHandler.
Есть несколько операций преобразования потока, чтобы гарантировать правильную последовательную и четкую передачу результирующих состояний, которые будут собраны в лямбда-параметре.
Необходимо указать предыдущее состояние, поскольку этот конечный автомат не отслеживает предыдущие состояния и делегирует эту функциональность своей реализации.
Поскольку конечный автомат является интерфейсом, нет смысла инициализировать канал (Channel), поскольку он должен быть переопределен этой реализацией.
Выбор использования interface обусловлен тем, что эта реализация не ограничена приложениями Android, а в примере кода из проекта интерфейс объявлен только в модуле Kotlin.
Следующие шаги описывают реализацию этого конечного автомата и его компонентов в модуле Android. В примере проекта модуль presentation, включающий interfaceконечного автомата, а также базовый и конкретный компоненты, включен в модуль приложения Android как зависимость.
Абстрактная модель представления
Логически подходящим местом для реализации абстрактных частей конечного автомата является ViewModel, потому что у нас вызывается несколько приостанавливающих функций, а ViewModel предоставляет viewModelScope, где безопасно выполнять эти приостанавливающие методы, поскольку они напрямую привязаны к жизненному циклу ViewModel.
Однако, если мы собираемся повторно использовать реализацию ViewModel, то можно также создать абстрактную модель представления abstract ViewModelи обрабатывать специфику каждого варианта сценария в дочерних ViewModel. Посмотрите, как объявляется abstract ViewModel:
LiveData используется здесь для размещения состояний, полученных после управления событиями. Новые состояния делаем доступными в observingBlock функции observe, объявленной в нижней части класса.
Для работы конечного автомата нужно предоставить начальное состояние, так как оно необходимо для выполнения действий (actions). Оно было выбрано вместо значения nullable для метода perform() в интерфейсе Action. Вследствие цикличности применяемой здесь парадигмы, из состояния могут быть запущены некоторые события, конвертируемые в состояния посредством трансформирующих действий, и получая опциональный вывод предыдущего состояния. Состояние nullпросто не имеет значения в этом цикле.
Реализации
Когда все необходимые для работы конечного автомата элементы сформированы, нужно просто добавить конкретные реализации.
Компоненты реализации
Используем запечатанные классы, чтобы представить на простом примере проекта различные состояния, действия и события. Поскольку такие классы поддерживают вложенность, можно использовать любую специфику приложений.
Состояния (States)
sealed class MainState : State {
object Initial: MainState()
object Loading : MainState(), State.Loading
data class Complete(override val result: String) : MainState(), State.Complete<String>
data class Error(override val throwable: Throwable? = null) : MainState(), State.Error
}
События (Events)
Это простые контейнеры вводов от пользовательского интерфейса в качестве параметров.
sealed class MainEvent : UiEvent<MainAction, MainState> { data class SuccessRequest(private val message: String) : MainEvent() { override suspend fun toAction(): Flow<MainAction> =
MainAction.SendSuccess(message = message).toFlow()
}
object ErrorRequest : MainEvent() {
override suspend fun toAction(): Flow<MainAction> = MainAction.SendError.toFlow()
}
}
Действия (Actions)
Как видите, между событием и действием есть полное соответствие, что имеет значение, поскольку мы хотим, чтобы пользовательское событие запускало некоторое преобразование и получало некоторое событие, содержащее всю обновленную, отображаемую в UI информацию.
Actions, как правило, оказываются большими по размеру, поскольку они связаны с выполнением логики функционирования и принимают предыдущие и вводимые данные из событий в качестве параметров для внутренних транзакций. Но с появлением Kotlin 1.5 это уже не является проблемой, потому что классам sealed разрешена реализация в не связанных с их объявлением файлах.
Здесь мы имитируем логику функционирования приложения, вызывая случайную задержку для действий SendSuccess и SendError.
ViewModel
Теперь, когда компоненты готовы, можно реализовать ViewModel, позволяющую объявить начальное состояние и отправить оба события.
class MainActivityViewModel : UniDirectionalFlowViewModel<MainEvent, MainAction, MainState>() {
override val initialState: MainState
get() = MainState.Initial
fun sendError() {
MainEvent.ErrorRequest.dispatch()
}
fun sendSuccess(message: String) {
MainEvent.SuccessRequest(message = message).dispatch()
}
}
MainActivity
Код операции (activity) теперь сводится к инициализации элементов макета и наблюдению за изменением состояния, связанным с оператором when.
Таким образом, логика рендеринга фрагментов и действий может быть изолирована от текущего обрабатываемого состояния. Не потребуется никаких флагов.
Тестирование
Как убедиться в корректной генерации состояния после завершения события? Мы создали процесс тестирования, который можно описать следующим образом:
- Определите начальное или предыдущее состояние.
- Определите ожидаемые результирующие состояния.
- Отправьте событие для тестирования.
- Преобразуйте его в действие и выполните.
- Подтвердите равенство генерированных состояний с ожидаемыми.
Смотрите реализацию:
internal inline fun <reified S : State, A : Action<S>, E : UiEvent<A, S>> E.dispatchAndAssertStateTransitions(
initialState: S,
vararg expectedStates: S
) {
runBlocking {
toAction()
.flatMapConcat { it.perform { initialState } }
.collectIndexed { index, actualState ->
assertEquals(expectedStates.getOrNull(index), actualState)
}
}
}
Реализация данной функции представляется очень знакомой, потому что это минимизированная версия UiEventsProcessor.
Эта функция оценит точность совпадения между ожидаемыми состояниями и генерируемыми после вызова функции perform() на Action (действие), вызванное отправленным событием UI(UI Event). Сложность использующих эту функцию тестов заключается в определении начального и ожидаемого состояний.
Ниже приведены примеры очень упрощенного подхода к реализации типового проекта, но в тестах для рабочего приложения использован вариант описанного выше метода.
В этом примере successOperationSimulator и erroneousOperationSimulator, инкапсуляции моделируемой логики функционирования используемого приложения, генерируют конечные и более сложные ожидаемые состояния. Очевидно, что для грамотного тестирования инкапсуляции подобного типа должны быть предусмотрены как для тестовых, так и для реальных модулей приложения, но это уже другая тема.
Заключение
Не существует универсальных решений при реализации приложений Android. Есть лишь несколько эффективных подходов к отдельным сложным частям приложения. Следует отметить, что при реконструкции приложения компании HSE очень хорошо сработало специфическое разделение состояний пользовательского интерфейса, что позволило обеспечить контроль над той частью кода приложения, которая всегда создает проблемы при коллективной разработке.
Читайте также:
Перевод статьи Julio Mendoza: Unidirectional Data Flow for Android UIs