Давайте зробимо MVVM на Android

Let's make an MVVM at Android

Настав час зробити гнучку архітектуру для Android за допомогою DataBinding!

Привіт! Перш за все, я хотів би вибачитися за 9 місяців мовчання з моменту публікації статті про DataBinding. У мене не було достатньо часу, щоб написати обіцяне продовження. Але навіть у цьому є свої плюси: цього разу нам вдалося "відшліфувати" деякі рішення і зробити їх ще кращими ;)

Що таке MVVM?

Для початку розглянемо класичний опис цього шаблону та проаналізуємо кожен з його компонентів. Model-View-ViewModel (тобто MVVM) це шаблон архітектури клієнтського додатку, запропонований Джоном Госсманом як альтернатива шаблонам MVC та MVP при використанні технології Data Binding. Його концепція полягає в тому, щоб відокремити логіку представлення даних від бізнес-логіки перемістивши її в окремий клас для чіткого розмежування.

Отже, що означає кожна з трьох частин у назві?

  • Модель - це логіка, пов'язана з даними додатку.
  • Іншими словами, це POJO, класи обробки API, база даних і так далі.
  • View - це фактично макет екрану, на якому розміщуються всі віджети для відображення інформації.
  • ViewModel - це об'єкт, який описує поведінку логіки View в залежності від результату роботи Model. Його можна назвати моделлю поведінки View. Це може бути як розгорнуте текстове форматування, так і логіка управління видимістю компонентів або відображення умов, таких як завантаження, помилка, порожній екран тощо. Також вона описує поведінку, яка була ініційована користувачем (введення тексту, натискання кнопок, свайп тощо).


Що це дає нам в результаті?

  • Гнучкість розробки. Такий підхід підвищує зручність командної роботи, адже поки один член команди працює з макетом і стилізацією екрану, інший, в той же час, описує логіку збору та обробки даних;
  • Тестування. Така структура спрощує написання тестів і процес створення макетів об'єктів. Крім того, в більшості випадків це усуває необхідність в автоматизованому тестуванні інтерфейсу користувача, оскільки ви можете обернути саму ViewModel юніт-тестами;
  • Поділ логіки. Завдяки більшій диференціації код стає більш гнучким і легким для підтримки, не кажучи вже про його читабельність. Кожен модуль відповідає лише за певну функцію.


Оскільки ніщо не є ідеальним, є і деякі недоліки:

  • Такий підхід не може бути виправданий для невеликих проектів.
  • Якщо логіка зв'язування даних занадто складна, налагодження програми буде трохи складнішим.

Але все ж таки, хто є хто?

Спочатку цей патерн потребує невеликої модифікації на Android. Точніше, необхідно переглянути компоненти та їх звичне сприйняття.

Для прикладу розглянемо Activity. У нього є layout-файл (XML) і пов'язаний з ним Java-клас, де ми описуємо все, що стосується його роботи. Виходить, що xml-файл - це View, а java-клас, відповідно, ViewModel? Не зовсім так. А якщо я скажу, що наш клас - це теж View? Адже користувацьке представлення також має xml і клас-обробник, але воно вважається уніфікованим. Більше того, і в активності, і в користувацькому представленні можна жити без xml-файлу, створюючи необхідні віджети в коді. Ось і виходить, що в нашій архітектурі View == Activity (тобто, XML + Java-class).

Але що таке ViewModel, чим вона є, і, головне, куди її подіти? Як ми могли бачити в одному з розділів попередньої статті, це абсолютно окремий об'єкт. І це те, що ми передали в xml-файл за допомогою binding.setViewModel(). У ньому будуть поля і методи, які нам знадобляться для зв'язування models з View.

Модель нічим не відрізняється від традиційного розуміння. Єдине, що я хотів би додати від себе - не робіть посилання на базу даних або API безпосередньо в ViewModel. Замість цього створіть Repository для кожної VM - так код буде чистішим і менш громіздким.

Таким чином, ми отримуємо наступне: активність "обслуговує" лише логіку, яка відноситься безпосередньо до View, але не впливає на його поведінку. До таких випадків можна віднести встановлення надбудов Toolbar або TabLayout і Viewpager. Важливо, що тільки View може звертатися до віджетів безпосередньо за ідентифікатором(binding.myView.doSomething()), оскільки VM не повинна нічого знати про View - зв'язок між ними реалізується тільки за допомогою Binding. Логіка завантаження та відображення даних лежить на ViewModel, а алгоритм отримання даних описаний відповідно в Model.

Let's make an MVVM at Android

Наш дизайнер пішов у відпустку, тому схема буде з елементами новорічного настрою :)

Просто зробіть це!

Тепер безпосередньо до реалізації. Дивлячись на діаграму вище, ми можемо помітити, що View надсилає ViewModel не тільки команди (дії користувача), але і свій життєвий цикл. Чому? Тому що, зокрема, це теж свого роду дія, яка ініціюється користувачем. Адже саме через його дії екран змінює свій стан. А після цього нам потрібно відреагувати на коректну роботу програми. Рішення напрошується саме собою: потрібно делегувати необхідні зворотні виклики на віртуальну машину.

Уявіть, що вам потрібно завантажувати інформацію кожного разу, коли користувач повертається до активності. Для цього нам потрібно викликати метод завантаження даних в onResume().
Змінимо ProfileActivity:

private ProfileViewModel viewModel;
 
@Override
protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   ActivityProfileBinding binding = DataBindingUtil.setContentView(this, LAYOUT_ACTIVITY);
 
   viewModel = new ProfileViewModel(this);
   binding.setViewModel(viewModel);
}
 
@Override
protected void onResume() {
   super.onResume();
   viewModel.onResume();
}

...і визначте той самий метод у ProfileViewModel:

public void onResume() {
   isLoading.set(this.user.get() == null);
   userRepo.getUser(this::onUserLoaded);
}

Тепер дані оновлюватимуться щоразу, коли користувач повертатиметься до вікна. Крім того, якщо інформація не була отримана раніше, з'явиться відповідний стан. Легко :)

Точно так само ми робимо з рештою необхідних методів. Звичайно, непрактично визначати це кожного разу при створенні віртуальної машини, тому ми перенесемо цю логіку в базові класи. Назвемо їх BindingActivity і ActivityViewModel:

public abstract class BindingActivity extends AppCompatActivity {
   …
 
   @Override
   protected void onStart() {
      super.onStart();
      viewModel.onStart();
   }
 
   @Override
   protected void onActivityResult(int requestCode, int resultCode, Intent data) {
      super.onActivityResult(requestCode, resultCode, data);
      viewModel.onActivityResult(requestCode, resultCode, data);
   }
 
   @Override
   protected void onResume() {
      super.onResume();
      viewModel.onResume();
   }
 
   @Override
   public void onBackPressed() {
      if (!viewModel.onBackKeyPress()) {
          super.onBackPressed();
      }
   }
   //….other methods
}
 
public abstract class ActivityViewModel extends BaseObservable {
   …
 
   public void onStart() {
      //Override me!
   }
 
   public void onActivityResult(int requestCode, int resultCode, Intent data) {
      //Override me!
   }
 
   public void onResume() {
      //Override me!
   }
 
   public void onBackPressed() {
      //Override me!
   }
   //….other methods
}

Тепер, як і у випадку з активністю за замовчуванням, нам просто потрібно перевизначити відповідний метод, щоб відреагувати на певні зміни.

Як на мене, немає необхідності створювати прив'язки та підключення віртуальної машини до нього кожного разу, коли ви створюєте активність. Цю логіку також можна перенести в базовий клас, але зі зміною методу onCreate(). Ми адаптуємо його для VM при створенні активності і додамо пару абстрактних методів для необхідних параметрів:

private AppCompatActivity binding;
private ActivityViewModel viewModel;
 
public abstract ActivityViewModel onCreate();
public abstract @IdRes int getVariable();
public abstract @LayoutRes int getLayoutId();
 
@Override
public void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   bind();
}
 
public void bind() {
   binding = DataBindingUtil.setContentView(this, getLayoutId());
   this.viewModel = viewModel == null ? onCreate() : viewModel;
   binding.setVariable(getVariable(), viewModel);
   binding.executePendingBindings();
}

Залишилося лише зробити базовий клас для ActivityViewModel. Тут все простіше: просто додаємо копію Activity. Вона стане нам у нагоді для створення намірів, а також підійде як контекст:

public abstract class ActivityViewModel extends BaseObservable {
 
   protected Activity activity;
 
   public ActivityViewModel(Activity activity) {
       this.activity = activity;
   }
 
   public Activity getActivity() {
       return activity;
   }
   //...lifecycle methods
}

Це все для діяльності. Ми маємо необхідні інструменти для опису логіки, за винятком однієї неприємної дрібниці. Такі поля як "viewModel" і "binding" в базовій діяльності явно типізовані, що ускладнює роботу з ними, змушуючи кожного разу отримувати типи. Тому давайте узагальнимо наші класи наступним чином:

public abstract class BindingActivity<B extends ViewDataBinding, VM extends ActivityViewModel> extends AppCompatActivity {
 
   private B binding;
   private VM viewModel;
 
   public B getBinding() {
       return binding;
   }
}
 
 
public abstract class ActivityViewModel<A extends AppCompatActivity>
       extends BaseObservable {
 
   protected A activity;
 
   public ActivityViewModel(A activity) {
       this.activity = activity;
   }
 
   public A getActivity() {
       return activity;
   }
}

Готово! Після всієї магії у нас є цей урок активності:

public class ProfileActivity
       extends BindingActivity<ActivityProfileBinding, ProfileViewModel> {
 
   @Override
   public ProfileViewModel onCreate() {
       return new ProfileViewModel(this);
   }
 
   @Override
   public int getVariable() {
       return BR.viewModel;
   }
 
   @Override
   public int getLayoutResources() {
       return R.layout.activity_profile;
   }
 
}

getVariable() має повертати ім'я змінної, яка вказана в тезі->ariable xml-файлу activity, і getLayoutId() має повертати той самий xml. Також варто зазначити, що ProfileViewModel має успадковувати ActivityViewModel.

Реалізація таких класів для фрагментів має невеликі відмінності, але ми не будемо розглядати їх детально в цій статті, оскільки концепція для всіх них схожа. Готовий до роботи клас можна побачити нижче.

Кілька корисних прикладів

З часу нашої останньої статті про DataBinding ця бібліотека не тільки втратила статус бета-версії, але й отримала кілька дуже корисних нововведень. Одне з них - двостороннє зв'язування. Тепер дані впливають на інтерфейс, і навпаки. Наприклад, коли користувач вводить своє ім'я в EditText, значення змінної також одразу оновлюється. Раніше ми вже робили подібну функцію, але за допомогою TextWatcher і BindingAdapter. Тепер цього можна досягти набагато простіше. Все, що вам потрібно зробити, це змінити
android:text="@{viewModel.text}" на android:text="@={viewModel.text}" зверніть увагу на знак рівності після "@"). Ось і все :). Але такі трюки працюють тільки з полями Observable (ObservableInt, ObservableBoolean, ObservableField і т.д.). Ось так зараз виглядає діалог, в якому ми змінили статус Марка:

<layout
   xmlns:android="http://schemas.android.com/apk/res/android">
 
   <data>
       <variable
           name="viewModel"
           type="com.stfalcon.androidmvvmexample.features.dialogs.input.InputDialogVM"/>
   </data>
 
   <EditText
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:hint="@string/dialog_status_text"
       android:text="@={viewModel.text}"
       android:textColor="@color/primary_text"
       android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"/>
 
</layout>

Ми додали ViewModel для перегляду в діалозі, оскільки передача ObservableField безпосередньо до змінної працює некоректно (я не знаю, чи це баг, чи особливість, але це не очевидно). Так само ви можете прив'язуватися до інших атрибутів, таких як checked в CheckBox і RadioButton, enabled і так далі.

Якщо вам потрібно відреагувати або змінити дані під час їхнього введення/виведення, ви можете перевизначити get() та/або set() у полі Observable і виконати потрібну маніпуляцію там.

public final ObservableField<String> field = new ObservableField<String>() {
   @Override
   public String get() {
       // TODO: your logic
       return super.get();
   }
 
   @Override
   public void set(String value) {
       // TODO: your logic
       super.set(value);
   }
};

А якщо проблема полягає лише у відстеженні змін, ви можете додати OnPropertyChangedCallback:

field.addOnPropertyChangedCallback(new OnPropertyChangedCallback() {
   @Override
   public void onPropertyChanged(Observable sender, int propertyId) {
      // TODO: your logic
   }
});

Ще однією особливістю є можливість використовувати сетери як атрибути в розмітці. Припустимо, у нас є метод setAdapter() у тому ж RecyclerView. Щоб його встановити, нам потрібно звернутися безпосередньо до екземпляру віджета і викликати його методи прямо з коду, що суперечить нашому підходу. Щоб вирішити цю проблему, можна створити BidningAdapter, або навіть CustomView, який буде розширювати RecyclerView і ви зможете додавати туди свої атрибути. Але це не найкращий варіант.
На щастя, все набагато простіше: завдяки генерації коду ми можемо вказати ім'я сеттера в xml, просто опустивши "set". Таким чином, адаптер можна встановити так:

bind:adapter="@{viewModel.adapter}"

Префікс "bind" - це старий добрий "простір імен додатків", і якщо вони вже оголошені, то краще просто продублювати їх, щоб не плутати оголошені користувацькі атрибути з атрибутами, згенерованими Binding:

<layout
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:bind="http://schemas.android.com/apk/res-auto">
   ...
   <android.support.v7.widget.RecyclerView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:reverseLayout="true"
       bind:adapter="@{viewModel.adapter}"/>
 
</layout>

Тим не менш, ідея CustomView має право на життя, якщо у віджеті немає потрібного сеттера (або він неправильно названий).

Можливо, хтось з вас задавався питанням, як передавати параметри у ViewModel з такою архітектурою? Підхід до делегування тут теж є, але для зручності ми створюємо статичний метод open (або openForResult), в якому перераховуємо всі необхідні параметри. Потім ми витягуємо ці параметри і передаємо їх у ViewModel, яка має відповідний конструктор. Наприклад, в якості параметра ми передамо статус нашої активності:

private static final String KEY_STATUS = "STATUS";
 
public static void open(Context context, String status) {
   Intent intent = new Intent(context, ProfileActivity.class);
   intent.putExtra(KEY_STATUS, status);
   context.startActivity(intent);
}
 
@Override
public ProfileActivityVM onCreate() {
   return new ProfileActivityVM(this, getIntent().getStringExtra(KEY_STATUS));
}

Ще однією маленькою особливістю, якою я хотів би поділитися, є накладення полів "isLoading" і "isError" в базовому класі ViewModel. Ці поля є загальнодоступними і мають тип ObservabeBoolean. Завдяки цьому немає необхідності дублювати логіку стану завантаження та помилок. Для реагування на їх зміну можна просто використовувати include:

<include
   layout="@layout/part_loading_state"
   bind:visible="@{viewModel.isLoading}"/>

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

Забудьте про шаблони!

Використовуючи MVVM, ми зіткнулися з тим, що доводилося писати багато надокучливого коду: модифікація Activity / Fragment під базові класи, прописування довгих імен для Binding-класів в дженериках, створення і прив'язка ViewModel; а на ранніх етапах доводилося копіювати базові класи з проекту в проект, що також забирало дорогоцінний час. Саме тому ми створили бібліотеку та плагін для Android Studio, з якими ця рутина стала займати всього 2-3 кліки.

Бібліотека AndroidMvvmHelper це набір базових класів для зручної роботи з MVVM. До цього списку входять класи для роботи з Activity (BindingActivity і ActivityViewModel), і з Fragment (BindingFragment і FragmentViewModel), в яких вже є логіка зв'язування, а також визначені необхідні методи для коллбеків. Для того, щоб почати використовувати його, вам потрібно лише визначити залежності у файлі gradle:

dependencies {
   ...
   compile 'com.github.stfalcon:androidmvvmhelper:X.X'
}

Хоча бібліотечне рішення спрощує життя розробника, створення класів все ще залишається досить трудомістким процесом. Для вирішення цієї проблеми ми розробили плагін для IntelliJ IDEA та Android Studio — MVVM Generator. Він дозволяє в один клік створити клас BindingActivity (або BindingFragment), його ViewModel і вже готовий маркувальний xml-файл для реєстрації компонента в AndroidManifest (у випадку активності, звичайно). Крім того, якщо плагін не виявить залежність від бібліотеки MVVMHelper, вона буде додана автоматично.

Для його встановлення потрібно перейти в розділ управління плагінами:

MVVM в Android MVVM в Android

Натисніть " Browse repositories ", щоб знайти доступні плагіни в Інтернеті:

Let's make an MVVM at Android

У вікні пошуку введіть "MVVM Generator", виберіть плагін і натисніть "Встановити":

Let's make an MVVM at Android

Після завершення встановлення необхідно перезапустити IDE. Після цього плагін готовий до використання.

Тепер створимо фрагмент профілю. Для цього, як і при створенні звичайного класу, викличемо контекстне меню на потрібному пакунку і виберемо пункт "Create Binding Fragment".

Let's make an MVVM at Android

Після того, як ми введемо назву треку (в даному випадку "ProfileFragment"), ми отримаємо наступне:

Let's make an MVVM at Android

Зазирнувши всередину, ми побачимо готові до роботи класи:

public class ProfileFragment
       extends BindingFragment<ProfileFragmentVM, FragmentProfileBinding> {
 
   public ProfileFragment() {
       // Required empty public constructor
   }
 
   public static ProfileFragment getInstance() {
       return new ProfileFragment();
   }
 
   @Override
   protected ProfileFragmentVM onCreateViewModel(FragmentProfileBinding binding) {
       return new ProfileFragmentVM(this);
   }
 
   @Override
   public int getVariable() {
       return BR.viewModel;
   }
 
   @Override
   public int getLayoutId() {
       return R.layout.fragment_profile;
   }
}
 
public class ProfileFragmentVM
       extends FragmentViewModel<ProfileFragment> {
 
   public ProfileFragmentVM(ProfileFragment fragment) {
       super(fragment);
   }
}

На додаток до цього у нас є готовий xml:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
       xmlns:app="http://schemas.android.com/apk/res-auto"
       xmlns:tools="http://schemas.android.com/tools">
 
   <data>
 
       <variable
           name="viewModel"
           type="com.stfalcon.androidmvvmexample.features.profile.ProfileFragmentVM"/>
   </data>
 
   <RelativeLayout
       android:layout_width="match_parent"
       android:layout_height="match_parent">
 
   </RelativeLayout>
 
</layout>

І все це всього за кілька секунд! Отже...

Підбиваємо підсумки

Плагін дуже простий і вирішує лише основну задачу - генерацію файлів. У планах додати перевірку на наявність прив'язок, більш гнучку валідацію заголовків, розширене налаштування шаблонів активностей і багато іншого, але поки що - маємо те, що маємо.

З моменту виходу стабільної версії DataBinding наша команда вже реалізувала кілька проектів з використанням цього підходу. З власного досвіду можу лише сказати, що повертатися до більш традиційних методів написання додатків не хочеться, а коли доводиться це робити - відчуваєш себе людиною з майбутнього. Загалом, у нас стало менше рутинної роботи, а тому процес розробки став цікавішим. Крім того, хлопці з Google активно працюють над адекватною підтримкою цієї технології в Android Studio, що значно мінімізує дискомфорт при розробці проекту. Тепер це основний підхід, який ми використовуємо для створення додатків.

Сподіваємося, що наш досвід полегшить вам життя при створенні MVVM-архітектури у вашому додатку.

Гарних вам свят!

P.S. Оновлений приклад коду з попередньої статті можна подивитися тут.