Однонаправленный поток данных в пользовательском интерфейсе Android

Вступление

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

interface UiEventsProcessor<E : UiEvent<A, S>, A : Action<S>, S : State> {
    
    val getPreviousState: () -> S
    val events: Channel<E>

    suspend fun Channel<E>.consumeAsStatesFlow(collectHandler: (state: S) -> (Unit)) {
        this.consumeAsFlow()
            .toAction()
            .toState()
            .distinctUntilChanged()
            .collect { collectHandler(it) }
    }

    suspend fun dispatchEvent(event: E) {
        events.send(event)
    }

    private fun Flow<E>.toAction(): Flow<A> = this.flatMapMerge { event ->
        event.toAction()
    }

    private fun Flow<A>.toState(): Flow<S> = this.flatMapMerge { action ->
        action.perform(getPreviousState)
    }
}

Обработка события/публикация состояния

В этом случае мы используем 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.

Есть несколько операций преобразования потока, чтобы гарантировать правильную последовательную и четкую передачу результирующих состояний, которые будут собраны в лямбда-параметре.

interface UiEventsProcessor<E : UiEvent<A, S>, A : Action<S>, S : State> {
    
    val getPreviousState: () -> S
    val events: Channel<E>

    suspend fun Channel<E>.consumeAsStatesFlow(collectHandler: (state: S) -> (Unit)) {
        this.consumeAsFlow()
            .toAction()
            .toState()
            .distinctUntilChanged()
            .collect { collectHandler(it) }
    }

    suspend fun dispatchEvent(event: E) {
        events.send(event)
    }

    private fun Flow<E>.toAction(): Flow<A> = this.flatMapMerge { event ->
        event.toAction()
    }

    private fun Flow<A>.toState(): Flow<S> = this.flatMapMerge { action ->
        action.perform(getPreviousState)
    }
}

Необходимо указать предыдущее состояние, поскольку этот конечный автомат не отслеживает предыдущие состояния и делегирует эту функциональность своей реализации.

Поскольку конечный автомат является интерфейсом, нет смысла инициализировать канал (Channel), поскольку он должен быть переопределен этой реализацией.

Выбор использования interface обусловлен тем, что эта реализация не ограничена приложениями Android, а в примере кода из проекта интерфейс объявлен только в модуле Kotlin.

Следующие шаги описывают реализацию этого конечного автомата и его компонентов в модуле Android. В примере проекта модуль presentation, включающий interfaceконечного автомата, а также базовый и конкретный компоненты, включен в модуль приложения Android как зависимость.

Абстрактная модель представления

Логически подходящим местом для реализации абстрактных частей конечного автомата является ViewModel, потому что у нас вызывается несколько приостанавливающих функций, а ViewModel предоставляет viewModelScope, где безопасно выполнять эти приостанавливающие методы, поскольку они напрямую привязаны к жизненному циклу ViewModel.

Однако, если мы собираемся повторно использовать реализацию ViewModel, то можно также создать абстрактную модель представления abstract ViewModelи обрабатывать специфику каждого варианта сценария в дочерних ViewModel. Посмотрите, как объявляется abstract ViewModel:

abstract class UniDirectionalFlowViewModel<E : UiEvent<A, S>, A : Action<S>, S : State> :
    ViewModel(), UiEventsProcessor<E, A, S> {

    protected abstract val initialState: S

    private val stateLiveData: MutableLiveData<S> = initializeStateLiveData()

    final override val getPreviousState: () -> S
        get() = { stateLiveData.requireValue() }

    final override val events: Channel<E> = Channel()

    init {
        startEventsProcessing()
    }

    private fun startEventsProcessing() {
        viewModelScope.launch {
            events.consumeAsStatesFlow {
                stateLiveData.value = it
            }
        }
    }

    protected fun E.dispatch() {
        viewModelScope.launch { dispatchEvent(this@dispatch) }
    }

    private fun initializeStateLiveData(): MutableLiveData<S> =
        MutableLiveData(initialState)

    private fun MutableLiveData<S>.requireValue() = requireNotNull(value) { "State is null!" }

    fun observe(owner: LifecycleOwner, observingBlock: (S?) -> Unit): Observer<S> =
        Observer<S>(observingBlock).also { stateLiveData.observe(owner, it) }
}

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.

sealed class MainAction : Action<MainState> {
    protected val delayMillis: Long
        get() = loadingSecondsRange.random().let { TimeUnit.SECONDS.toMillis(it) }

    data class SendSuccess(private val message: String) : MainAction(), SuccessOperationSimulator {

        override suspend fun perform(getPreviousState: () -> MainState): Flow<MainState> = flow {
            emit(MainState.Loading)
            val newMessage = getPreviousState().getNewMessage()
            emit(MainState.Complete(result = newMessage))
        }

        /**
         * Симулирует длительную успешную асинхронную операцию 
         */
        private suspend fun MainState.getNewMessage(): String {
            delay(delayMillis)
            return this.let { it as? MainState.Complete }?.result.appendMessageAbove(message)
        }
    }

    object SendError : MainAction(), ErroneousOperationSimulator {
        override suspend fun perform(getPreviousState: () -> MainState): Flow<MainState> = flow {
            emit(MainState.Loading)
            // Симулирует длительную неуспешную асинхронную операцию
            delay(delayMillis)
            val exception = getException()
            emit(MainState.Error(throwable = exception))
        }
    }

    private companion object {
        val loadingSecondsRange: LongRange = 1L..5L
    }
}

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

class MainActivity : BaseActivity<ActivityMainBinding>() {

    private val viewModel: MainActivityViewModel by viewModels()

    override fun inflateBinding(): ActivityMainBinding = ActivityMainBinding.inflate(layoutInflater)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel.observe(this) { it?.render() }
        binding.errorButton.setOnClickListener { viewModel.sendError() }
        binding.eventsButton.setOnClickListener {
            viewModel.sendSuccess(
                Random.nextLong().toString()
            )
        }
    }

    private fun MainState.render() {
        when (this) {
            is MainState.Loading -> renderLoading()
            is MainState.Error -> render()
            is MainState.Complete -> render()
            else -> return
        }
    }

    private fun renderLoading() {
        binding.progressBar.isVisible = true
        switchButtonState(false)
    }

    private fun MainState.Error.render() {
        binding.progressBar.isGone = true
        Snackbar.make(
            binding.root,
            getMessage(),
            Snackbar.LENGTH_LONG
        ).onDismissed {
            switchButtonState(true)
        }.show()
    }

    private fun MainState.Error.getMessage(): String =
        this.throwable?.message ?: getString(R.string.unknown_error)

    private fun MainState.Complete.render() {
        showAlertDialog(result)
        binding.progressBar.isGone = true
        switchButtonState(true)
    }

    private fun switchButtonState(isEnabled: Boolean) {
        binding.apply {
            eventsButton.isEnabled = isEnabled
            errorButton.isEnabled = isEnabled
        }
    }
}

Код операции (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). Сложность использующих эту функцию тестов заключается в определении начального и ожидаемого состояний.

Ниже приведены примеры очень упрощенного подхода к реализации типового проекта, но в тестах для рабочего приложения использован вариант описанного выше метода.

@Test
    fun whenSendSuccessIsSentWithInitialStateResultingStateIsSuccess() {
        val eventMessage = "Hello world"
        val expectedCompleteMessage =
            successOperationSimulator.getMessage(eventMessage)
        val expectedFirstState = MainState.Loading
        val expectedFinalState = MainState.Complete(result = expectedCompleteMessage)

        MainEvent.SuccessRequest(message = eventMessage).dispatchAndAssertStateTransitions(
            initialState = MainState.Initial, expectedFirstState, expectedFinalState
        )
    }

    @Test
    fun whenSendErrorIsSentWithInitialStateResultingStateIsSuccess() {
        val expectedThrowable = erroneousOperationSimulator.getException()
        val expectedFirstState = MainState.Loading
        val expectedFinalState = MainState.Error(throwable = expectedThrowable)

        MainEvent.ErrorRequest.dispatchAndAssertStateTransitions(
            initialState = MainState.Initial, expectedFirstState, expectedFinalState
        )
    }

    @Test
    fun whenSendSuccessIsSentAfterPreviousCompleteBothMessagesAreAppended() {
        val previousMessage = "Message: first message 😀"
        val eventMessage = "second Message 💪"
        val expectedCompleteMessage =
            successOperationSimulator.getMessage(
                previousMessage = previousMessage,
                newMessage = eventMessage
            )
        val expectedFirstState = MainState.Loading
        val expectedFinalState = MainState.Complete(result = expectedCompleteMessage)

        MainEvent.SuccessRequest(message = eventMessage).dispatchAndAssertStateTransitions(
            initialState = MainState.Complete(result = previousMessage), expectedFirstState, expectedFinalState
        )
    }

В этом примере successOperationSimulator и erroneousOperationSimulator, инкапсуляции моделируемой логики функционирования используемого приложения, генерируют конечные и более сложные ожидаемые состояния. Очевидно, что для грамотного тестирования инкапсуляции подобного типа должны быть предусмотрены как для тестовых, так и для реальных модулей приложения, но это уже другая тема.

Заключение

Не существует универсальных решений при реализации приложений Android. Есть лишь несколько эффективных подходов к отдельным сложным частям приложения. Следует отметить, что при реконструкции приложения компании HSE очень хорошо сработало специфическое разделение состояний пользовательского интерфейса, что позволило обеспечить контроль над той частью кода приложения, которая всегда создает проблемы при коллективной разработке.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Julio Mendoza: Unidirectional Data Flow for Android UIs

Предыдущая статьяСоздаем приложение React с нуля в 2021 году
Следующая статьяРабота с HTML и CSS: 10 полезных приемов для дизайнеров