Восьмая лекция курса по архитектуре клиент-серверных android-приложений, в которой мы рассмотрим паттерн Model-View-ViewModel и Data Binding.
Чтобы на практике познакомиться с архитектурным паттерном MVVM и Data Binding, записывайтесь на продвинутый курс по разработке приложения «Чат-мессенжер»
Введение
В рамках предыдущих лекций мы рассмотрели наиболее актуальные варианты клиент-серверного взаимодействия и архитектурные паттерны. Мы можем строить архитектуру приложения, чтобы писать чистый и понятный код. Мы также умеем тестировать приложение на самых разных уровнях. То есть мы достигли всех целей, которые ставились в начале курса. Поэтому на этом этапе можно было закончить рассмотрение курса, но он был бы неполным без рассмотрения последней темы – паттерна MVVM. В пятой лекции мы разбирали самые известные паттерны, позволяющие разделить работу с UI и логику приложения. Мы обсудили, почему в Android не получается использовать паттерн MVC, и в деталях разобрали паттерн MVP. Из самых известных паттернов мы обошли стороной только MVVM, и теперь настала пора исправить это упущение.
MVVM – это сокращение для Model-View-ViewModel. Мы хорошо знаем, за что отвечают View и Model, поэтому нам осталось понять, что такое ViewModel и чем он отличается от Controller и Presenter.
Ключевой особенностью паттерна MVVM является то, что ни один компонент (Model, View, ViewModel) не знает о другом явно. Эти компоненты взаимодействуют между собой за счет механизма связывания данных (Bindings), который реализуется средствами той или иной системы. При этом изменение данных во ViewModel автоматически меняет данные, отображаемые во View. Аналогично, любое событие или изменение данных во View (нажатие на кнопку, ввода текста и другое) изменяет данные во ViewModel. Это позволяет не хранить явные ссылки на View во ViewModel и наоборот, а также держать эти компоненты очень слабо связными, что удобно при тестировании.
Разумеется, такое связывание данных выполняется не само по себе и не магическим образом, а должно реализовываться за счет каких-то фреймворков. В Android таким фреймворком стал Data Binding, который был представлен на конференции Google I/O 2015. Поэтому, прежде чем двигаться дальше в рассмотрении паттерна MVVM, нужно изучить этот фреймворк.
Data Binding
Data Binding – это фреймворк от Google, который позволяет выполнить связывание Java-кода и xml-файлов. При этом можно полностью избавиться от работы со View в Java-коде. И более того, это избавляет от рутинных вызовов для изменения View. С помощью Data Binding View в xml-разметке можно задать любые свойства, что очень удобно и скрывает некоторые детали реализации.
Подключить Data Binding очень легко – для этого нужно добавить пару строк в build.gradle:
android { //... dataBinding { enabled = true } //... }
Чтобы сразу понять, о чем идет речь и как можно применять Data Binding, давайте рассмотрим простой пример. Допустим, у нас есть экран, на котором отображается имя пользователя и его возраст. Данные пользователя определяются классом User, в котором определены два поля и методы для получения значений этих полей. Для решения этой задачи мы бы создали примерно такую xml-разметку:
<TextView android:id="@+id/userName" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <TextView android:id="@+id/userAge" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
И всем полям в Java-коде задали бы текст, используя экземпляр класс User:
User user = new User("John", 25); TextView userName = (TextView) findViewById(R.id.userName); TextView userAge = (TextView) findViewById(R.id.userAge); userName.setText(user.getName()); userAge.setText(getString(R.string.age_format, user.getAge()));
С использованием Data Binding мы могли бы сделать это немного проще и, что самое главное, лучше для понимания:
User user = new User("John", 25); ActivityUserBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_user); binding.setUser(user);
Код уменьшился незначительно, но во втором варианте мы можем сразу сказать, что данные пользователя передаются для отображения в xml-файл, и нам не нужно знать, как именно они отображаются, то есть мы избавляемся от низкоуровневых подробностей.
Конечно, в таком случае нужно модифицировать и файл разметки. Во-первых, корневым элементом разметки должен стать layout. Во-вторых, мы должны объявить все переменные, которые будут дальше использоваться, внутри тега <data>:
<layout> <data> <variable name="user" type="ru.gdgkazan.pictureofdaymvvm .content.User"/> </data> //... </layout>
Внутри тега <data> можно объявлять переменные с помощью тегов <variable>, в качестве атрибутов указывая имя переменной и ее тип. Далее эти переменные можно использовать в разметке и получать из них данные:
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.name}"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{@string/age_format(user.age)}"/>
И как видно, нам даже не нужно получать View из Java-кода, чтобы менять поля во View, что может сэкономить нам немало кода.
В xml-разметке можно писать и выражения, например, стандартный тернарный оператор. Например, у нас есть текст, который можно показывать только совершеннолетним пользователям, а для остальных его нужно скрывать. Тогда мы можем написать следующую разметку:
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/age_format" android:visibility="@{user.adult ? View.VISIBLE : View.INVISIBLE}"/>
Чтобы такой код заработал, нужно сделать две вещи: во-первых, добавить метод isAdult в класс User:
public boolean isAdult() { return mAge >= 18; }
И импортировать класс View в разметку:
<data> <variable...> <import type="android.view.View"/> </data>
В разметке можно также вызывать методы любых импортированных классов, писать лямбда-выражения и многое другое. Но не нужно реализовывать какую-то сложную логику в xml, так как это будет можно протестировать только с помощью UI-тестов. Вы должны использовать Data Binding, чтобы избежать частой работы со View и рутинной установкой значений, но никак не для того, чтобы писать всю логику в xml.
Разумеется, в таком подходе нет никакой магии. Data Binding по созданным xml-файлам генерирует специальные классы, которые и управляют всеми процессами передачи данных от ViewModel к View и наоборот.
Продолжим рассмотрение возможностей Data Binding. Как уже говорилось, для реализации паттерна МVVM фреймворк Data Binding должен обеспечить возможность связывания данных, то есть чтобы при изменении данных во ViewModel они автоматически менялись и в отображаемой разметке.
Рассмотрим связывание от модели к разметке. Допустим, мы написали код, который присваивает всем View начальные данные, а теперь хотим изменить данные модели:
User user = new User("John", 25); ActivityUserBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_user); binding.setUser(user); //... user.setAge(26);
В такой ситуации мы бы хотели, чтобы данные в разметке автоматически обновились, чтобы нам не пришлось заново вызывать binding.setUser при каждом изменении. И это возможно, нужно только внести небольшие изменения в класс User.
Чтобы указать, что значение может меняться и что эти изменения нужно отслеживать, используется аннотация Bindable, которая применяется к методу для получения значения какого-то поля:
@Bindable public int getAge() { return mAge; }
Если после этого выполнить сборку проекта, то мы увидим, что в сгенерированном классе BR (этот класс очень похож на класс R – класс всех ресурсов, только служит для идентификаторов всех свойств) появится поле age. Это поле используется для уведомления о том, что связанное поле класса было изменено. Дальше нужно унаследовать класс User от BaseObservable:
public class User extends BaseObservable { ...
И теперь мы можем использовать метод notifyPropertyChanged и передать ему идентификатор изменившегося свойства:
public void setAge(int age) { mAge = age; notifyPropertyChanged(BR.age); }
Использовать связывание нужно очень аккуратно. Например, здесь мы забыли, что от значения поля mAge зависит не только сам возраст, но и значение свойства adult. Поэтому корректная реализация метода setAge выглядит следующим образом:
public void setAge(int age) { mAge = age; notifyPropertyChanged(BR.age); notifyPropertyChanged(BR.adult); }
Есть и другие способы для реализации связывания, например, можно использовать классы ObservableInt, ObservableField<Type> и другие. Так работает связывание от ViewModel к View. Обратное связывание (например, ввод текста в EditText) выполняется аналогично.
Еще один важный момент, который нужно учитывать. После присвоения данных во ViewBinding (вызов binding.setUser) они отображаются во View не мгновенно, а с небольшой задержкой. Это сделано для того, чтобы отрисовки изменений совпадали с тактами вызова перерисовки системой. Но если вам нужно, чтобы после задания модели данные отобразились мгновенно, нужно использовать метод executePendingBindings:
binding.setUser(user); binding.executePendingBindings();
Еще одной крайне важной возможностью Data Binding являются кастомные атрибуты. До этого момента мы задавали значения View только с помощью стандартных методов и атрибутов. Но точно также можно создавать и свои атрибуты. Например, мы бы хотели задать ImageView url изображения с помощью атрибута android:src, и чтобы Data Binding загрузил картинку по url:
<ImageView android:layout_width="match_parent" android:layout_height="wrap_content" android:src="@{dayPicture.url}"/>
Разумеется, Data Binding не может знать о том, что нужно загрузить картинку и каким образом это сделать, зато он позволяет нам обработать такую ситуацию. Для этого нужно написать свой метод для обработки атрибута и в нем реализовать все действия, которые требуются (загрузить картинку с помощью Picasso):
@BindingAdapter("android:src") public static void setImageUrl(@NonNull ImageView imageView, @NonNull String url) { Picasso.with(DayPictureApp.getAppContext()) .load(url) .noFade() .into(imageView); }
В аннотации BindingAdapter передается атрибут или список атрибутов, которые будут обрабатываться статическим методом. В качестве аргументов методу передаются значения, которые используются в xml-разметке.
С помощью таких кастомных атрибутов можно перенести в xml почти все приложение, но нужно снова понимать, что такие адаптеры не предназначены для написания сложной логики, а только для нашего удобства.
Конечно, это далеко не все возможности Data Binding, но для дальнейшего использования этого фреймворка при построении архитектуры с MVVM нам хватит рассмотренных примеров. Узнать больше о том, что еще позволяет делать Data Binding, вы можете в документации или в статьях, ссылки на которые есть в конце лекции.
Model-View-ViewModel
Примеры, рассмотренные при изучении возможностей Data Binding, были общими и не строились в рамках архитектуры. Теперь нам осталось встроить Data Binding в архитектуру с паттерном MVVM и посмотреть, какие плюсы мы получим и с какими проблемами столкнемся.
В большинстве примеров не рассматривается использование чистого MVVM в Android-приложениях. Обычно Data Binding используется в качестве вспомогательного инструмента для взаимодействия View и Presenter. То есть мы передаем уже не какие-то отдельные данные для отображения во View, а целые объекты, которые передаются во ViewModel и отображаются через xml с Data Binding. Такая схема позволяет упростить View (в частности логику реализации методов View и количество методов в интерфейсе), но она же и добавляет лишние прослойки для взаимодействия. Такой способ можно использовать для сложных экранов, когда часть работы хотелось бы перенести в xml. Это небольшое упрощение, которое, разумеется, можно использовать на практике. Но здесь нет ничего принципиально нового, что заслуживало бы отдельного рассмотрения. Знаний паттерна MVP и фреймворка Data Binding вполне достаточно для того, чтобы применять такой подход.
Но это тривиальный вариант использования Data Binding, который не дает видимых преимуществ, так как мы только увеличиваем количество классов. К тому же, это все еще паттерн MVP, а не MVVM, который мы хотим рассмотреть. Чтобы можно было получить выгоду от использования Data Binding и паттерна MVVM, нужно попробовать использовать другой подход, который бы действительно мог упростить взаимодействие.
В чем может заключаться это упрощение? В паттерне MVP класс, ответственный за логику приложения, передавал команды View через интерфейс, и Presenter со View были связаны друг с другом. То есть условно схему взаимодействия можно представить следующим образом:
То есть делегат (Presenter) каким-то образом получает данные, выполняет над ними определенные преобразования в соответствии с логикой приложения и использует интерфейс View для того, чтобы передать данные для отображения пользователю.
В варианте, когда Data Binding является лишь инструментом для паттерна MVP, взаимодействие между View и Presenter все равно осуществляется через интерфейс, так как Presenter должен передать данные во ViewModel. Но тогда почему-то бы не попробовать объединить ViewModel и Presenter? То есть использовать в качестве делегата для бизнес-логики не Presenter, а ViewModel. Тогда ViewModel сможет заменить Presenter, выполнять всю его работу, а данные отдавать напрямую в xml с помощью Data Binding. Тогда схема взаимодействия будет следующей:
В таком случае мы вообще избавляемся от необходимости использовать View, так как в роли View теперь выступает xml-разметка. При этом ViewModel все так же является делегатом и управляет бизнес-логикой, не работая с View непосредственно. Если использовать терминологию Data Binding, то ViewModel – это тот объект, который передается в xml-разметку. Все действия пользователя также делегируются этому объекту.
В остальном принципы, которые мы рассматривали ранее (разбиение приложения на модули, разбиение на слой логики и слой UI, тестирование) остаются неизменными.
Конечно, при таком подходе есть немало спорных моментов. Во-первых, действительно ли у MVP есть проблемы, которые требуют такого непростого решения? Как уже было сказано, это актуально скорее для экранов, которые содержат очень много UI-элементов, которые также часто меняются во время работы приложения. Когда в паттерне MVP во View определено 2-3 метода, использовать Data Binding и MVVM не имеет смысла.
Во-вторых, как бы мы не хотели использовать только работу с логикой во ViewModel, так не получится. Поскольку при использовании Data Binding мы не должны вообще использовать View классы в Java-коде, то и их настройку придется выполнять в xml-файлах, а значит, через ViewModel. Какая это может быть настройка? Например, задание LayoutManager-а RecyclerView или же создание адаптера для данных. Использование их во ViewModel означает, что мы используем UI-классы в классах, которые должны отвечать только за логику приложения. Но на самом деле, в этом нет ничего страшного. Для того, чтобы связать UI-классы с xml через ViewModel, мы будем использовать методы, аннотированные как Bindable (опять же для примера это методы, которые возвращают LayoutManager, адаптер и другие UI-объекты). И самое главное – все такие методы должны быть методами без состояния, то есть мы не должны сохранять UI-объекты в качестве полей ViewModel.
При соблюдении таких условий мы будем использовать в качестве полей ViewModel только обычные Java-объекты, и у нас не будет проблем с тестированием методов, влияющих на логику. Но методы, возвращающие какие-то UI-объекты, разумеется, не будут протестированы с помощью тестов на JUnit. Есть и другой вариант решения этой проблемы – использование двух ViewModel, одна из которых будет отвечать за UI-классы, а другая – за логику. Какой из этих подходов лучше и удобнее, сказать сложно.
И в-третьих, не совсем понятно, как можно тестировать ViewModel. В случае MVP мы проверяли, что Presenter вызывает правильные методы View. При использовании MVVM такой возможности у нас больше нет, так как отсутствует сам интерфейс View. Понятно, что такие проблемы актуальны только для тестирования на JUnit, в случае UI-тестирования ничего не меняется, так как оно выполняется с точки зрения пользователя. Для решения проблем тестирования на JUnit можно объявлять package-private методы во ViewModel и тестировать их вызовы. Есть и еще один вариант – изменение View происходит за счет вызова метода notifyPropertyChanged, поэтому можно проверять то, что этот метод был вызван с нужным аргументом и в нужный момент. Далее мы разберем оба этих способа.
Теперь перейдем к практическому изложению и рассмотрим небольшой пример использования MVVM и Data Binding – приложение для просмотра фотографий дня от NASA. Для простоты приложение будет состоять из одного экрана, на котором будет один запрос и несколько UI-элементов. Реализуем предложенную схему для этого экрана.
Создаем класс DayPictureViewModel, который будет играть роль ViewModel на экране. Поскольку этот класс является ViewModel, он должен отдавать все данные View и быть связанным с ней. Поэтому этот класс будет наследоваться от BaseObservable:
public class DayViewModel extends BaseObservable { @Nullable private DayPicture mDayPicture; private final LifecycleHandler mLifecycleHandler; public DayViewModel(@NonNull LifecycleHandler lifecycleHandler) { mLifecycleHandler = lifecycleHandler; } }
Поскольку ViewModel является делегатом, она должна управлять и событиями жизненного цикла, для чего ей передается объект LifecycleHandler. После этого мы определяем список полей ViewModel, которые связаны с xml-разметкой:
@Bindable @NonNull public String getTitle() { if (mDayPicture == null) { return ""; } return mDayPicture.getTitle(); } //...
Это позволяет использовать нашу ViewModel в качестве единственного источника данных в xml. Далее в Activity нужно инициализировать ViewModel и вызвать у нее метод для загрузки данных – так же, как мы делали это при использовании паттерна MVP:
LifecycleHandler handler = LoaderLifecycleHandler.create(this, getSupportLoaderManager()); DayViewModel viewModel = new DayViewModel(handler); ActivityDayPictureBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_day_picture); binding.setModel(viewModel); viewModel.init();
Метод init должен начать загрузку данных и в случае успешной загрузки отобразить их:
RepositoryProvider.provideNasaRepository() .dayPicture() .compose(mLifecycleHandler.load(R.id.day_picture_request)) .subscribe(dayPicture -> { mDayPicture = dayPicture; notifyPropertyChanged(BR.title); notifyPropertyChanged(BR.explanation); notifyPropertyChanged(BR.url); }, throwable -> {});
Для такого случая осталось рассмотреть еще два момента: показ прогресса во время загрузки и отображение ошибки в случае неудачного запроса. Оба этих сценария обрабатываются эквивалентно, поэтому рассмотрим только показ прогресса.
Для этого мы, естественно, создадим метод с аннотацией Bindable, который будет возвращать текущее состояние загрузки. Для этого определим два метода во ViewModel, которые будут отвечать за получение и изменение состояния загрузки:
@Bindable public boolean isLoading() { return mIsLoading; } @VisibleForTesting void setLoading(boolean isLoading) { mIsLoading = isLoading; notifyPropertyChanged(BR.loading); }
Теперь мы можем использовать это свойство в xml-разметке:
<ProgressBar android:layout_width="@dimen/progress_bar_size" android:layout_height="@dimen/progress_bar_size" android:layout_gravity="center_horizontal" android:layout_marginTop="@dimen/margin_medium" android:visibility="@{model.loading ? View.VISIBLE : View.GONE}"/>
Этот ProgressBar отображается только в том случае, в модели выполняется загрузка данных. Для определения видимости основного контента экрана, разумеется, используется тот же тернарный оператор, но в другом порядке:
android:visibility="@{model.loading ? View.GONE : View.VISIBLE}"
Обработка ошибок, как уже было сказано, выполняется аналогичным образом. Таким образом, мы без особых сложностей реализовали один экран полностью без использования View-объектов, сохранив при этом модульность и возможность тестирования, в чем мы убедимся далее.
Последний вопрос, который нам осталось рассмотреть, – это тестирование ViewModel. Поскольку ViewModel не зависит от других классов (если не считать LifecycleHandler), нам требуется замокать только ответы сервера, или же, как мы обычно делали при тестировании делегата, репозиторий.
Начнем с инициализации объектов в тестовом классе:
private DayViewModel mViewModel; @Before public void setUp() throws Exception { LifecycleHandler lifecycleHandler = new MockLifecycleHandler(); mViewModel = Mockito.spy(new DayViewModel(lifecycleHandler)); }
В методе setUp есть одна особенность – это метод Mockito.spy. По сути, это такой же метод, как и Mockito.mock, но он позволяет отслеживать вызовы методов у существующего объекта, а не создавать новый. При этом он также позволяет переопределять вызовы методов, но без переопределения все методы будут работать как обычно.
Нам необходимо отслеживать уже существующий объект, так как мы хотим проверять вызовы методов этого объекта (например, notifyPropertyChanged). И мы не можем создавать объект с помощью Mockito.mock, так как в таком случае мы не протестируем реальный объект. Поэтому метод Mockito.spy – единственный возможный вариант.
Напишем тест для проверки, что в случае успешной загрузки все поля (изображение и тексты) были подставлены в нужные UI-элементы. Для этого проверим, что после успешной загрузки ViewModel вызывает метод notifyPropertyChanged:
@Test public void testDataLoaded() throws Exception { NasaRepository repository = Mockito.mock(NasaRepository.class); Observable<DayPicture> observable = Observable.just(new DayPicture()); Mockito.when(repository.dayPicture()).thenReturn(observable); RepositoryProvider.setNasaRepository(repository); mViewModel.init(); Mockito.verify(mViewModel).notifyPropertyChanged(BR.title); Mockito.verify(mViewModel).notifyPropertyChanged(BR.explanation); Mockito.verify(mViewModel).notifyPropertyChanged(BR.url); }
После вызова метода init ViewModel должна загрузить все данные из репозитория и отобразить их, что мы и проверяем. Да, разумеется, мы проверяем только факт того, что был вызван метод notifyPropertyChanged с нужным параметром, и не проверяем корректность установки значений в UI-элементы, но это та цена, за которую приходится платить при использовании Data Binding. Чтобы хоть как-то проверить корректность отображаемых значений, мы можем проверить, что Bindable методы возвращают нужные значения:
mViewModel.init(); assertEquals("Title", mViewModel.getTitle()); assertEquals("Explanation", mViewModel.getExplanation()); assertEquals("Url", mViewModel.getUrl());
Это первый вариант тестирования ViewModel. Как уже было сказано, есть и другая возможность – package-private методы во ViewModel. Все методы обработки результата, которые передают данные во View, можно объявить с видимостью по умолчанию, и тогда эти методы можно будет использовать в тестовом классе. Такой подход мы использовали для отображения процесса загрузки:
@VisibleForTesting void setLoading(boolean isLoading) { mIsLoading = isLoading; notifyPropertyChanged(BR.loading); }
Аннотация VisibleForTesting как раз и служит для указания того факта, что чуть более широкая видимость метода нужна только для тестирования. И теперь в тестовом методе мы можем проверять вызов этого метода, что немного упрощает тестирование:
@Test public void testLoadingShown() throws Exception { NasaRepository repository = Mockito.mock(NasaRepository.class); Observable<DayPicture> observable = Observable.just(new DayPicture()); Mockito.when(repository.dayPicture()).thenReturn(observable); RepositoryProvider.setNasaRepository(repository); mViewModel.init(); Mockito.verify(mViewModel).setLoading(true); Mockito.verify(mViewModel).setLoading(false); }
Здесь мы выполняем загрузку данных и проверяем, что у ViewModel метод setLoading сначала вызывается с параметром true, чтобы отобразить процесс загрузки, а после процесс загрузки скрывается. Это еще один способ, позволяющий тестировать ViewModel.
Нужно сказать, что в таком примере мы не встретились с серьезными проблемами. Как всегда, они начинаются при масштабировании системы. И, к сожалению, в случае такого использования MVVM проблемы будут возникать куда чаще, чем при использовании стандартного подхода с MVP. Существует много задач, которые сложно решить в таком подходе, начиная с показа диалога, заканчивая открытием нового экрана из ViewModel. Конечно, все такие проблемы можно решить, но использовать предложенный подход нужно аккуратно. И, разумеется, нельзя забывать и о совместном использовании MVP и Data Binding, о чем говорилось в начале.
Практика
- Скачайте проект PictureOfDayMVVM
- Создайте экран списка картинок за все даты (почти бесконечный)
- Реализуйте подгрузку новых картинок при скролле
- Используйте рассмотренный паттерн MVVM и Data Binding
- Реализуйте тестирование ViewModel
Ссылки и полезные ресурсы
- Приложения из репозитория:
- PictureOfDayMVVM – пример приложения, написанного с использованием паттерна MVVM и DataBinding и практическое задание.
- PopularMoviesDataBinding – более сложный пример по архитектуре, рассмотренной в лекции.
- Статья про паттерн MVVM с сайта MSDN.
- Хорошая статья про паттерн MVVM.
- Документация по Data Binding.
- Интересный доклад про Data Binding.
- Видео про Data Binding с Google I/O 2016.
- Хорошая статья про Data Binding.
- Статья про использование паттерна MVVM вместе с MVP.
- Пример использования Data Binding и MVP от Google.