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 с нужными нам полями:

publicclass User {
 
       /* constructor */
 
    privateString name;privateString surname;privateString status;privateboolean 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
protectedvoid 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")publicstaticvoid bindOnClick(View view, finalRunnable runnable){
    view.setOnClickListener(v -> runnable.run());}

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

@BindingAdapter({"android:src", "app:error"})publicstaticvoid loadImage(ImageView view, String url, Drawable error){
    Picasso.with(view.getContext())
            .load(url)
            .error(error)
            .into(view);}

Но бывают случаи, когда необходимо преобразовать типы автоматически (например boolean в int (Visibility)). Тогда на смену адаптеру приходит @BindingConversion:

@BindingConversion
publicstaticint convertBooleanToVisibility(boolean visible){return visible ?View.VISIBLE:View.GONE;}

Он отличается тем, что имеет возвращаемый тип, который потом применяется к атрибуту, в то время как адаптер полностью уводит поведение на себя.

5. Пишем себе в удовольствие

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

Итак, первым делом добавим фотографию пользователя и возможность добавить его в друзья. Допишем необходимые поля в модель User:

privateString photo;privateboolean 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 для профиля:

publicclass ProfileViewModel extends BaseObservable {
 
    publicstaticfinalint LOADING_SHORT =1000;
 
    privateboolean isLoaded;privateboolean isFriend;
 
    public ProfileViewModel(boolean isFriend){this.isFriend= isFriend;this.isLoaded=true;}
 
    @Bindable
    publicboolean getIsLoaded(){returnthis.isLoaded;}
 
    publicvoid setIsLoaded(boolean isLoaded){this.isLoaded= isLoaded;
        notifyPropertyChanged(BR.isLoaded);}
 
    @Bindable
    publicboolean getIsFriend(){returnthis.isFriend;}
 
    publicvoid setIsFriend(boolean isFriend){this.isFriend= isFriend;
        notifyPropertyChanged(BR.isFriend);}
 
    publicvoid changeFriendshipStatus(){
        load(()-> setIsFriend(!isFriend));}
 
    privatevoid 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:

publicclass ProfileViewModel {
 
    publicstaticfinalint LOADING_SHORT =1000;
 
    publicfinal  ObservableBoolean isLoaded =new ObservableBoolean(true);publicfinal ObservableBoolean isFriend =new ObservableBoolean();public ProfileViewModel(boolean isFriend){
	    isFriend.set(isFriend);}
 
    publicvoid changeFriendshipStatus(){
        load(()-> isFriend.set(!isFriend.get()));}
 
    privatevoid load(Runnable onLoaded){
        isLoaded.set(false);new Handler().postDelayed(()->{
            isFriend.set(!isFriend.get());
            isLoaded.set(true);}, LOADING_SHORT);}}

Нетрудно заметить, насколько меньше стало кода, хоть он и выполняет все те же функции, что и раньше. Стоит заметить, что почти для каждого примитивного типа есть его Observable-аналог. В свою очередь мы использовали ObservableBoolean.

В библиотеке, к сожалению, еще не реализован двусторонний биндинг, но не стоит расстраиваться — у нас есть все, что бы справиться с этой задачей самостоятельно! Двусторонний биндинг — это когда не только данные влияют на то, что отображается во View, но и наоборот — когда введенные пользователем данные изменяют модель. Давайте попробуем сделать это на примере EditText.

Первым делом обратим внимание на то, что по каким-то причинам отсутствует ObservableString, а потому создадим свой:

publicclass ObservableString extends BaseObservable {
 
    privateString value ="";
 
    public ObservableString(String value){this.value= value;}
 
    public ObservableString(){}
 
    publicString get(){return value !=null? value :"";}
 
    publicvoid set(String value){if(value ==null) value ="";if(!this.value.contentEquals(value)){this.value= value;
            notifyChange();}}
 
    publicboolean isEmpty(){return value ==null|| value.isEmpty();}
 
    publicvoid clear(){ set(null);}}

Логика простая и в пояснениях, не нуждается, поэтому перейдем к реализации адаптера. Основной трюк в том, чтобы добавить TextWatcher к EditText и в колбэке обновлять наш ObservableString:

@BindingAdapter("android:text")publicstaticvoid 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 — это универсальный адаптер для простых списков. Один и на весь проект. Больше не нужно создавать их каждый раз отдельно для каждого списка :) Давайте взглянем, как это происходит:

publicclass RecyclerBindingAdapter<T>extends RecyclerView.Adapter<RecyclerBindingAdapter.BindingHolder>{privateint 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);returnnewBindingHolder(v);}
    @Override
    publicvoid 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
    publicint getItemCount(){return items.size();}publicvoid setOnItemClickListener(OnItemClickListener<T> onItemClickListener){this.onItemClickListener= onItemClickListener;}publicinterface OnItemClickListener<T>{void onItemClick(int position, T item);}publicstaticclassBindingHolderextends RecyclerView.ViewHolder{private ViewDataBinding binding;publicBindingHolder(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:

publicclass RecyclerConfiguration extends BaseObservable {
 
    private RecyclerView.LayoutManager layoutManager;private RecyclerView.ItemAnimator itemAnimator;private RecyclerView.Adapter adapter;
 
    /* @Bindable getters *//* notifyPropertyChanged setters */
 
    @BindingAdapter("app:configuration")publicstaticvoid 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 или прототип приложения? Ознакомьтесь с нашим портфолио и сделайте заказ уже сегодня!