Введение


Профессионально андроид-разработкой занимаюсь чуть больше года, до этого разрабатывал по Windows Phone и мне понравилась возможность связывать данные из вью модели с самим View при помощи механизма Bindings. А после изучения RX, многие задачи стали решаться более чисто, вью-модель полностью отделилась от View. Она стала оперировать только моделью, совсем не заботясь о том, как она будет отображаться.

В Android такой строгости я не заметил, Activity или Fragment как простейшие представители контроллера чаще всего имеют полный доступ как ко View, так и к модели, зачастуя решая, какой View будет видим, решая таким образом чисто вьюшные задачи. Поэтому я довольно радостно воспринял новость о появлении Data Binding в Android на прошедшем Google IO.



Пока что это только бета релиз, но уже можно протестировать функционал и увидеть направление, в котором двигаются разработчики из Google.

Начало


Я использую Android Studio 1.3. Data binding поддерживает Android 2.1 и выше (API level 7+).

Для сборки используется новый android плагин для Gradle (нужна версия 1.3.0-beta1 и старше). Так как связи отрабатываются во время компиляции, нам понадобиться ещё один плагин к Gradle 'com.android.databinding:dataBinder:1.0-rc0'. В отличие от того же Windows Phone где механизм привязок реализован глубоко по средством DependencyProperty и в RealTime, в Android эта функция реализуется как бы поверх обычных свойств, во время компиляции и дополнительной кодогенерации, поэтому в случае ошибок будьте готовы разбирать ответ от компилятора.

Итак, заходим в файл build.gradle, который лежит в корневом каталоге проекта (в нём идут настройки Gradle для всего проекта). В блоке dependencies вставляем:

dependencies {
    classpath 'com.android.tools.build:gradle:1.3.0-beta2'
    classpath 'com.android.databinding:dataBinder:1.0-rc0'
}

Теперь подключим плагин к конкретному модулю, откроем build.gradle файл, который лежит внутри модуля. По умолчанию app/build.gradle и добавим строчку:

apply plugin: 'com.android.databinding'

Настройка Layout


Мы должны обернуть наш внешний View в тег <layout>. Cтудия сейчас подчеркивает его красным и требует ему назначить атрибуты высоты и ширины; игнорируем её, иначе получим ошибку дубликации атрибутов. Этим действием вы сказали плагину биндинга генерировать класс связывания. По умолчанию имя этого класс будет выбираться на основе названия файла разметки. Для main_activity — MainActivityBinding, который будет доступен для импорта из пакета, для меня com.georgeci.bindingssample.databinding.ActivityMainBinding

Важно: если в разметке не будет View с Id или тега <data> класс биндинг не создастся.

<layout xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
       <variable name="user" type="com.georgeci.bindingssample.User"/>
   </data>
   <LinearLayout
                   xmlns:tools="http://schemas.android.com/tools"
                   android:layout_width="match_parent"
                   android:layout_height="match_parent"
                   android:paddingLeft="@dimen/activity_horizontal_margin"
                   android:paddingRight="@dimen/activity_horizontal_margin"
                   android:paddingTop="@dimen/activity_vertical_margin"
                   android:paddingBottom="@dimen/activity_vertical_margin"
                   tools:context=".MainActivity">
       <TextView
           android:id="@+id/bindTv"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"/>
   </RelativeLayout>
</layout>

Уже сейчас можно начать его использовать класс Binding для доступа к элементам интерфейса, без использования findViewById. В MainActivity добавим поле и перепишем метод onCreate:

ActivityMainBinding binder;  

@Override
protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
 binding.bindTv.setText("Some text");
}

Название поля берётся из Id View, без Id в биндере поле не появиться, если изменить Id View, то поле в биндере сразу же переметнуться. Если с зажатым CTRL нажать на название поля View, то сразу перейдешь к нему в файле разметки. Как по мне так уже одного такого функционала достаточно для того чтобы начать использовать биндинги.

Мои предвкушения
Но это лишь начало и идёт как бонус, главная особенность это конечно же использование самого Layout файла для разметки того какие данные куда размещать и как следствие возможность реализации паттерна MVVM как это происходит в С# в Windows приложениях. А если ещё и приложить мощь Java RX, но не будем отвлекаться.

Привязка данных


Например у нас есть карточка пользователя имя и возраст.

public class User {
   public String name;
   public int age;

   public User(String name, int age) {
       this.nam = name;
       this.age = age;
   }
}

Изменим Layout, заменим содержимое LinearLayout на:

<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="@{Integer.toString(user.age)}"/>


И в onCreate заменим последнюю строку на:

   binding.setUser(new User("Some name", 27));

Запускаем. Всё работает.
Наверное у всех проектах в активити или в фрагментах встречается такие строчки:

   someView.setVisibility(isVisible ? View.VISIBLE : View.GONE);

Тут то мы и начинаем использовать непосредственно привязки данных. Перепишем модель:

public class User {
   public String name;
   public int age;

   public User(String name, int age, boolean isAdult) {
       this.name = name;
       this.age = age;
       this.isAdult = isAdult;
   }
   public boolean isAdult; 
}

И добавим в Layout:

<TextView
   android:id="@+id/adult_content"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:visibility="@{user.isAdult ?View.VISIBLE :View.GONE}"
   android:text="Some content only for adult"/>

На красные выделения студии игнорируем.
Так как мы используем класс View, то его нужно импортировать, добавим в ноду <data>:

   <import type="android.view.View"/> 

Или используем его вместе с названием пакета:

   android:visibility="@{user.isAdult ? android.view.View.VISIBLE : android.view.View.GONE}"

Так же возможно в ноде <import> задать псевдоним для класса:

<import type="android.view.View" alias="SomeAlias"/>
...
android:visibility="@{user.isAdult ? SomeAlias.VISIBLE : SomeAlias.GONE}"

Конвертеры


Импорт в свою очередь даёт возможность писать конвертеры. Добавим в модель поле с датой рождения и удалим возраст:

public class User {
   public String name;
   public long birthday;
   public boolean isAdult;
   public User(String name, long birthday, boolean isAdult) {
       this.name = name;
       this.birthday = birthday;
       this.isAdult = isAdult;
   }
}

Напишем конвертер:

public class UnixDateConverter {
   public static String convert(long timestamp) {
       Calendar mydate = Calendar.getInstance();
       mydate.setTimeInMillis(timestamp * 1000);
       SimpleDateFormat sdf = new SimpleDateFormat("MM/dd/yyyy");
       return sdf.format(mydate.getTime());
   }
}


Импортируем его в разметку:

<import type="com.georgeci.bindingssample.UnixDateConverter"/>
...
<TextView
   android:id="@+id/birthday"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:text="@{UnixDateConverter.convert(user.birthday)}"/>


Обратная связь и Binding


Попробуем сменить имя пользователя.
Добавим в Layout:

<variable
            name="clicker"
            type="android.view.View.OnClickListener"/>
...
<Button
            android:text="Some button"
            app:onClickListener="@{clicker}"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>

Перепишем onCreate:

protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        final User user = new User("Some name", 668714400L, false);
        binding.setUser(user);
        binding.setClicker(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Toast.makeText(MainActivity.this, "ckick!", Toast.LENGTH_SHORT).show();
                user.name="from btn";
            }
        });
    }

Запускаем и кликаем, тост всплыл, но имя не изменилось. Это случилось из-за того, что модель ни как не известила binder о своём изменении.

Можно создать новую модель и вставить её, но с точки зрения памяти это расточительно:

   binding.setUser(new User("New model", 668714400L, false));

Или вытащить старую, заменить данные и вставить опять:

User user1 = binding.getUser();
user1.name = "old model";
binding.setUser(user1);

Но тогда обновятся все View, связанные с этой моделью. Лучшим вариантом будет связать модель с binder, чтобы она могла его оповестить о своём изменении. Для этого перепишем класс модели, добавив геттеры и сеттеры,
Что в Android Studio дело пары кликов
Alt-Insert -> Getter and Setter -> Ctrl-A -> Enter
помечая геттеры атрибутом @Bindable, и добавив в сеттеры вызов notifyPropertyChanged(BR.lastName);

public class User extends BaseObservable {
    private String name;
    private long birthday;
    private boolean adult;
    public User(String name, long birthday, boolean isAdult) {
        this.name = name;
        this.birthday = birthday;
        this.adult = isAdult;
    }
    @Bindable
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
        notifyPropertyChanged(com.georgeci.bindingssample.BR.name);
    }
    @Bindable
    public long getBirthday() {
        return birthday;
    }
    public void setBirthday(long birthday) {
        this.birthday = birthday;
        notifyPropertyChanged(com.georgeci.bindingssample.BR.birthday);
    }
    @Bindable
    public boolean isAdult() {
        return adult;
    }
    public void setAdult(boolean adult) {
        this.adult = adult;
        notifyPropertyChanged(com.georgeci.bindingssample.BR.adult);
    }
}

Видим новый класс BR, в котором содержатся идентификаторы полей, чьи геттеры помечены атрибутом @Bindable. В Layout оставляем android:text="@{user.name}", меняем только isAdult на adult, c 'is' в названии поля возникли проблемы. Запускаем всё работает.

ObservableFields


В пакете android.databinding есть классы, которые могут упростить нотификацию binder об изменении модели:
  • Обёртки над элементарными типами
  • ObservableField<T>
  • ObservableArrayMap<K, V>
  • ObservableArrayList<T>

Попробуем изменить модель:

public class User extends BaseObservable {
    @Bindable
    public final ObservableField<String> name = new ObservableField<>();
    @Bindable
    public final ObservableLong birthday = new ObservableLong();
    @Bindable
    public final ObservableBoolean adult = new ObservableBoolean();

    public User(String name, long birthday, boolean isAdult) {
        this.name.set(name);
        this.birthday.set(birthday);
        this.adult.set(isAdult);
    }
}

Публичное поле? Не в мою смену.
Так как поле помечено как final, и по существу является просто умной обёрткой над типом, то не вижу проблем в этом; для остальных есть первый вариант.

По коллекциям аналогично, единственное приведу пример обращения ко ключу к Map:

<TextView
   android:text='@{user["lastName"]}'
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"/>

Из View в Model


Теперь попробуем взять новое имя из UI, привязав EditText к некоторой модели. Так как внутри EditText крутится Editable, то и привязать будет к ObservableField<Editable>. Но где именно держать этот объект? Вот тут и настаёт время для MVVM. Создаём класс SimpleViewMode:

public class Vm extends BaseObservable {
    @Bindable
    public final ObservableField<Editable> edit = new ObservableField<>();
    
    public Vm() {
        this.edit.set(Editable.Factory.getInstance().newEditable(""));
    }
}

Изменю MainActivity:

public class MainActivity extends AppCompatActivity {

    User user;
    ActivityMainBinding binding;
    Vm vm = new Vm();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        user = new User("Some name", 668714400L, false);
        binding.setUser(user);
        binding.setVm(vm);
        binding.setClicker(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Toast.makeText(getApplicationContext(), vm.edit.get().toString(), Toast.LENGTH_SHORT).show();
            }
        });
    }
}

А в разметку добавлю:

<EditText
            android:id="@+id/edit_text"
            android:layout_width="match_parent"
            android:text="@{vm.edit}"
            android:layout_height="wrap_content"/>

Вот тут возникает проблема, если привязать ObservableField<Editable> к EditText, то всё будет работать только в сторону View. Как я понял, проблема в том, что Editable, который лежит внутри ObservableField, отличается от того, который лежит внутри EditText.

Если у кого есть идеи — делитесь.

Итог


Очень любопытно было увидеть библиотеку для поддержки Data Binding в Android от Google. В документации тоже нет информации про обратную связь данных, но я надеюсь на скорую её реализацию. После официального выхода стабильной версии можно будет посмотреть на интеграцию с JavaRX.

[ Оффициальная документация ]
[ Ссылка на мой простенький пример ]

Комментарии (12)


  1. Revertis
    15.06.2015 17:47
    -5

    Ох уж эта мания превращать приложения в сайты. Что внешне, что внутренне.


  1. Sp0tted_0wl
    15.06.2015 22:04
    +2

    А я считаю, что это отличная идея. Наконец-то гугл официально дал рекомендации по проектированию архитектуры приложений. Раньше все писали кто как хотел. Много было статей по MVP и прочим изыскам. Кто-то пытался натянуть MVVM используя сторонние библиотеки. А теперь рай на земле видимо наступил наконец-то =)


    1. Ghedeon
      15.06.2015 23:00
      +1

      Согласен, начинают просыпаться что-ли, правильные вещи выдавать. Кстати, кому интересно, вот свежий доклад с дроидкона в Берлине на тему MVVM. Автор как раз проходится по всем сторонним библиотекам и приходит к выводу, какой сюрприз, что реализация от гугла получилась лучшей. Правда, тоже очень надеется на скорую реализацию обратного связывания данных.


  1. bBars
    15.06.2015 23:31

    А здесь теперь уже вьюха вылазит за границы дозволенного парадигмой:

    <TextView android:text="@{UnixDateConverter.convert(user.birthday)}" />
    

    Вопрос: как с производительностью (полагаю, все неважно) и как самочувствие GC после всего этого?


    1. Nagg
      15.06.2015 23:51

      Это немного другая вьюха, не от MVP/C, а от MVVM, ей можно. в MVVM ViewModel не сильно волнуется о том, в каком именно виде вьюха отразит день рождения. Вообще очень клево, давно это ждал. После опыта с Windows и MVVM разработка под Android приносила некоторый зуд и неудобство, спасибо Butterknife хоть как-то уменьшал этот зуд. Теперь заживём :-).


  1. artemgapchenko
    16.06.2015 11:19

    Я правильно понимаю, что механизм Data Binding не зависит от версии Android, установленной на устройстве пользователя? То есть работать будет везде, чисто за счет кодогенерации, верно?


    1. georgeci Автор
      16.06.2015 11:34
      +1

      Android 2.1 (API level 7+)


  1. Nagg
    16.06.2015 12:12

    не очень понял как прибайндить коллекцию из вьюмодели к ListView/RecyclerView :(


    1. georgeci Автор
      16.06.2015 12:23

      Из коробки пока нет варианта. Как вариант посмотреть binding-collection-adapter, но ещё не было времени изучить эту библиотеку.


      1. Nagg
        16.06.2015 12:28

        Да, то что нужно. Странно что не из коробки. Ещё бы two-way binding и было бы прекрасно. Сейчас нафигачил сложную форму и смотрю профайлером насколько это тормознуто всё.


  1. samodum
    17.06.2015 13:19

    >«someView.setVisibility(isVisible: View.VISIBLE: View.GONE);»

    Может быть, так?

    someView.setVisibility(isVisible? View.VISIBLE: View.GONE);


    1. georgeci Автор
      17.06.2015 13:29

      Спасибо, исправил.