Реализация паттерна MVVM на Android через Data Binding
При разработке сложных 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:
class SignInViewModel( private val dataListener: SignInDataListener ) { val signInRequest = SignInRequest("", "") fun getEmailTextWatcher() = object : TrimmedTextWatcher() { override fun afterTextChanged(editable: Editable) { signInRequest.setEmail(editable.toString()) } } fun getPasswordTextWatcher() = object : TrimmedTextWatcher() { override fun afterTextChanged(editable: Editable) { signInRequest.setPassword(editable.toString()) } } fun onEditorAction( textView: TextView, actionId: Int, keyEvent: KeyEvent ): Boolean { if (TextUtils.editorActionBaseCheck(textView, actionId, keyEvent) && signInRequest.isInputDataValid()) { requestSignIn() } return false } fun onSignInClick(view: View) { requestSignIn() } private fun requestSignIn() { // Здесь мы пытаемся войти и затем открываем MainActivity dataListener.onSignInCompleted() } fun onSignUpClick(view: View) { dataListener.onSignUpClicked() } interface SignInDataListener { fun onSignInCompleted() fun onSignUpClicked() } class SignInRequest( private var email: String, private var password: String ) : BaseObservable() { fun getEmail() = email fun setEmail(email: String) { this.email = email notifyChange() } fun getPassword() = password fun setPassword(password: String) { this.password = password notifyChange() } fun isInputDataValid() { return TextUtils.isEmailValid(email) && password.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:
class SignInFragment : Fragment(), SignInViewModel.SignInDataListener { override onCreateView( inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle ): View? { val layout = inflater.inflate(R.layout.fragment_sign_in, container, false) FragmentSignInBinding.bind(layout).setViewModel(SignInViewModel(this)) return layout } override fun onSignInCompleted() { // Start main screen } override fun 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.
- Возможность использования всех преимуществ Data Binding Library (Observable fields, не нужно вызывать findViewById или подключать ButterKnife или аналогичные библиотеки, Binding adapters и т.д.).
- Существенно упрощает написание тестов, т.к. логика отделена от представления.
Минусы:
- Необходимость сохранения состояния ViewModel.
- Не всегда можно разделить логику от представления.
В целом, от использования данного подхода остаются только положительные эмоции. Различные возникающие проблемы, такие как сохранение состояния ViewModel и привязка к lifecycle fragment/activity, решаются несложно. Используя предложенный подход, вы сможете довольно легко создавать приложения с насыщенным интерфейсом и, в то же время, получать простые и компактные ViewModel.
Источник: Реализация паттерна MVVM на Android через Data Binding