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

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

Каждый раз при виде километров строк с findViewById и «тернарок» с visibility у тебя дергается глаз? Знай — выход есть! Подробности — под катом.

План

  1. Что за зверь
  2. Интеграция
  3. Пробный заезд
  4. Это еще цветочки
  5. Пишем себе в удовольствие
  6. Дальше — интересней
  7. Сладости в придачу
  8. Ложка дегтя
  9. Заключение

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>

Давайте вкратце пройдемся по всем странностям в разметке:

  1. <layout> — этим тегом мы обрамляем корневой элемент и тем самым даем понять компилятору, что файл разметки относится к биндингу. Стоит также отметить, что он всегда должен находиться в корне.
  2. <data> — тег, который лежит внутри layout и служит оберткой для переменных, используемых в разметке.
  3. <variable> — содержит в себе name и type, в которых описывается название переменной и ее полное имя соответственно (включая название пакета).
  4. @{} — контейнер, в котором описывается выражение. К примеру, форматирование имени и фамилии в одну строку или простое отображение поля. Мы еще вернемся к теме выражений немного позже.

Теперь, по завершению всех необходимых шагов, давайте посмотрим, как будет выглядеть наш класс активности:

@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).

Посмотрим на результат:

Начало работы с DataBinding в Android

Да, этого кода достаточно для того, чтобы отобразить нужные нам данные :) Но...

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’ом на секунду и возвращаться обратно. Самое время запустить и посмотреть на результат.

Пример использования DataBinding

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-событий:

Имитация realtime-событий с помощью DataBinding

8. Ложка дегтя

На сегодняшний день DataBinding находится на стадии разработки, а потому при использовании проявляется целый ряд неприятностей. К ним можно отнести отсутствие двустороннего биндинга и некоторых полей «из коробки». Помимо этого есть и проблемы с поддержкой в Android Studio: часто выражения в разметке распознаются как ошибки, пропадает класс BR или весь пакет binding целиком (решается с помощью Build → Clean Project). В разметке есть проблемы с кодированием (к примеру, вместо оператора «&&» приходится писать «&amp;&amp;») и прочие. Но стоит рассматривать эти проблемы как временные неудобства, ведь разработка ведется активно, а технология стоит того, чтобы немножко потерпеть :)

9. Заключение

Можно еще долго описывать возможности этой замечательной библиотеки, ведь они практически безграничны, но все хорошее рано или поздно заканчивается. Надеюсь, у нас получилось заинтересовать вас и наш опыт будет вам полезен.

В следующей статье мы рассмотрим, как лучше всего реализовать MVVM-архитектуру приложения, которая так тесно связана с DataBiding.

Спасибо за внимание!

Исходный код приложения на GitHub.

Нужен MVP, разработка под iOS, Android или прототип приложения? Ознакомьтесь с нашим портфолио и сделайте заказ уже сегодня!