Як перестати використовувати MVVM

Двоголовий MVVM
На недавньому DroidCon Moscow 2016 був доповідь про MVVM c Databinding Library і доповідь про бібліотеку Moxy, допомагає працювати з MVP. Справа в тому, що за останні півроку ми встигли випробувати обидва підходу на живих проектах. І я хочу розповісти про свій шлях від освоєння Databinding Library і випуску в продакшн проекту на MVVM до усвідомлення, чому я більше не хочу використовувати цей патерн.
Присвячується всім, кого зачепила Databinding Library і хто вирішив будувати додаток на MVVM, – ви відважні люди!
Databinding Library
Почавши розбиратися з Databinding Library, я був під враженням. Ті, хто вже знайомий з нею, мене зрозуміють, а для інших, ось як виглядає робота з цієї бібліотеки:

Використання 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);
    }
  • Зробити стан view залежним від даних. Наприклад, відображається індикатор завантаження, буде залежати від того, чи є дані.
Останній пункт особливо приємний для мене тому, що стану завжди були складною темою. Якщо на екрані потрібно відобразити три стани (завантаження, дані, помилка), це ще добре. Але, коли з'являються різні вимоги до стану елементів в залежності від даних (наприклад, відображати текст тільки якщо він не порожній, або міняти колір залежно від значення), може знадобитися або великий switch з усіма можливими варіантами станів інтерфейсу, або багато прапорів і коду методи встановлення значень елементів.
Тому те, що 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, які треба відображати, наприклад, йде завантаження.
Зв'язок і взаємодія між собою цих компонентів ми бачимо на картинці:

Стрілками показані залежності: 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:

Розглянемо, що в підсумку виходить:
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 фрагмента.
Приблизно так все працює.
Уважний читач запитає: «А чого мучитися з 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 квітами на одній з граней. А можна набором рухів, які приведуть його з початкового стану в потрібний.

Може знадобитися всього один поворот, а може і набагато більше дев'яти. Виходить, залежно від ситуації, будь-то спосіб опису стану краще або гірше (менше даних потрібно).
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.
вам цей патерн – вирішуйте самі. Але я вас попередив.
<h4 id=«ps-databinding-library — >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-ах пишіть тільки атрибути (
    @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/
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.