Как перестать использовать MVVM

в 9:42, , рубрики: android, databinding, moxy, mvvm, patterns, Блог компании MobileUp, разработка мобильных приложений, Разработка под android

Двухголовый MVVM

На недавнем DroidCon Moscow 2016 был доклад о MVVM c Databinding Library и доклад о библиотеке Moxy, помогающей работать с MVP. Дело в том, что за последние полгода мы успели опробовать оба подхода на живых проектах. И я хочу рассказать о своём пути от освоения Databinding Library и выпуска в продакшн проекта на MVVM до осознания, почему я больше не хочу использовать этот паттерн.

Посвящается всем, кого зацепила Databinding Library и кто решил строить приложение на MVVM, – вы отважные люди!

Databinding Library

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

Как перестать использовать MVVM - 2

Использование Databinding Library позволяет:

  • Избавиться от вызовов findViewById и setOnClickListener. То есть, указав id в xml, можно обращаться к view через binding.viewId. И можно устанавливать вызовы методов прямо из xml;
  • Связать данные напрямую с элементами view. Мы вызываем binding.setUser(user), а в xml указываем, к примеру, android:text = “@{user.name}”;
  • Создавать кастомные атрибуты. Например, если мы хотим загружать изображения в ImageView при помощи библиотеки Picasso, то можем создать BindingAdapter для атрибута “imageUrl”, а в xml писать bind:url=”@{user.avatarUrl}”.
    Такой BindingAdapter будет выглядеть так:

    @BindingAdapter("bind:imageUrl")
    public static void loadImage(ImageView view, String url) {
       Picasso.with(view.getContext()).load(url).into(view);
    }
  • Cделать состояние view зависимым от данных. Например, отображается ли индикатор загрузки, будет зависеть от того, есть ли данные.

Последний пункт особенно приятен для меня потому, что состояния всегда были сложной темой. Если на экране нужно отобразить три состояния (загрузка, данные, ошибка), это ещё ладно. Но, когда появляются различные требования к состоянию элементов в зависимости от данных (например, отображать текст только если он не пустой, или менять цвет в зависимости от значения), может понадобиться либо большой switch cо всеми возможными вариантами состояний интерфейса, либо много флагов и кода в методах установки значений элементам.
Поэтому то, что Databinding Library позволяет упростить работу с состояниями, – огромный плюс. К примеру, написав в xml android:visibility=”@{user.name != null ? View.VISIBLE : View.GONE}”, мы можем больше не думать о том, когда надо скрыть или показать TextView с именем пользователя. Мы просто задаём имя, а видимость изменится автоматически.

ViewModel

Но, начав использовать databinding активнее, вы получите в xml всё больше и больше кода. И, чтобы не превращать layout в свалку, мы создадим класс, в который вынесем этот код. А в xml будут оставаться только вызовы свойств. Приведу маленький пример. Предположим, есть класс User:

public class User {
    public firstname;
    public lastname;
}

А в UI мы хотим видеть полное имя и пишем в xml:

<TextView
    android:text="@{user.firstname + user.lastname}"
    />

Это не очень хочется видеть в xml, и мы создаём класс, в который выносим эту логику:

public class UserViewModel extends BaseObservable {

    private String name;

    @Bindable
    public String getFullname() {
        return name;
    }

    public void setUser(User user) {
        name = user.firstname + user.lastname;
        notifyPropertyChanged(BR.name);
    }
}

Создатели библиотеки предлагают называть такие классы ViewModel (прям, как в паттерне MVVM, удивительно).

В примере класс наследуется от BaseObservable, a в коде вызывает notifyPropertyChanged(), но это не единственный способ. Можно также обернуть поля в ObservableField, и зависимые элементы UI будут обновляться автоматически. Но я считаю такой способ менее гибким и редко его использую.

Теперь в xml у нас будет:

<TextView
    android:text="@{viewmodel.name}"
    />

Гораздо лучше, не правда ли?

Итак, у нас появился ViewModel класс, который выступает в роли прослойки между данными и view. Он занимается преобразованиями данных, управляет тем, какие поля (и связанные элементы UI) и когда обновляются, содержит логику того, как одни поля зависят от других. Это позволяет очистить xml от кода. Кроме того, удобно использовать этот класс для обработки событий из view (нажатия и т.п).

И тут к нам приходит мысль: Если у нас уже есть databinding, есть ViewModel класс, содержащий логику отображения, то почему бы не использовать паттерн MVVM?

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

MVVM

В паттерне Model-View-ViewModel три основных компонента:

  • Model. Бизнес-логика приложения, предоставляющая данные для отображения.
  • View. Отвечает за внешний вид, расположение и структуру всех UI-элементов, которые пользователь видит на экране.
  • ViewModel. Выступает мостом между View и Model и обрабатывает логику отображения. Запрашивает у Model данные и передает их View в виде, который View может легко использовать. Также содержит обработку событий, совершенных пользователем приложения во View, таких, как нажатие на кнопку. Кроме того, ViewModel отвечает за определение дополнительных состояний View, которые надо отображать, например, идет ли загрузка.

Связь и взаимодействие между собой этих компонентов мы видим на картинке:

Как перестать использовать MVVM - 3

Стрелками показаны зависимости: View знает о ViewModel, а ViewModel знает о Model, но модель ничего не знает о ViewModel, которая ничего не знает о View.

Процесс такой: ViewModel запрашивает данные у Model и обновляет её когда необходимо. Model уведомляет ViewModel, что данные есть. ViewModel берёт данные, преобразует их и уведомляет View, что данные для UI готовы. Связь между ViewModel и View осуществляется путём автоматического связывания данных и отображения. В нашем случае это достигается через использование Databinding Library. При помощи databinding’а View обновляется, используя данные из ViewModel.

Наличие автоматического связывания (databinding) является главным отличием этого паттерна от паттерна PresentationModel и MVP (в MVP Presenter изменяет View путём вызова на ней методов через предоставленный интерфейс).

MVVM в Android

Так я начал использовать MVVM в своем проекте. Но, как часто бывает в программировании, теория и практика – не одно и тоже. И после завершения проекта у меня осталось чувство неудовлетворенности. Что-то было не так в этом подходе, что-то не нравилось, но я не мог понять, что именно.

Тогда я решил нарисовать схему MVVM на Android:

Как перестать использовать MVVM - 4

Рассмотрим, что в итоге получается:

ViewModel содержит поля, используемые в xml для биндинга данных (android:text=”@{viewmodel.username}”), обрабатывает события вызванные на View (android:onClick=”@{viewmodel::buttonClicked}”). Она запрашивает данные у Model, преобразует их, и при помощи databinding’a эти данные попадают во View.

Fragment одновременно выполняет две роли: входная точка, обеспечивающая инициализацию и связь с системой, и View.

То, что Fragment (или Activity) рассматриваются как View в понимании паттернов MVP и MVVM, уже стало распространённой практикой, поэтому я не стану на этом останавливаться.

Чтобы пережить повороты и пересоздание Activity, мы оставляем ViewModel жить на то время, пока пересоздаётся View (в нашем случае Fragment). Достигается это с использованием dagger и пользовательских scopes. Не стану вдаваться в подробности, уже написано много хороших статей про dagger. Своими словами, происходит следующее:

  • ViewModel создается при помощи dagger (и её инстанс живёт в нём), и фрагмент берет её когда нужно.
  • Когда фрагмент умирает при повороте, он вызывает detachView() у ViewModel.
  • ViewModel продолжает жить, её фоновые процессы тоже, и это очень удобно.
  • Потом, когда фрагмент пересоздан, он вызывает attachView() и передаёт себя в качестве View (используя интерфейс).
  • Если же фрагмент умирает полностью, а не из-за поворота, то он убивает scope (обнуляется нужный компонент dagger, и ViewModel может быть собрана garbage collector’ом вместе с этим компонентом) и ViewModel умирает. Это реализовано в BaseFragment.

Зачем фрагмент передаёт себя во ViewModel, используя интерфейс MvvmView? Это нужно для того, чтобы мы могли вызывать команды «вручную» на View. Не всё можно сделать при помощи Databinding Library.

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

Примерно так всё работает.

Внимательный читатель спросит: «A чего мучаться с dagger custom scopes, если можно просто использовать Fragment как контейнер и вызвать в нём setRetainInstance(true)». Да, так сделать можно. Но, рисуя схему, я учитывал, что в качестве View можно использовать Activity или ViewGroup.

Недавно я нашел хороший пример реализации MVVM, полностью отражающий нарисованную мной структуру. За исключением пары нюансов, всё сделано очень хорошо. Посмотрите, если интересно.

Проблема двойственности

Нарисовав схему и обдумав всё, я понял, что именно меня не устраивало во время работы с этим подходом. Взгляните на схему снова. Видите толстые стрелки «databinding» и «manual commands to view»? Вот оно. Сейчас расскажу подробнее.

Раз у нас есть databinding, то большую часть данных мы можем просто устанавливать в View при помощи xml (создав нужный BindingAdapter, если понадобится). Но есть случаи, которые не укладываются в этот подход. К таким относятся диалоги, toast’ы, анимации, действия с задержкой и другие сложные действия с элементами View.

Вспомним пример с TextView:

<TextView
    android:text="@{viewmodel.name}"
    />

Что, если нам нужно установить этот текст, используя view.post(new Runnable())? (Не думаем зачем, думаем как)

Можно сделать BindingAdapter, в котором создать атрибут «byPost», и сделать, чтобы учитывалось наличие перечисленных атрибутов у элемента.

@BindingAdapter(value = {"text", "byPost"}, requireAll = true)
public static void setTextByPost(TextView textView, String text, boolean byPost) {
   if (byPost) {
       textView.post(new Runnable {
           public void run () {
               textView.setText(text);
           }
       })
   } else {
       textView.setText(text);
   }
}

И теперь каждый раз, когда у TextView будут указаны оба атрибута, будет использоваться этот BindingAdapter. Добавим атрибут в xml:

<TextView
    android:text="@{viewmodel.name}"
    bind:byPost="@{viewmodel.usePost}"
    />

ViewModel теперь должно иметь свойство, указывающее на то, что в момент установки значения мы должны использовать view.post(). Добавим его:

public class UserViewModel extends BaseObservable {

    private String name;
    private boolean usePost = true; // only first time

    @Bindable
    public String getFullname() {
        return name;
    }

    @Bindable
    public boolean getUsePost() {
        return usePost;
    }

    public void setUser(User user) {
        name = user.firstname + user.lastname;
        notifyPropertyChanged(BR.name);
        notifyPropertyChanged(BR.usePost);
        usePost = false;
    }
}

Видите, сколько всего нужно сделать, чтобы реализовать очень даже простое действие?

Поэтому гораздо проще делать подобные вещи прямо на View. То есть использовать интерфейс MvvmView, который реализуется нашим фрагментом, и вызывать методы View (также, как это обычно делается в MVP).

Вот тут и проявляется проблема двойственности: мы работаем с View двумя разными способами. Один – автоматический (через состояние данных), второй – ручной (через вызовы команд на view). Лично мне это не по душе.

Проблема состояний

Теперь расскажу о ещё одной проблеме. Представим ситуацию с поворотом телефона.

  1. Мы запустили приложение. ViewModel и View (фрагмент) живы.
  2. Повернули телефон – фрагмент умер, а ViewModel живёт. Все её фоновые задачи продолжают работать.
  3. Новый фрагмент создался, присоединился. View через databinding получила сохраненное состояние (поля) из ViewModel. Всё круто.
  4. Но что если в тот момент, когда фрагмент (View) отсоединён, фоновый процесс завершился с ошибкой, и мы хотим показать toast об этом? Фрагмент (выполняющий роль View) мёртв, и вызвать метод на нём нельзя.
  5. Мы потеряем этот результат.

Получается, что нужно как-то хранить не только состояние View, представленное набором полей ViewModel, но также и методы, которые ViewModel вызывает на View.

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

Про состояния

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

Представьте себе кубик Рубика. Его состояние можно описать 9 цветами на одной из граней. А можно набором движений, которые приведут его из начального состояния в требуемое.

Как перестать использовать MVVM - 5

Может понадобиться всего один поворот, а может и намного больше девяти. Получается, в зависимости от ситуации, какой-то способ описания состояния лучше или хуже (меньше данных нужно).

Moxy

Обдумывая способы воссоздания состояния, я не мог не вспомнить о библиотеке Moxy. Мои коллеги параллельно делали проект, используя паттерн MVP и эту библиотеку. Подробно я о ней рассказывать не стану, уже есть отличная статья от авторов.

В контексте моих рассуждений интересна одна особенность Moxy – она хранит состояние view как набор команд, вызванных на этой view. И когда я узнал об этом впервые, мне это показалось странным.

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

  • Не всегда можно (удобно) представить состояние только данными (полями).
  • В MVP общение с View идёт через вызовы команд. Почему бы это не использовать?
  • В реальности количество полей view, нужных чтобы воссоздать ее состояние, может быть куда больше числа вызванных на ней команд.

Кроме того, этот подход даёт ещё один плюс. Он также, как и Databinding Library, по-своему решает проблему большого количества разных состояний. Тоже не придется писать огромный switch, изменяющий UI в зависимости от набора полей или названия одного из состояний, так как изменения воссоздаются набором вызовов методов.

И всё же я не могу совсем ничего больше не сказать про Moxy. По моему мнению и мнению моих коллег, на сегодняшний день она является лучшей библиотекой, которая помогает наладить работу с паттерном MVP. Она использует генерацию кода, чтобы минимизировать трудозатраты разработчика. Вы можете не думать о реализации паттерна, а думать о функционале своего проекта. А это хорошо.

Но хватит про MVP. Всё-таки речь у нас про MVVM, и пора подвести итоги.

Выводы

Мне нравится MVVM как паттерн, и я не оспариваю его плюсы. Но в большинстве своём они те же самые, что у других паттернов, либо являются делом вкуса разработчика. Да и основной плюс даёт всё же databinding, а не сам паттерн.

Ведомый симпатией к MVVM, я реализовал проект на нём. Долго изучал тему, обдумывал, обсуждал и вынес для себя набор минусов этого паттерна:

  • MVVM заставляет работать с View одновременно двумя путями: через databinding и через методы View.
  • С MVVM нельзя красиво решить проблему состояний (необходимости сохранения вызова метода View, вызванного когда View была отсоединена от ViewModel).
  • Необходимо продвинутое использование Databinding Library, что требует времени на освоение.
  • Код в xml далеко не всем нравится.

Да, с этими минусами можно свыкнуться. Но после долгих раздумий я пришел к выводу, что не хочу работать с паттерном, который создает раздробленность подходов. И решил, что следующий проект буду писать, используя MVP и Moxy.

Использовать ли вам этот паттерн – решайте сами. Но я вас предупредил.

PS: Databinding Library

Закончим, пожалуй, тем же, с чего и начали – Databinding Library. Мне она по-прежнему нравится. Но использовать её я собираюсь только в ограниченном количестве:

  • Чтобы не писать findViewById и setOnClickListener.
  • И чтобы создавать удобные xml-атрибуты при помощи BindingAdapter-ов (например, bind:font=”Roboto.ttf”).

И всё. Это даст плюсы, но не станет манить в сторону MVVM.

Если вы тоже планируете работать с Databinding Library, то вот вам немного полезной информации:

  • Вызывайте binding.executePendingBindings() в onViewCreated() после задания переменных биндингу. Это поможет, если вы хотите менять что-то в только что созданных view из кода. Не придётся писать view.post(), узнав, что view ещё не готова.
  • В тег <fragment> переменную передать (как можно в <include>) нельзя: https://code.google.com/p/android/issues/detail?id=175338.
  • Лямбды в xml в Databinding Library с особенностями. Нельзя писать без скобок (() -> method()). Нельзя блок кода. Зато можно опустить параметры, если не используются в методе (android:onClick=”@{() -> handler.buttonClicked()}”).
  • backtick (`) можно юзать вместо двойных кавычек (“).
  • В BindingAdapter-aх пишите только атрибуты (@BindingAdapter(“attributeName”)), namespace всё равно игнорируется. И в xml не важно, какой будет namespace. Но часто используют bind, чтобы отличать (bind:attributeName=”...”).
  • Сгенерированные databinding-классы искать тут: app/build/intermediates/classes/debug
  • Готовые адаптеры можно посмотреть тут.
  • Что почитать кроме документации:
    https://realm.io/news/data-binding-android-boyar-mount/
    https://www.bignerdranch.com/blog/descent-into-databinding/
    https://halfthought.wordpress.com/2016/03/23/2-way-data-binding-on-android/

Автор: MobileUp

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js