DataBinding: ускоряем разработку приложений под Android

Каждый раз при виде километров строк с findViewById и «тернарок» с visibility у тебя дергается глаз? Знай — выход есть! Подробности — под катом.
План
- Что за зверь
- Интеграция
- Пробный заезд
- Это еще цветочки
- Пишем себе в удовольствие
- Дальше — интересней
- Сладости в придачу
- Ложка дегтя
- Заключение
1. Что за за зверь
Ни для кого не секрет, что самая скучная часть разработки приложений — это описывание поведения UI в зависимости от логики изменения данных. Сколько слез было пролито при написании (а в последствии и в поддержке) тривиального и почти бесполезного «треша», который занимал десятки, а то и сотни строк в классах практически каждой активности или фрагмента. О читабельности и вовсе не стоит говорить — разобраться, а тем более найти баг (который очень-то просто допустить) в джунглях однообразной логики — то еще приключение.
Вывод очевиден: нужно каким-то образом это автоматизировать. Как раз эту задачу и выполняет DataBinding — библиотека, с помощью которой максимально минимизируется связующий код между логикой приложения и ее представлением.
2. Интеграция
Чтобы продемонстрировать возможности сего чуда мы напишем небольшое приложение на примере профиля пользователя. Но прежде чем приступить давайте подключим наш волшебный инструмент к проекту.
Для интеграции биндинга нам необходимо использовать плагин Gradle версии не ниже, чем 1.5.0, поэтому обновим файл build.gradle проекта, добавив следующую строку:
buildscript { ... dependencies { classpath 'com.android.tools.build:gradle:1.5.0' } }
Далее остается добавить элемент dataBinding в модульный build.gradle:
android { ... dataBinding { enabled = true } }
Синхронизируем Gradle и радуемся передовым технологиям в нашем проекте :)
3. Пробный заезд
Ну что ж, теперь можно создавать наш пример. Начнем с простого — создадим активность, в которой будут отображаться основные данные о пользователе, а именно: имя, фамилия, статус и индикатор «онлайн».
Ничего сложного в задаче нет, так что приступим к реализации. Создадим модель User с нужными нам полями:
public class User { /* constructor */ private String name; private String surname; private String status; private boolean isOnline; /* getters and setters */ }
Следующим шагом создадим разметку. Ограничимся простыми TextView для отображения имени и статуса, и View — для индикатора:
<layout xmlns:android="https://schemas.android.com/apk/res/android" > <data> <variable name="user" type="com.example.models.User" /> </data> <RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent"> <View android:layout_width="@dimen/indicator_size" android:layout_height="@dimen/indicator_size" android:background="@{user.isOnline ? @drawable/shape_online : @drawable/shape_offline}" .../> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.name + ' ' + user.surname}" .../> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{user.status}" .../> </RelativeLayout> </layout>
Давайте вкратце пройдемся по всем странностям в разметке:
<layout>
— этим тегом мы обрамляем корневой элемент и тем самым даем понять компилятору, что файл разметки относится к биндингу. Стоит также отметить, что он всегда должен находиться в корне.<data>
— тег, который лежит внутри layout и служит оберткой для переменных, используемых в разметке.<variable>
— содержит в себе name и type, в которых описывается название переменной и ее полное имя соответственно (включая название пакета).-
@{}
— контейнер, в котором описывается выражение. К примеру, форматирование имени и фамилии в одну строку или простое отображение поля. Мы еще вернемся к теме выражений немного позже.
Теперь, по завершению всех необходимых шагов, давайте посмотрим, как будет выглядеть наш класс активности:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ActivityMainBinding binding = DataBindingUtil.setContentView(this, LAYOUT); binding.setUser(Demo.getUser()); }
Здесь с помощью DataBindingUtil мы инициализируем лэйаут и в ответ получаем любезно сгенерированный для нас ActivityMainBinding (по умолчанию Binding-класс генерируется на основе имени файла макета переведенного в CamelCase с добавлением суффикса «Binding»), в котором и хранятся все ссылки на наши элементы интерфейса. Для того, чтобы заполнить их данными нужно просто задать объект пользователя с помощью метода setUser (название этого метода зависит от именования переменной в поле name блока variable).
Посмотрим на результат:
Да, этого кода достаточно для того, чтобы отобразить нужные нам данные :) Но...
4. Это еще цветочки
У DataBinding есть собственный язык выражений для файлов разметки. Он в точности соответствует выражениям в Java и впечатляет своими возможностями. Ниже приведен перечень всех доступных операторов:
- математические операторы;
- конкатенация строк;
- логические операторы;
- бинарные операторы;
- унарные операторы;
- битовые сдвиги;
- операторы сравнения;
- instanceof;
- группирование;
- литералы: строковые, числовые, символьные, null;
- приведение типов;
- вызов методов и доступ к полям;
- доступ к элементам массива и List;
- тернарный оператор «?:».
Однако, для того, чтобы файл разметки не превратился в место, где можно писать всю возможную логику, есть и несколько неподдерживаемых операторов:
- this;
- super;
- new;
- явное выполнение типизированных методов .
Нельзя не упомянуть и так называемый Null Coalescing Operator «??», который достаточно лаконично позволяет решить множество проверок на null. Вот как это выглядит:
android:text="@{user.status ?? user.lastSeen}" android:text="@{user.status != null ? user.status : user.lastSeen}"
Обе строки кода эквивалентны. Ну разве не находка? :)
Код, сгенерированный библиотекой DataBinding, также автоматически проверяет все объекты и предотвращает NullPointerException. Например, если в выражении @{user.status} поле status является null, то его значением будет являться значение по умолчанию — т.е. «null». Этот принцип работает и для примитивных типов данных.
Знакомый нам тег <data>
имеет еще одно свойство — с его помощью можно импортировать типы, необходимые нам для работы. К тому же, их можно сокращать для удобства и экономии места с помощью alias.
<data> <import type="android.view.View" alias="v"/> </data> <View android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="@{user.isNinja ? v.GONE : v.VISIBLE}"/>
Работа с ресурсами здесь заслуживает отдельных похвал — практически все возможные ресурсы можно вызывать напрямую и комбинировать с логическими операторами. В примере с индикатором «онлайн» мы уже видели одну из реализаций — в атрибут android:background подставлялся нужный Drawable в зависимости от того, находится ли пользователь в сети. Так вот, представьте себе гибкость разметки, используя все допустимые ссылки:
Тип | Нормальная ссылка | Ссылка в выражении |
String[] | @array | @stringArray |
int[] | @array | @intArray |
TypedArray | @array | @typedArray |
Animator | @animator | @animator |
StateListAnimator | @animator | @stateListAnimator |
Color int | @color | @color |
ColorStateList | @color | @colorStateList |
Помимо всего перечисленного, не остается без внимания и @BindingAdapter. С его помощью можно переопределять поведение существующих атрибутов и создавать свои, не думая о attrs.xml.
Для этого нужно создать публичный статический метод, на вход которому будет приходить View нужного нам типа и значение, которое мы указываем в разметке. Сам метод нужно пометить аннотацией @BindingAdapter и в ее теле указать строку с именем атрибута.
К примеру, такой адаптер позволяет «повесить» на view любой метод без параметров в качестве OnClickListener:
@BindingAdapter("app:onClick") public static void bindOnClick(View view, final Runnable runnable) { view.setOnClickListener(v -> runnable.run()); }
Адаптеры также можно создавать для нескольких параметров одновременно. Например, так можно задать url на изображение и ресурс на случай ошибки при загрузке:
@BindingAdapter({"android:src", "app:error"}) public static void loadImage(ImageView view, String url, Drawable error) { Picasso.with(view.getContext()) .load(url) .error(error) .into(view); }
Но бывают случаи, когда необходимо преобразовать типы автоматически (например boolean в int (Visibility)). Тогда на смену адаптеру приходит @BindingConversion:
@BindingConversion public static int convertBooleanToVisibility(boolean visible) { return visible ? View.VISIBLE : View.GONE; }
Он отличается тем, что имеет возвращаемый тип, который потом применяется к атрибуту, в то время как адаптер полностью уводит поведение на себя.
5. Пишем себе в удовольствие
Расширим наше приложение дополнительными функциями. Во-первых, в профиле отсутствует фотография, что немаловажно. Во-вторых, отсутствие каких-либо действий делают его практически бесполезным.
Итак, первым делом добавим фотографию пользователя и возможность добавить его в друзья. Допишем необходимые поля в модель User:
private String photo; private boolean isFriend;
Затем разместим в разметке ImageView для аватара профиля:
<ImageView android:layout_width="@dimen/avatar_size" android:layout_height="@dimen/avatar_size" android:src="@{user.photo}"/>
Для загрузки изображения сработает написанный нами ранее адаптер loadImage. Теперь добавим кнопки добавления и удаления из френдлиста:
<RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/add_as_friend" android:visibility="@{!viewModel.isFriend}" app:onClick="@{viewModel.changeFriendshipStatus}"/> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/already_friends" android:visibility="@{viewModel.isFriend}" app:onClick="@{viewModel.changeFriendshipStatus}"/> </RelativeLayout>
Вы, наверное, заметили, что объект user был переименован на viewModel, хотя на самом деле это совсем другой объект. ViewModel — это связующее звено между View и моделью данных, в которой описывается вся логика поведения. О модели MVVM (Model-View-ViewModel) мы поговорим в следующий статье и подробно разберем как правильно ее реализовать, а пока просто запомним, что логика поведения должна размещаться в отдельном объекте. Вот наша VM для профиля:
public class ProfileViewModel extends BaseObservable { public static final int LOADING_SHORT = 1000; private boolean isLoaded; private boolean isFriend; public ProfileViewModel(boolean isFriend) { this.isFriend = isFriend; this.isLoaded = true; } @Bindable public boolean getIsLoaded() { return this.isLoaded; } public void setIsLoaded(boolean isLoaded) { this.isLoaded = isLoaded; notifyPropertyChanged(BR.isLoaded); } @Bindable public boolean getIsFriend() { return this.isFriend; } public void setIsFriend(boolean isFriend) { this.isFriend = isFriend; notifyPropertyChanged(BR.isFriend); } public void changeFriendshipStatus() { load(() -> setIsFriend(!isFriend)); } private void load(Runnable onLoaded) { setIsLoaded(false); new Handler().postDelayed(() -> { setIsFriend(!isFriend); setIsLoaded(true); }, LOADING_SHORT); } }
Как видно, VM наследуется от BaseObservable. Это дает нам возможность оповещать биндинг о изменениях внутри с помощью notifyPropertyChanged, куда передается BR.variable. Название переменной генерируется так же, как id в стандартном R-классе, а помечаются они аннотацией @Bindable (в нашем случае — в геттерах).
Поле isLoaded будет служить флагом для индикатора загрузки, который будет переключаться на 1 секунду каждый раз, при вызове changeFriendshipStatus. Осталось только немного модифицировать лэйаут с кнопками и добавить ProgressBar:
<RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="@{viewModel.isLoaded}"> <!-- buttons --> </RelativeLayout> <ProgressBar android:layout_width="@dimen/small_progressbar_size" android:layout_height="@dimen/small_progressbar_size" android:layout_gravity="center" android:visibility="@{!viewModel.isLoaded}"/>
Теперь каждый раз, когда мы будем нажимать на кнопку удаления или добавления в друзья — RelativeLayout будет заменяться ProgressBar’ом на секунду и возвращаться обратно. Самое время запустить и посмотреть на результат.
6. Дальше — интересней
Вам тоже не понравились громоздкие геттеры и сеттеры @Bindable и notifyPropertyChanged во ViewModel? Они жутко засоряют код и заставляют заниматься рутинной работой, от которой мы как раз-таки и хотели избавиться с помощью биндинга. Но не все так плохо — у нас есть ObservableField<T>.
ObservableField представляют собой автономные наблюдаемые объекты, которые имеют одно поле. К нему можно обращаться с помощью методов get() и set(), которые автоматически оповещают View об изменениях. Чтобы использовать его, нужно просто создать public final поле в классе VM. Для этого обновим нашу ProfileViewModel:
public class ProfileViewModel { public static final int LOADING_SHORT = 1000; public final ObservableBoolean isLoaded = new ObservableBoolean(true); public final ObservableBoolean isFriend = new ObservableBoolean(); public ProfileViewModel(boolean isFriend) { isFriend.set(isFriend); } public void changeFriendshipStatus() { load(() -> isFriend.set(!isFriend.get())); } private void load(Runnable onLoaded) { isLoaded.set(false); new Handler().postDelayed(() -> { isFriend.set(!isFriend.get()); isLoaded.set(true); }, LOADING_SHORT); } }
Нетрудно заметить, насколько меньше стало кода, хоть он и выполняет все те же функции, что и раньше. Стоит заметить, что почти для каждого примитивного типа есть его Observable-аналог. В свою очередь мы использовали ObservableBoolean.
В библиотеке, к сожалению, еще не реализован двусторонний биндинг, но не стоит расстраиваться — у нас есть все, что бы справиться с этой задачей самостоятельно! Двусторонний биндинг — это когда не только данные влияют на то, что отображается во View, но и наоборот — когда введенные пользователем данные изменяют модель. Давайте попробуем сделать это на примере EditText.
Первым делом обратим внимание на то, что по каким-то причинам отсутствует ObservableString, а потому создадим свой:
public class ObservableString extends BaseObservable { private String value = ""; public ObservableString(String value) { this.value = value; } public ObservableString() { } public String get() { return value != null ? value : ""; } public void set(String value) { if (value == null) value = ""; if (!this.value.contentEquals(value)) { this.value = value; notifyChange(); } } public boolean isEmpty() { return value == null || value.isEmpty(); } public void clear() { set(null); } }
Логика простая и в пояснениях, не нуждается, поэтому перейдем к реализации адаптера. Основной трюк в том, чтобы добавить TextWatcher к EditText и в колбэке обновлять наш ObservableString:
@BindingAdapter("android:text") public static void bindEditText(EditText view, final ObservableString observableString) { Pair<ObservableString, TextChangeListener> pair = (Pair) view.getTag(R.id.bound_observable); if (pair == null || pair.first != observableString) { if (pair != null) view.removeTextChangedListener(pair.second); TextChangeListener watcher = new TextChangeListener( (s, start, before, count) -> observableString.set(s.toString())); view.setTag(R.id.bound_observable, new Pair<>(observableString, watcher)); view.addTextChangedListener(watcher); } String newValue = observableString.get(); if (!view.getText().toString().equals(newValue)) view.setText(newValue); }
Теперь можно просто указать в XML наш ObservableString и дело в шляпе!
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{viewModel.status}" style="@style/StatusTextView"/>
7. Сладости в придачу
За время использования библиотеки у нашей команды появился ряд наработок, которые каждый день упрощают нам жизнь. Вот некоторые из них:
RecyclerBindingAdapter — это универсальный адаптер для простых списков. Один и на весь проект. Больше не нужно создавать их каждый раз отдельно для каждого списка :) Давайте взглянем, как это происходит:
public class RecyclerBindingAdapter<T> extends RecyclerView.Adapter<RecyclerBindingAdapter.BindingHolder> { private int holderLayout, variableId; private AbstractList<T> items = new ArrayList<>(); private OnItemClickListener<T> onItemClickListener; public RecyclerBindingAdapter(int holderLayout, int variableId, AbstractList<T> items) { this.holderLayout = holderLayout; this.variableId = variableId; this.items = items; } @Override public RecyclerBindingAdapter.BindingHolder onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()) .inflate(holderLayout, parent, false); return new BindingHolder(v); } @Override public void onBindViewHolder(RecyclerBindingAdapter.BindingHolder holder, int position) { final T item = items.get(position); holder.getBinding().getRoot().setOnClickListener(v -> { if (onItemClickListener != null) onItemClickListener.onItemClick(position, item); }); holder.getBinding().setVariable(variableId, item); } @Override public int getItemCount() { return items.size(); } public void setOnItemClickListener(OnItemClickListener<T> onItemClickListener) { this.onItemClickListener = onItemClickListener; } public interface OnItemClickListener<T> { void onItemClick(int position, T item); } public static class BindingHolder extends RecyclerView.ViewHolder { private ViewDataBinding binding; public BindingHolder(View v) { super(v); binding = DataBindingUtil.bind(v); } public ViewDataBinding getBinding() { return binding; } }
А создать его можно всего в одну строку:
new RecyclerBindingAdapter<>(R.layout.item_holder, BR.data, list);
BR.data — это имя variable в xml-файле, а list — наша выборка. Задаем адаптер RecyclerView и забываем о головной боли раз и навсегда. Забегая наперед, задам вопрос: а как нам конфигурировать RecyclerView не обращаясь к биндингу напрямую? Здесь-то и напрашивается следующая фича — RecyclerConfiguration:
public class RecyclerConfiguration extends BaseObservable { private RecyclerView.LayoutManager layoutManager; private RecyclerView.ItemAnimator itemAnimator; private RecyclerView.Adapter adapter; /* @Bindable getters */ /* notifyPropertyChanged setters */ @BindingAdapter("app:configuration") public static void configureRecyclerView(RecyclerView recyclerView, RecyclerConfiguration configuration) { recyclerView.setLayoutManager(configuration.getLayoutManager()); recyclerView.setItemAnimator(configuration.getItemAnimator()); recyclerView.setAdapter(configuration.getAdapter()); } }
Эта простая обертка не засоряет код ObservableFileds позволяет указывать конфигурацию прямо в XML, предварительно заполнив ее из кода.
Еще одна интересная практика — это тег <include>
. В биндинге он обретает совершенно другой смысл, а именно заменяет простые CustomView. Давайте добавим в наш проект счетчики фотографий, друзей и «лайков». Элемент разметки будет представлять из себя заглавие и, собственно, сам счетчик. Но чтобы не дублировать код вынесем его в item_counter.xml:
<layout xmlns:android="https://schemas.android.com/apk/res/android"> <data> <variable name="count" type="int"/> <variable name="title" type="String"/> </data> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="center"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{Integer.toString(count)}"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{title}"/> </LinearLayout> </layout>
Для того, чтобы импорт работал, разметка должна быть обернута в тег <layout>
. А если добавить <data>
, можно вынести все значения как переменные. Самое прекрасное то, что задавать эти переменные можно снаружи с помощью атрибутов с таки же именем! Вот как передаются данные в счетчик друзей:
<include layout="@layout/item_counter" android:layout_width="wrap_content" android:layout_height="wrap_content" app:title="@{@plurals/friends(viewModel.friendsCount)}" app:count="@{viewModel.friendsCount}"/>
Немного дописав ViewModel, мы получим вот такую имитацию realtime-событий:
8. Ложка дегтя
На сегодняшний день DataBinding находится на стадии разработки, а потому при использовании проявляется целый ряд неприятностей. К ним можно отнести отсутствие двустороннего биндинга и некоторых полей «из коробки». Помимо этого есть и проблемы с поддержкой в Android Studio: часто выражения в разметке распознаются как ошибки, пропадает класс BR или весь пакет binding целиком (решается с помощью Build → Clean Project). В разметке есть проблемы с кодированием (к примеру, вместо оператора «&&» приходится писать «&&») и прочие. Но стоит рассматривать эти проблемы как временные неудобства, ведь разработка ведется активно, а технология стоит того, чтобы немножко потерпеть :)
9. Заключение
Можно еще долго описывать возможности этой замечательной библиотеки, ведь они практически безграничны, но все хорошее рано или поздно заканчивается. Надеюсь, у нас получилось заинтересовать вас и наш опыт будет вам полезен.
В следующей статье мы рассмотрим, как лучше всего реализовать MVVM-архитектуру приложения, которая так тесно связана с DataBiding.
Спасибо за внимание!
Исходный код приложения на GitHub.
Нужен MVP, разработка под iOS, Android или прототип приложения? Ознакомьтесь с нашим портфолио и сделайте заказ уже сегодня!