Android Architecture Components в связке с Data Binding
Не так давно для андроид-разработчиков Google представил новую библиотеку — Android Architecture Components. Она помогает реализовать в приложении архитектуру на основе паттернов MV* (MVP, MVVM etc.). Кроме того, уже давно выпущена другая библиотека от Google — Data Binding Library. Она позволяет прямо в разметке связывать отображение UI-контролов со значениями, содержащимися в объектах. Это важная особенность паттерна MVVM — связывать слой View со слоем ViewModel.
Обе библиотеки направлены на построение архитектуры Android-приложений в MVVM стиле. Я расскажу, как можно использовать их вместе для создания проекта с архитектурой на основе MVVM.
Немного об MVVM
Паттерн MVVM предполагает разделение архитектуры приложения на 3 слоя:
- Model — слой данных. Содержит всю бизнес-логику приложения, доступ к файловой системе, базе данных, ресурсам системы и другим внешним сервисам.
- View — слой отображения. Все, что видит пользователь и с чем может взаимодействовать. Этот слой отображает то, что представлено в слое ViewModel. Также он отправляет команды (например, действия пользователя) на выполнение в слой ViewModel;
- ViewModel — слой представления. Связан со слоем View биндингами. Содержит данные, которые отображены на слое View. Связан со слоем Model и получает оттуда данные для отображения. Также обрабатывает команды, поступающие из слоя View, изменяя тем самым слой Model.
Основной интерес в статье будет прикован к биндингам. Это связи отображения конкретных параметров View (например, “text” у TextView) с конкретными полями представления ViewModel (например, поле “имя пользователя”). Задаются они в разметке View (в layout), а не в коде. ViewModel, в свою очередь, должна так представлять данные, чтобы их было легко связать биндингами с View.
Зачем нам это все надо?
Сам по себе паттерн MVVM, как и MVP, и MVC, позволяет разделить код на независимые слои. Основное отличие MVVM — в биндингах. То есть, в возможности прямо в разметке связать отображение того, что видно пользователю — слой View, с состоянием приложения — слоем Model. В общем, польза MVVM в том, чтобы не писать лишний код для связывания представления с отображением — за вас это делают биндинги.
Google двигается в сторону поддержки архитектуры на основе паттерна MVVM. Библиотеки Android Architecture Components (далее, AAC) и Data Binding — прямое тому подтверждение. В будущем, скорее всего, этот паттерн будет использоваться на большинстве проектов под Android.
На данный момент проблема в том, что ни AAC, ни Data Binding по отдельности не предоставляют возможность реализовать MVVM паттерн в полной мере. AAC реализует слой ViewModel, но биндинги надо настраивать вручную в коде. Data Binding, в свою очередь, предоставляет возможность написать биндинги в разметке и привязать их к коду, но слой ViewModel надо реализовывать вручную, чтобы прокидывать обновление состояния приложения через биндинги к View.
В итоге, вроде бы, все уже готово, но разделено на две библиотеки, и чтобы это было действительно похоже на MVVM, нужно просто взять и объединить их.
В общем, что надо для этого сделать:
- реализовать слой View на биндингах;
- реализовать слой ViewModel на основе классов LiveData и ViewModel из AAC;
- связать эти два слоя минимальным количеством кода;
- оформить это так, чтобы это можно было переиспользовать в проектах.
Сделать это попробуем на примере простого экрана профиля пользователя.
Описание примера
На экране будет три элемента:
- Кнопка войти/выйти. Текст зависит от того, авторизован пользователь или нет.
- Поле ввода логина. Показывается, когда пользователь не авторизован.
- Лэйбл с логином. Показывает логин авторизованного пользователя.
Логин будет храниться в SharedPreferences. Пользователь считается авторизованным, если в SharedPreferences записан какой-нибудь логин.
Для простоты не будут использоваться сторонние фреймворки, запросы к сети, а также отображение ошибок.
Слой View
Начну я со слоя View, чтобы было понятно, что будет видно пользователю на экране. Сразу же размечу нужные мне биндинги без привязки к конкретной ViewModel. Как это все будет работать — станет понятно позже.
Собственно, layout:
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <import type="touchin.aacplusdbtest.R"/> <import type="android.view.View"/> <!-- Это ViewModel для экрана, к которой будет все биндиться --> <variable name="profileViewModel" type="touchin.aacplusdbtest.ProfileViewModel"/> </data> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <!-- Текст с логином авторизованного пользователя. Биндится логин как текст из поля userLogin. Биндится visibility к полю isUserLoggedIn, так как отображается элемент, только если пользователь авторизован. --> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@{profileViewModel.userLogin}" android:visibility="@{profileViewModel.isUserLoggedIn ? View.VISIBLE : View.GONE}"/> <!-- Поле ввода логина. Биндится введенный логин, как текст из поля inputLogin, этот биндинг двусторонний, то есть при изменении inputLogin во ViewModel будет меняться отображение на View, и наоборот - при вводе пользователем другого текста он будет изменяться в поле inputLogin. Биндится visibility к полю isUserLoggedIn, так как отображается элемент только если пользователь не авторизован. --> <touchin.aacplusdbtest.views.SafeEditText android:layout_width="match_parent" android:layout_height="wrap_content" android:addTextChangedListener="@{profileViewModel.inputLogin}" android:text="@{profileViewModel.inputLogin}" android:visibility="@{profileViewModel.isUserLoggedIn ? View.GONE : View.VISIBLE}"/> <!-- Кнопка войти/выйти. Биндится текст к полю isUserLoggedIn: отображается текст "Выйти", если пользователь авторизован, текст "Войти" - если не авторизован. Также настраивается вызов команды по клику в зависимости от значения в поле isUserLoggedIn: вызывается команда logout или login. --> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{profileViewModel.isUserLoggedIn ? R.string.logout : R.string.login}" android:onClick="@{(v)-> profileViewModel.loginOrLogout()}"/> </LinearLayout> </layout>
Класс LiveData
Перед реализацией слоя Model надо разобраться с классом LiveData из AAC. Он нам понадобится для нотификации слоя ViewModel об изменениях слоя Model.
LiveData — это класс, объекты которого поставляют данные и их обновления подписчикам. Он представляет собой реализацию паттерна Observer. На LiveData можно подписаться, а сама LiveData реализует внутри то, как она будет вычислять и обновлять данные для подписчиков.
Особенность LiveData в том, что она может быть привязана к объекту жизненного цикла и активироваться, только когда такой объект в состоянии started. Это удобно для обновления слоя View: пока активити или фрагмент в состоянии started, это значит, что у них инициализирован весь UI и им нужны актуальные данные. LiveData реагирует на это и активизируется — рассчитывает актуальное значение и уведомляет подписчиков об обновлении данных.
Слой Model
От слоя Model нам нужна следующая функциональность: методы login(login: String), logout() и возможность отслеживать текущий логин авторизованного пользователя на основе LiveData.
Добавим класс ProfileRepository, который будет отвечать за логику авторизации пользователя:
class ProfileRepository(context: Context) { private val loginKey = "login" private val preferences = context.getSharedPreferences("prefs", Context.MODE_PRIVATE) // LiveData, на которую можно подписаться // и получать обновления логина пользователя private val innerLoggedInUser = LoggedInUserLiveData() val loggedInUser: LiveData<String?> get() = innerLoggedInUser fun login(login: String) { preferences.edit().putString(loginKey, login).apply() notifyAboutUpdate(login) } fun logout() { preferences.edit().putString(loginKey, null).apply() notifyAboutUpdate(null) } private fun notifyAboutUpdate(login: String?) { innerLoggedInUser.update(login) } private inner class LoggedInUserLiveData : LiveData<String?>() { // так лучше не делать в конструкторе, а высчитывать текущее значение асинхронно // при первом вызове метода onActive. Но для примера сойдет init { value = preferences.getString(loginKey, null) } // postValue запрашивает обновление на UI-потоке // используем, так как мы не уверены, с какого потока будет обновлен логин // для немедленного обновления на UI-потоке можно использовать метод setValue fun update(login: String?) { postValue(login) } } }
Этот объект разместим в Application, чтобы было проще получить к нему доступ, имея Context:
class AacPlusDbTestApp : Application() { lateinit var profileRepository: ProfileRepository private set override fun onCreate() { super.onCreate() profileRepository = ProfileRepository(this) } }
Класс ViewModel
Перед реализацией слоя ViewModel надо разобраться с основным классом из AAC, использующимся для этого.
ViewModel — это класс, представляющий объекты слоя ViewModel. Объект такого типа может быть создан из любой точки приложения. В этом классе всегда должен быть либо дефолтный конструктор (класс ViewModel), либо конструктор с параметром типа Application (класс AndroidViewModel).
Чтобы запросить ViewModel по типу, нужно вызвать:
mvm = ViewModelProviders.of(fragmentOrActivity).get(MyViewModel::class.java)
Либо по ключу:
mvm1 = ViewModelProviders.of(fragmentOrActivity).get("keyVM1", MyViewModel::class.java) mvm2 = ViewModelProviders.of(fragmentOrActivity).get("keyVM2", MyViewModel::class.java)
ViewModel'и хранятся отдельно для каждой активити и для каждого фрагмента. При первом запросе они создаются и помещаются для хранения в активити или фрагменте. При повторном запросе — возвращается уже созданная ViewModel. Уникальность конкретной ViewModel — это ее тип или строковый ключ + где она хранится.
Создаются ViewModel и AndroidViewModel по умолчанию через рефлексию — вызывается соответствующий конструктор. Так что, при добавлении своих конструкторов, в методе ViewModelProviders.of(...) нужно явно указывать фабрику создания таких объектов.
Слой ViewModel
От ProfileViewModel нам надо следующее:
- метод loginOrLogout, который будет представлять команду логина или логаута пользователя в зависимости от того, авторизован ли пользователь;
- изменяемое значение isUserLoggedIn, которое будет представлять состояние — авторизован ли пользователь;
- изменяемое значение loggedInUser, которое будет представлять логин текущего авторизованного пользователя;
- изменяемое значение inputLogin, которое будет представлять то, что пользователь ввел на экране в поле логина.
Создадим ProfileViewModel и свяжем ее с ProfileRepository:
// наследуем от AndroidViewModel, для доступа к ProfileRepository через Application class ProfileViewModel(application: Application) : AndroidViewModel(application) { private val profileRepository: ProfileRepository = (application as AacPlusDbTestApp).profileRepository // класс Transformations — это класс-хэлпер для преобразования данных // метод map, просто конвертирует данные из одного типа в другой - // в данном случае из String? в boolean val isUserLoggedInLiveData = Transformations.map(profileRepository.loggedInUser) { login -> login != null } // LiveData, чтобы отслеживать логин авторизованного пользователя val loggedInUserLiveData = profileRepository.loggedInUser // Представляет логин, введенный пользователем с клавиатуры // TextField - это ObservableField, реализующий интерфейс TextWatcher // это нужно, чтобы можно было биндиться к text и addTextChangedListener, // организовав таким образом двусторонний биндинг // При вводе текста в EditText изменяется ViewModel, // при изменении ViewModel изменяется EditText. val inputLogin = TextField() fun loginOrLogout() { // необходимо получить текущее состояние - // авторизован пользователь или нет, // и решить, что делать isUserLoggedInLiveData.observeForever(object : Observer<Boolean> { override fun onChanged(loggedIn: Boolean?) { if (loggedIn!!) { profileRepository.logout() } else if (inputLogin.get() != null) { // вызываем логин, только если пользователь что-то ввел в поле ввода profileRepository.login(inputLogin.get()) } else { // по идее, тут можно отобразить ошибку "Введите логин" } // при выполнении команды приходится отписываться вручную isUserLoggedInLiveData.removeObserver(this) } }) } }
Теперь при вызове метода loginOrLogout в ProfileRepository будет обновляться LoginLiveData и эти обновления можно будет отображать на слое View, подписавшись на LiveData из ProfileViewModel.
Но LiveData и ViewModel пока что не адаптированы под биндинг, так что использовать этот код еще нельзя.
Адаптация ViewModel под Data Binding
С доступом к ViewModel из разметки проблем особых нет. Объявляем ее в разметке:
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="profileViewModel" type="touchin.test.ProfileViewModel"/> </data> ... </layout>
И устанавливаем в активити или фрагменте:
// наследуем от LifecycleActivity, так как это может понадобиться для LiveData // LiveData будет активироваться, когда эта активити будет в состоянии started class ProfileActivity : LifecycleActivity() { lateinit private var binding: ActivityProfileBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // инициализируем биндинг binding = DataBindingUtil.setContentView<ActivityProfileBinding>(this, R.layout.activity_profile) // устанавливаем ViewModel для биндинга binding.profileViewModel = ViewModelProviders.of(this).get(ProfileViewModel::class.java) } }
Адаптация LiveData под Data Binding
Адаптировать LiveData я решил на основе класса ObservableField. Он позволяет привязать изменяющееся значение произвольного типа к конкретному свойству view.
В моем примере надо будет прибиндить visibility у view к тому, авторизован пользователь или нет. А также свойство text к логину пользователя.
У ObservableField есть два метода — addOnPropertyChangedCallback и removeOnPropertyChangedCallback. Эти методы вызываются, когда добавляется и удаляется биндинг из view.
По сути, эти методы — те моменты, когда нужно подписываться и отписываться от LiveData:
// наследуем от ObservableField, // имплементируем Observer (подписчик для LiveData), // чтобы синхронизировать значения LiveData и ObservableField class LiveDataField<T>(val source: LiveData<T?>) : ObservableField<T>(), Observer<T?> { // отслеживаем количество подписчиков на этот ObservableField private var observersCount: AtomicInteger = AtomicInteger(0) override fun addOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) { super.addOnPropertyChangedCallback(callback) if (observersCount.incrementAndGet() == 1) { // подписываемся на LiveData, // когда к ObservableField прибиндивается первая view source.observeForever(this) } } override fun onChanged(value: T?) = set(value) override fun removeOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) { super.removeOnPropertyChangedCallback(callback) if (observersCount.decrementAndGet() == 0) { // отписываемся от LiveData, когда все view отбиндились от ObservableField source.removeObserver(this) } } }
Для подписки на LiveData я использовал метод observeForever. Он не передает объект жизненного цикла и активирует LiveData независимо от того, в каком состоянии находится активити или фрагмент, на котором находится view.
В принципе, из объекта OnPropertyChangedCallback можно достать view, из view — context, context привести к LifecycleActivity и привязать LiveData к этой активити. Тогда можно будет использовать метод observe(lifecycleObject, observer). Тогда LiveData будет активироваться только когда активити, на которой находится view, в состоянии started.
Выглядеть этот хак будет примерно так:
class LifecycleLiveDataField<T>(val source: LiveData<T?>) : ObservableField<T>(), Observer<T?> { ... override fun addOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) { super.addOnPropertyChangedCallback(callback) try { // немножко рефлексии, по-другому никак val callbackListenerField = callback.javaClass.getDeclaredField("mListener") callbackListenerField.setAccessible(true) val callbackListener = callbackListenerField.get(callback) as WeakReference<ViewDataBinding> val activity = callbackListener.get()!!.root!!.context as LifecycleActivity if (observersCount.incrementAndGet() == 1) { source.observe(activity, this) } } catch (bindingThrowable: Throwable) { Log.e("BINDING", bindingThrowable.message) } } ... }
Теперь изменим ProfileViewModel так, чтобы к ней можно было легко прибиндиться:
class ProfileViewModel(application: Application) : AndroidViewModel(application) { ... // представляет логин авторизованного пользователя или null val userLogin = LifecycleLiveDataField(loggedInUserLiveData) // представляет, авторизован ли пользователь val isUserLoggedIn = LifecycleLiveDataField(isUserLoggedInLiveData) ... }
Важно! В процессе тестирования обнаружился один неприятный недостаток в библиотеке Data Binding — прибинденные view не вызывают метод removeOnPropertyChangedCallback, даже когда активити умирает. Это приводит к тому, что слой Model держит ссылки на объекты слоя View через слой ViewModel. В общем, утечка памяти из объектов LiveDataField.
Чтобы этого избежать, можно использовать еще один хак и вручную обнулить все биндинги на onDestroy активити:
class ProfileActivity : LifecycleActivity() { ... override fun onDestroy() { super.onDestroy() // обнуляем поле profileViewModel binding.profileViewModel = null // необходимо вручную вызвать обновление биндингов, // так как автоматическое обновление не работает на этапе onDestroy :( binding.executePendingBindings() } }
Кроме того, внимательные читатели могли заметить в разметке класс SafeEditText. В общем, он понадобился из-за бага в Data Binding Library. Суть в том, что она добавляет листенер вводимого текста через addTextChangedListener, даже если этот листенер null.
Так как на этапе onDestroy я обнуляю модель, то сперва в EditText добавляется null-листенер, а потом обновляется текст, который стал тоже null. В итоге на onDestroy происходил NPE краш при попытке оповестить null-листенер о том, что текст стал null.
В общем, при использовании Data Binding будьте готовы к таким багам — их там довольно много.
Не идеально, но получилось
В общем, с некоторыми трудностями, хаками и некоторыми разочарованиями, но связать AAC и Data Binding получилось. Скорее всего, в скором времени (года через 2?) Google добавит какие-нибудь фичи, чтобы связать их — тот же аналог моей LiveDataField. Пока что (на момент написания статьи - июль 2017 года) AAC в альфе, так что там многое еще может измениться.
Основные проблемы на текущий момент, на мой взгляд, связаны с библиотекой Data Binding — она не подстроена под работу с ViewModel и в ней есть неприятные баги. Это наглядно видно из хаков, которые пришлось использовать в статье.
Во-первых, при биндинге сложно получить активити или фрагмент, чтобы получить LifecycleObject, необходимый для LiveData. Эту проблему можно решить двумя способами: либо достаем это через рефлексию, либо просто делаем observeForever, который будет держать подписку на LiveData, пока мы вручную не обнулим биндинги на onDestroy.
Во-вторых, Data Binding предполагает, что ObservableField и прочие Observable объекты живут тем же жизненным циклом, что и view. По факту, эти объекты — это часть слоя ViewModel, у которой другой жизненный цикл. Например, в AAC этот слой переживает перевороты активити, а Data Binding не обновляет биндинги после переворота активити — для нее все view умерли, а значит, и все Observable объекты тоже умерли, и обновлять ничего нет смысла. Эту проблему можно решить обнулением биндингов вручную на onDestroy. Но это требует лишнего кода и необходимости следить за тем, чтобы все биндинги были обнулены.
В-третьих, возникает проблема с объектами слоя View без явного жизненного цикла, например, ViewHolder адаптера для RecyclerView. У них нет четкого вызова onDestroy, так как они переиспользуются. В какой момент обнулять биндинги во ViewHolder — сложно сказать однозначно.
Не сказал бы, что на текущий момент связка этих библиотек выглядит хорошо, хотя использовать ее можно. Стоит ли использовать такой подход с учетом недостатков, описанных выше, — решать вам.
Пример из статьи можно посмотреть на гитхабе Touch Instinct.
Источник: Android Architecture Components в связке с Data Binding