Привет, Хабр! Представляю вашему вниманию перевод статьи ViewModels and LiveData: Patterns + AntiPatterns автора Jose Alcerreca.

View и ViewModel


Распределение ответственностей


Типичное взаимодействие объектов приложения, построенное с помощью Архитектурных Компонентов:

image

В идеале ViewModel не должна ничего знать про Android. Это улучшает тестируемость и модульность, снижает кол-во утечек памяти. Основное правило — в Вашей ViewModel не должно быть импортов android.* (за исключением вроде android.arch.*). Это относится и к Presenter.
ViewModel (и Presenter) не должны знать о классах фреймворка Android

Условные операторы, циклы и общие решения должны проводиться в ViewModel или прочих слоях приложения, не в активити или фрагментах. View обычно не подвергается unit-тестированию (кроме случаев, когда используется Robolectric), поэтому чем меньше строчек кода — тем лучше. View должны только отображать данные и посылать действия пользователя в ViewModel (или Presenter). Этот паттерн называется Passive View.
Активити и фрагменты должны содержать минимум логики

Ссылки на View в ViewModel


Скоуп ViewModels отличается от скоупа активити или фрагмента. Тем временем, как ViewModel инициализирована и работает, активити может пройти через несколько состояний жизненного цикла. ViewModel может ничего не знать о том, что активити и фрагменты были уничтожены и созданы.

ViewModels сохраняется при изменениях конфигурации:

image

Передача ссылки на View (активити или фрагмент) в ViewModel является серьезным риском. Предположим, ViewModel запросила данные из сети, и они придут чуть позже. В этот момент ссылка на View может быть уничтожена или активити может больше не отображаться — это приведет к утечке памяти, а возможно и к крэшу приложения.
Избегайте ссылок на View в ViewModels.
Рекомендуемый способ коммуникации между ViewModel и View — использование observer pattern, при помощи LiveData или observable из других библиотек.

Observer Pattern (шаблон проектирования «Наблюдатель»)


image

Удобным способом дизайна презентационного слоя в Android является, когда View (активити или фрагмент) наблюдает (подписана на изменения в) ViewModel. Т.к. ViewModel не знает ничего про Android, она также не знает о том, как часто Android убивает View. У этого подхода есть несколько преимуществ:

  1. ViewModel сохраняются при изменении конфигурации, поэтому нет нужды в повторном запросе внешних источников данных (к примеру, базы данных или сетевого хранилища) при произведенном повороте устройства.
  2. Когда заканчивается какая-либо длительная операция, observable в ViewModel обновляются. Не важно, велось ли наблюдение за данными или нет. NPE не произойдет даже при попытке обновления несуществующего View.
  3. ViewModel не содержат ссылки на View, что снижает риск возникновения утечек памяти.

Типичные подписки от активити или фрагмента:

private void subscribeToModel() {
  // Observe product data
  viewModel.getObservableProduct().observe(this, new Observer<Product>() {
      @Override
      public void onChanged(@Nullable Product product) {
        mTitle.setText(product.title);
      }
  });
}
Вместо того, чтобы отсылать данные в UI, пусть UI наблюдает за изменениями в данных.

«Жирные» ViewModel


Разделение ответственностей — всегда хорошая идея. Если Ваш ViewModel содержит в себе слишком много кода или имеет слишком много ответственностей, подумайте о том, что возможно следует:

  • Переместить часть логики в Presenter, с тем же scope, что у ViewModel. Именно он будет связываться с другими частями приложения и обновлять LiveData в ViewModel.
  • Добавить слой Domain и адаптировать приложение под Чистую Архитектуру. Это облегчит проведение тестов и поддержку кода. Также это обычно приводит к тому, что большая часть логики не выполняется в главном треде. С примером Чистой Архитектуры можно ознакомиться в Architecture Blueprints.

Распределяйте ответственности, добавьте слой domain если требуется.

Использовать репозиторий для данных


Как видно в руководствах по архитектуре приложений, большинство приложений имеют несколько источников данных. Таких как:

  1. Удаленный: сеть или облако
  2. Локальный: база данных или файл
  3. Кэш

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

Если у вас несколько моделей данных и они достаточно сложны — имеет смысл добавления нескольких репозиториев.
Добавьте репозиторий данных в качестве едсинственной точки входа для данных

Обработка состояний данных


Представьте следующее: Вы подписаны на обновления LiveData, предоставленной ViewModel, которая содержит список для отображения. Как View сможет отличить загруженные данные от сетевой ошибки или пустого списка?

Вы можете предоставить доступ к LiveData через ViewModel. К примеру, MyDataState может содержать информацию о том, корректно ли загружаются данные, загрузились ли окончательно или загрузка была прервана.

image

Вы можете обернуть данные в клас, который содержит в себе состояния и другие метаданные (например, сообщение о ошибке). См. класс Resource в предоставленных примерах.
Expose информацию о состоянии данных, используя обертку или другой LiveData.

Сохраняя состояние активити


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

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

Для эффективного сохранения и восстановления состояния UI используйте комбинацию сохранения данных, onSaveInstanceState() и ViewModel.

Для примера см.: ViewModels: Persistence, onSaveInstanceState(), Restoring UI State and Loaders Events

События


Событие — это что-то, что произошло единожды. ViewModel предоставляет доступ к данным, но что насчет событий? К примеру, события навигации или показ сообщений в Snackbar являются действиями, которые выполняются лишь один раз.

Концепция События не вписывается в то, как LiveData хранит и воссоздает данные. Представим себе ViewModel с таким полем:

LiveData<String> snackbarMessage = new MutableLiveData<>();

Активити подписывается на изменения этой ViewModel, и когда ViewModel заканчивает операцию должно появиться сообщение:

snackbarMessage.setValue("Объект сохранен!");

Активити получает значение и показывает Snackbar. Вроде всё работает.

Не смотря на это, если пользователь повернет телефон — будет создана новая активити, которая также подпишется на изменения ViewModel. Когда произойдет подписка на изменения в LiveData, активити немедленно получит старое значение и сообщение отобразится снова!

Для того, чтобы решить эту проблему в одном из наших примеров мы создали класс SingleLiveEvent (класс наследуется от LiveData). Он посылает только те обновления, которые произошли после подписки на изменения. Также обратите внимание, что класс поддерживает только одного подписчика.
Для таких событий, как показ сообщений в Snackbar или навигации используйте observable вроде SingleLiveEvent.

Утечки памяти в ViewModels


Парадигма реактивного программирования отлично работает в Android, т.к. предоставляет удобную связь между UI и прочими слоями архитектуры приложения. LiveData является ключевым компонентом этой структуры, поэтому обычно Ваши активити и фрагменты будут подписаны на изменения инстансов LiveData.

То, как ViewModels будет сообщаться с прочими компонентами приложения — решать Вам, но остерегайтесь утечек памяти и пограничных состояний. В нижеприведенной диаграмме слой Presentation использует observer pattern и слой Data, который получает колбэки.
Observer pattern в UI и колбэки в слой Data:

image

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

image

Эта утечка не так важна, если ViewModel легкая или операция гарантированно завершится в течение короткого отрезка времени. Идеально ViewModel'и должны освобождаться во всех случаях, когда отсутствуют View, которые за ними наблюдают:

image

Этого можно достигнуть многими способами:

  • С помощью ViewModel.onCleared() Вы можете сообщить репозиторию о том, что он должен сбросить колбэк к ViewModel.
  • В репозитории Вы можете использовать WeakReference или Event Bus (использование обеих может проводиться некорректно и даже считается вредным).
  • Используйте LiveData для коммуникации между репозиторием и ViewModel также, как используете ее для коммуникации между View и ViewModel.
Учитывайте пограничные случаи, утечки памяти и то, как долгие операции может повлиять на инстансы в Вашей архитектуре.
Не помещайте в ViewModel логику, которая является критически важной для сохранения чистоты архитектуры или если она связана с данными. Каждый вызов, сделанный от ViewModel может быть последним.

LiveData в репозиториях


Чтобы избежать утечек в ViewModel и «ада колбэков», репозитории могут наблюдаться следующим образом:

image

Когда ViewModel очищена или когда жизненный цикл view прекратился, подписка очищается:

image

В этом способе есть одна тонкость: как подписаться к репозиторию от ViewModel, если нет доступа к LifecycleOwner? С помощью Трансформаций. Transformations.switchMap позволяет создавать LiveData, которая реагирует на изменения в других инстансах LiveData. За счет этого информация передается через observer жидненного цикла по цепочке:

LiveData<Repo> repo = Transformations.switchMap(repoIdLiveData, repoId -> {
        if (repoId.isEmpty()) {
            return AbsentLiveData.create();
        }
        return repository.loadRepo(repoId);
    }
);

Пример трансформации [ссылка]

В этом примере, когда триггер получает обновление, выполняется функция и результат каскадируется. Активити наблюдает за репозиторием и тот же LifecycleOwner будет использован для вызова repository.loadRepo(id).
Каждый раз, когда Вы думаете, что Вам нужен объект Lifecycle внутри ViewModel, использование Transformation скорее всего поможет этого избежать и решит проблему.

Наследуясь от LiveData


Самый частый вид использования LiveData это использование MutableLiveData в ViewModel представление их как LiveData для того, чтобы сделать их неизменяемыми для наблюдателей.

Если Вам нужно больше функционала, наследование от LiveData позволит узнать, присутствуют ли активные наблюдатели. К примеру, это может быть полезно, если вы хотите начать прослушивать сервис локации или сеносоры устройства.

public class MyLiveData extends LiveData<MyData> {

    public MyLiveData(Context context) {
        // Initialize service
    }

    @Override
    protected void onActive() {
        // Start listening
    }

    @Override
    protected void onInactive() {
        // Stop listening
    }
}

Когда не надо наследоваться от LiveData


Вы также можете использовать onActive() для начала какого-то сервиса, который загружает данные, но если у Вас для этого нет хорошей причины, Вам не надо ждать, пока LiveData начнет наблюдаться. Самые распространненные паттерны:

Добавьте метод start() в ViewModel и вызовите его как можно скорее [см. пример с Blueprints]
Назначьте поле, которое прекращает загрузку [см. пример GithubBrowserExample].
Обычно наследование от LiveData не происходит. Пусть активити или фрагмент подскажет ViewModel когда начать загружать данные.
Оригинал статьи

Комментарии (4)


  1. flatscode
    24.09.2017 17:35

    Подскажите, а в iOS так же приходится плясать с бубном, как и в Android вокруг onSaveInstanceState(), Fragment.setRetainInstance() и т.п., или там проще?


  1. agent10
    24.09.2017 22:48

    Ну, на мой взгляд вот эта «Концепция событий» в данном случае выглядит как костыль. Архитектурно не заложено и просто решили притянуть за уши то, что есть. Так же костыльным решением выглядит обработка ошибок, почему сразу не добавили некий onError по аналогии с Rx Observable не очень ясно.

    Было бы также интересно посмотреть на случай разной вёрстки для планшетов/телефонов. Что лучше делать в этом случае? Разные ViewModel или может одна? Если одна, то где располагать логику поведения для разных типов девайсов?


    1. saege5b
      25.09.2017 11:26

      Блин, сейчас даже крупные сайты делают под телефон, а как оно будет выглядеть на большом мониторе — никого не волнует (привет гугл и вк!).


      1. agent10
        25.09.2017 11:29

        Хмм, а как связаны сайты, мой комментарий, ваш комментарий и разработка под Андроид? :)