Реализация паттерна MVVM на Android через Data Binding

IT-копирайтер
Время чтения: 12 минут
При разработке сложных UI решений в Android приложениях зачастую приходится писать много шаблонного кода. Например, когда пользователь вводит или получает какие-то данные, некоторые View могут изменять свои параметры text, visibility, enable и т.д. При сложной логике код фрагмента или активити обрастает кучей сеттеров setEnabled(), setVisibility() setText() и т.д. Всё это приводит к увеличению кода, а, следовательно, и к росту числа багов.
К счастью, появилась библиотека databinding, которая позволяет решить эту проблему, сделать код более удобным, читаемым и избежать большого количества шаблонного кода. Она позволяет привязать к лейауту структуры данных, следить за их изменениями и в режиме реального времени отображать их в xml-разметке.
Конечно, это не первая библиотека, реализующая binding. Но Data Binding отличается от аналогов (Bindroid, RoboBinding, ngAndroid) по следующим параметрам:
- Является официальной библиотекой от Google, а, следовательно, не испытывает каких-либо проблем с AppCompat, RecyclerView и другими компонентами Android.
- Проверка ошибок осуществляется на этапе компиляции.
- Происходит генерация кода, а не работа с reflection.
Библиотека пока имеет свои недочёты, но они незначительны. Рассмотрим, каким образом можно реализовать паттерн MVVM на Android с использованием Data Binding.
Проблема
В стандартном подходе разработки под Android данные, логика и представление не разделены и находятся в коде фрагмента или активити. Традиционный подход будет неудобен при разработке приложений со сложной объёмной логикой по тем же причинам, что и при разработке сложных UI решений: много кода —> смешивание логики и представления —> баги.
Решение
Для решения этой проблемы мы можем реализовать паттерн Model View ViewModel (MVVM) через Data Binding, которая открывает возможности для разделения данных, логики и представления.
Схема паттерна MVVM
Ключевая идея заключается в том, чтобы через databinding привязать объект ViewModel к представлению (через лейаут), специфичные моменты взаимодействия с fragment/activity реализовать через interface (например, смена fragment/activity), а во ViewModel описать всю логику. Т.е. наша ViewModel выступает прослойкой между Model и View. Таким образом, мы получим гибкую распределённую систему, где каждый элемент играет свою роль и не мешает другому.
Рассмотрим это на примере экрана авторизации:
Пример
Как видно из этого экрана, кнопка Sign in становится enabled только когда оба текста EditText введены корректно (для email — проверка паттерна email, для password — количество символов > 3). Реализуем это с помощью паттерна MVVM.
Код ViewModel:
public class SignInViewModel { public SignInRequest signInRequest; private SignInDataListener mDataListener; public SignInViewModel(@NonNull final SignInDataListener signInDataListener) { mDataListener = signInDataListener; signInRequest = new SignInRequest("", ""); } public TrimmedTextWatcher getEmailTextWatcher() { return new TrimmedTextWatcher() { @Override public void afterTextChanged(@NonNull final Editable editable) { signInRequest.setEmail(editable.toString()); } }; } public TrimmedTextWatcher getPasswordTextWatcher() { return new TrimmedTextWatcher() { @Override public void afterTextChanged(@NonNull final Editable editable) { signInRequest.setPassword(editable.toString()); } }; } public boolean onEditorAction(@NonNull final TextView textView, final int actionId, @Nullable final KeyEvent keyEvent) { if (TextUtils.editorActionBaseCheck(textView, actionId, keyEvent) && signInRequest.isInputDataValid()) { requestSignIn(); } return false; } public void onSignInClick(@NonNull final View view) { requestSignIn(); } private void requestSignIn() { // Here we trying to sign in and after open MainActivity mDataListener.onSignInCompleted(); } public void onSignUpClick(@NonNull final View view) { mDataListener.onSignUpClicked(); } public interface SignInDataListener { void onSignInCompleted(); void onSignUpClicked(); } public class SignInRequest extends BaseObservable { @NonNull private String mEmail; @NonNull private String mPassword; public SignInRequest(@NonNull final String email, @NonNull final String password) { mEmail = email; mPassword = password; } @NonNull public String getEmail() { return mEmail; } public void setEmail(@NonNull final String email) { mEmail = email; notifyChange(); } @NonNull public String getPassword() { return mPassword; } public void setPassword(@NonNull final String password) { mPassword = password; notifyChange(); } public boolean isInputDataValid() { return TextUtils.isEmailValid(getEmail()) && getPassword().length() > 2; } } }
Во ViewModel мы создаём 2 метода, возвращающих два TextWatcher, и метод для обработки нажатия кнопки Done на клавиатуре. Для связи с SignInFragment в SignInViewModel описывается интерфейс SignInDataListener, реализация которого передаётся в конструкторе SignInViewModel.
Вся обработка и логика проверки на валидность введённых данных происходит в классе SignInRequest, который наследуется от BaseObservable. Также есть 2 метода для прослушки кликов по кнопкам Sign in и Sign up. Для кнопки Sign up мы просто вызываем метод реализованного интерфейса onSignUpClicked. Для кнопки Sign in сначала посылаем запрос, выполняем всю работу по его обработке, если всё удачно — вызываем метод реализованного интерфейса onSignInCompleted, если возникли проблемы — обрабатываем их.
Таким образом, вся логика и магия происходит во ViewModel.
Рассмотрим, как это привязывается к нашему лейауту:
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="viewModel" type="com.azoft.mvvm.SignInViewModel"/> </data> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <EditText style="@style/EditTextEmail" android:addTextChangedListener="@{viewModel.getEmailTextWatcher}" android:onEditorAction="@{viewModel.onEditorAction}"/> <EditText style="@style/EditTextPassword" android:addTextChangedListener="@{viewModel.getPasswordTextWatcher}" android:onEditorAction="@{viewModel.onEditorAction}"/> <Button style="@style/ButtonSignIn" android:enabled="@{viewModel.signInRequest.isInputDataValid}" android:onClick="@{viewModel.onSignInClick}"/> <Button style="@style/ButtonSignUp" android:onClick="@{viewModel.onSignUpClick}"/> </LinearLayout> </layout>
В теге variable передаём нашу ViewModel с параметрами name=”viewModel”, type=”com.azoft.mvvm.SignInViewModel”. И для EditText’ов устанавливаем соответствующие TextWatcher и EditorAction. Для кнопок устанавливаем соответствующие обработчики нажатия, а для кнопки Sign in параметр enabled, который зависит от метода isInputDataValid класса SignInRequest. Этот метод всегда возвращает актуальные данные о валидности введённых полей, т.к. SignInRequest наследуется от BaseObservable и при установке его полей в сеттерах вызывается метод notifyChange(), уведомляющий о том, что данные изменились.
Связь View и ViewModel:
public class SignInFragment extends Fragment implements SignInViewModel.SignInDataListener { @Override public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { final View layout = inflater.inflate(R.layout.fragment_sign_in, container, false); FragmentSignInBinding.bind(layout) .setViewModel(new SignInViewModel(this)); return layout; } @Override public void onSignInCompleted() { // Start main screen } @Override public void onSignUpClicked() { // Start sign up screen } }
В автогенерируемом классе FragmentSignInBinding мы привязываем наш layout к SignInFragment и передаём туда SignInViewModel.
Fragment реализует интерфейс, определённый во ViewModel, в нашем случае это SignInDataListener. Тем самым обозначая, что должно произойти во View после того, как мы успешно прошли авторизацию или нажали на кнопку Sign up.
Таким образом, View (в нашем случае Fragment) ничего не знает о том, что именно происходит при нажатии кнопки или вводе данных. Ей только говорят, как она должна измениться или обновиться.
Конечно, нужно не забывать сохранять состояние ViewModel и привязывать к lifecycle SignInFragment’а. Это не описано в коде, чтобы не загромождать его.
Таким образом мы получаем распределённую систему, где разделены логика и представление. Эта система хороша тем, что большая часть логики ViewModel часто одна и та же в разных приложениях и может повторяться в рамках одного проекта. То есть, большая часть кода ViewModel может быть использована при создании любых приложений на Android даже с учётом того, что представления зачастую уникальны для каждого приложения.
Для ознакомления вы можете посмотреть другие сэпмплы приложений, доступные на GitHub: https://github.com/ivacf/archi. Здесь представлены наглядные примеры работы с экраном поиска и списка.
Выводы
Плюсы выбранного подхода:
- Очень удобен на сложных экранах со сложной логикой и UI.
- Возможность использования всех преимуществ Databinding library(ObservableFields, не нужно вызывать findViewById или подключать Butterknife или аналогичные бибилиотеки, Binding adapters и т.д.).
- Существенно упрощает написание тестов, т.к. логика отделена от представления.
Минусы:
- Необходимость сохранения состояния ViewModel.
- Не всегда можно разделить логику от представления.
В целом, от использования данного подхода остаются только положительные эмоции. Различные возникающие проблемы, такие, как сохранение состояния ViewModel и привязка к lifecycle fragment/activity, решаются несложно. Используя предложенный подход, вы сможете довольно легко создавать приложения с насыщенным интерфейсом и, в то же время, получать простые и компактные ViewModel.
Комментарии