Каждый раз при виде километров строк с 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 с нужными нам полями:
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>
Давайте вкратце пройдемся по всем странностям в разметке:
<layout>
— этим тегом мы обрамляем корневой элемент и тем самым даем понять компилятору, что файл разметки относится к биндингу. Стоит также отметить, что он всегда должен находиться в корне.<data>
— тег, который лежит внутри layout и служит оберткой для переменных, используемых в разметке.<variable>
— содержит в себе name и type, в которых описывается название переменной и ее полное имя соответственно (включая название пакета).@{}
— контейнер, в котором описывается выражение. К примеру, форматирование имени и фамилии в одну строку или простое отображение поля. Мы еще вернемся к теме выражений немного позже.
Теперь, по завершению всех необходимых шагов, давайте посмотрим, как будет выглядеть наш класс активности:
@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).
Посмотрим на результат:
Да, этого кода достаточно для того, чтобы отобразить нужные нам данные :) Но...
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’ом на секунду и возвращаться обратно. Самое время запустить и посмотреть на результат.
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-событий:
8. Ложка дегтя
На сегодняшний день DataBinding находится на стадии разработки, а потому при использовании проявляется целый ряд неприятностей. К ним можно отнести отсутствие двустороннего биндинга и некоторых полей «из коробки». Помимо этого есть и проблемы с поддержкой в Android Studio: часто выражения в разметке распознаются как ошибки, пропадает класс BR или весь пакет binding целиком (решается с помощью Build → Clean Project). В разметке есть проблемы с кодированием (к примеру, вместо оператора «&&» приходится писать «&&») и прочие. Но стоит рассматривать эти проблемы как временные неудобства, ведь разработка ведется активно, а технология стоит того, чтобы немножко потерпеть :)
9. Заключение
Можно еще долго описывать возможности этой замечательной библиотеки, ведь они практически безграничны, но все хорошее рано или поздно заканчивается. Надеюсь, у нас получилось заинтересовать вас и наш опыт будет вам полезен.
В следующей статье мы рассмотрим, как лучше всего реализовать MVVM-архитектуру приложения, которая так тесно связана с DataBiding.
Спасибо за внимание!
Исходный код приложения на GitHub.
Нужен MVP, разработка под iOS, Android или прототип приложения? Ознакомьтесь с нашим портфолио и сделайте заказ уже сегодня!