Отправка событий ViewModel в интерфейс Android

В мире мобильной разработки изменения происходят регулярно, появляются возможности сделать код короче и красивее, а сам процесс разработки менее трудоёмким. Появился Jetpack Compose, упрощающий написание и обновление визуального интерфейса приложения, предоставляя разработчикам декларативный подход. Google выпустил ряд рекомендаций относительно различных схем связи, которые должно использовать приложение, и способа передачи данных между пользовательским интерфейсом и моделью представления.

 

В этой статье мы сосредоточимся на новом руководстве Google в отношении схемы связи для однократных действий между моделью представления Android и связанным с ней представлением.

Под капотом

Сразу уточним что будем определять событие как уведомление о необходимости выполнить одно определённое действие и только один раз. Некоторые разработчики используют термин «побочный эффект» (side effect). Google использует термин «событие ViewModel», чтобы отличить его от события пользовательского интерфейса, похожего на свайп или клик.

 

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

 

Для целей этой статьи будет использоваться шаблон проектирования MVVM, в котором модель представления отображает состояние пользовательского интерфейса по образцу поведенческого шаблона проектирования Наблюдатель (Observer). Мы хотим показать состояние пользовательского интерфейса как StateFlow в модели представления. Например,

 

class MyViewMode: ViewModel() {
    data class ViewState(
        val someUIProperty: String = "",
        val someOtherUIProperty: Int = 1,
    )
    
    private val _viewState = MutableStateFlow<ViewState>(ViewState())
    val viewState = _viewState.asStateFlow()
}

Некоторые разработчики предпочитают LiveData, но, независимо от вашего выбора, состояние UI предоставляется ViewModel как наблюдаемое свойство, которое может наблюдаться представлением в любом удобном состоянии жизненного цикла. Возможно, что-то вроде этого:

 

// In your view/fragment
viewLifecycleOwner.lifecycleScope.launch {
    viewModel.viewState
        .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
        .collect { 
            // do something with the UI updates
        }
}

 

Также можно реализовывать события, используя канал, полученный в виде потока (именно этот канал событий мы собираемся удалить/обновить). Например:

 

// In your view model
private val _eventChannel = Channel<Event>(Channel.BUFFERED)
val events = _eventChannel.receiveAsFlow()

 

Требования к событиям

Изменились ли требования к событиям в связи с новым руководством Google?

 

Предположительно, события важны, даже критические. К старым требованиям о событии и его наблюдателе добавилось лишь одно неявное: события можно наблюдать только один раз.

  • Новые события по-прежнему не могут перезаписывать ненаблюдаемые события.
  • Если наблюдателя нет, события должны буферизоваться до тех пор, пока наблюдатель не начнёт их использовать.
  • Представление может иметь важные состояния жизненного цикла, в течение которых оно может только безопасно наблюдать за событиями. Таким образом, наблюдатель не всегда может быть активным или потреблять поток в данный момент времени.

Новое руководство Google

Попробуем разобраться, как мы будем работать с событиями, которые необходимо наблюдать один раз, следуя только новым указаниям Google?

 

Новое руководство Google вы можете найти здесь:

 

Руководство на удивление короткое и простое:

  • События должны приводить к обновлению пользовательского интерфейса и, следовательно, должны создаваться вместе со всеми другими обновлениями пользовательского интерфейса (то есть события являются частью потока обновлений пользовательского интерфейса, а не отдельным потоком).
  • Использование событий может инициировать обновления состояния пользовательского интерфейса.
  • Представление отвечает за уведомление ViewModel о том, что событие было обработано.

Вот и всё! Теперь давайте посмотрим, как это работает.

 

Сначала посмотрим, как Google определяет события в своём руководстве.

 

data class UserMessage(val id: Long, val message: String)

data class LaatestNewsUiState(
         val news: List<News> = emptyList,
          val isLoading: Boolean = false,
          val user Messages: List<UserMessage> = emptyList()
}

 

В приведенном выше примере userMessages свойство состояния пользовательского интерфейса — это набор временных сообщений, которые будут отображаться в пользовательском интерфейсе, например, в виде всплывающего уведомления или сообщения внизу экрана. В примере Google они добавляют значение в эту коллекцию, когда хотят, чтобы сообщение (событие) отображалось в пользовательском интерфейсе, и удаляют значение из этой коллекции, когда сообщение (событие) было отображено.

 

_uistate.update { currentUiState ->
        val messages = currentUiState.user Messages + User Message(
               id - UUID. randomUUIDO.most significant Bits, 
              message = "No Internet connection"
       )
        currentUiState.copy (user Messages = messages)
}

 

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

 

lifecycleScope. launch {
        repeatOnLifecycle(Lifecycle. State. STARTED) {
               viewModel.uiState.collect { uiState -> 
                     uiState.user Messages. firstOrNull()?.let { user Message ->
                            viewModel.user Message Shown(userMessage.id)
                     }
              }
       }
}

 

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

 

fun user Message Shown(messageId: Long) {
       _uiState.update { currentUiState ->
              val messages = currentUiState.user Messages.filterNot { it.id == messageId }  currentUiState.copy (user Messages = messages)
       }
}

 

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

 

Первое требование «Новые события не могут перезаписывать ненаблюдаемые события» выполняется за счёт использования списка (а не набора) для хранения сообщений.

 

Второе требование «Если нет наблюдателя, события должны буферизоваться до тех пор, пока наблюдатель не начнёт их использовать» удовлетворяется использованием состояния пользовательского интерфейса, сохраняемого чем-то вроде StateFlow или LiveData. Если нет наблюдателей, список событий будет просто расти, так что здесь нет никаких проблем.

 

Третье требование «Представление может иметь важные состояния жизненного цикла, во время которых оно может только безопасно наблюдать за событиями» также удовлетворяется тем, что наблюдатель собирает состояния пользовательского интерфейса только между запуском и остановкой с помощью repeatOnLifecycle помощника.

 

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

 

Несколько важных заметок

Всё идёт нормально? Просто создайте список событий в состоянии пользовательского интерфейса, наблюдайте за этим списком и сообщайте модели представления, когда эти события были обработаны. Это кажется очевидным, но о чём ещё мы не вспомнили?

Уникальные идентификаторы

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

Вероятно, это можно упростить, используя запечатанный класс или что-то, что автоматически генерирует идентификатор, например, так:

 

sealed class Event { val uniqueId: String = UUID.randomUUID().toString() data class MessageEvent(val message: String): Event() object MarkerEvent: Event() }

Занятый цикл

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

 

Даже при использовании update функции нужно знать, как она работает внутри. Очень легко ошибиться и удалить ненаблюдаемые события, если вы не будете осторожны, используя его везде, где обновляете состояние пользовательского интерфейса.

 

Обязательная функция обратного вызова

Последнее замечание касается определения события, которое следует наблюдать один раз. Представление должно информировать модель представления о том, что событие было обработано. Это возлагает на него ответственность за то, чтобы события действительно были событиями представления. Без него весь поток распадается, и события перестают быть единичными наблюдаемыми событиями.

 

Это то, что не обязательно при отправке событий с использованием канала. Когда значение наблюдается, оно автоматически удаляется из канала. (Но у этого шаблона есть свои скрытые требования.)

Так лучше?

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

 

Идея уведомления «вверх» в значительной степени соответствует шаблону однонаправленного потока данных, который очень хорошо работает с компоновкой. Повторяется та же схема связи, что позволяет разработчику сосредоточиться на более важных вопросах.

 

«Плохая» часть нового руководства Google — небольшие скрытые требования, необходимые для того, чтобы всё это работало и потребность в том, чтобы представление уведомляло модель представления. Без функции обратного вызова модели представления событие не является событием. Его можно наблюдать многократно. Конечно, это не такая уж большая проблема, но надо быть внимательным, чтобы не допустить ошибку.

 

В конце руководства у Google есть важно примечание:

«В некоторых приложениях вы могли видеть, что события VewModel отображаются в пользовательском интерфейсе с помощью каналов Kotlin или других реактивных потоков. Как правило, эти решения требуют обходных путей, таких как обёртывание событий, чтобы убедиться, что события не будут потеряны и будут использованы только один раз.

 

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

 

На наш взгляд, в решении Google существует столько же обходных путей (необходимое использование уникального идентификатора для каждого события, защита, необходимая для набора событий, и требование, чтобы представление уведомляло модель представления о том, когда событие было обработано), как и в других решениях, например, использовать каналы или обёртывание событий, или их комбинацию. Так лучше ли это, в конце концов? В целом, да, в контексте однонаправленного потока данных и Compose.

 

Шаблон Google немного проще для понимания и обработки. В этом, конечно, меньше «магии», чем в использовании канала или потока для определения того, было ли событие использовано. Но и он не лишён собственных причуд.

 

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

Posted by:

Mobile News

Back to Top