Android Architecture Components в связке с Data Binding


    Не так давно для андроид-разработчиков Google представил новую библиотеку — Android Architecture Components. Она помогает реализовать в приложении архитектуру на основе паттернов MVx (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.

    MVVM
    Основной интерес в статье будет прикован к байндингам. Это связи отображения конкретных параметров 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(String login), 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. Пока что 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.

    Метки:
    • +18
    • 6,7k
    • 8
    Touch Instinct 138,08
    Разрабатываем мобильные приложения
    Поделиться публикацией
    Похожие публикации
    Комментарии 8
    • +1
      Сложновато как-то. У microsoft'а это попроще сделано.
      • 0

        да, WPF изначально под MVVM проектировался, а тут скорее попытка воспроизвести что-то похожее и привести к этому

      • 0
        Эту проблему можно решить: либо достаем это через рефлексию, либо просто делаем observeForever, который будет держать подписку на LiveData, пока мы вручную не обнулим байндинги на onDestroy.

        Рефлексию ни в коем случае нельзя использовать, если в проекте ProGuard, то задолбаетесь правила добавлять
        • 0

          В данном случае получается доступ к полю "mListener" у объекта OnPropertyChangedCallback. Это поле используется в DataBinding'е, так что вряд ли будет выпилено ProGuard'om. Но если и перестраховаться, то придется добавить одно правило только — на этот класс конкретный или просто на все классы датабайндинга

        • 0
          Ужас то какой. В .NET это реализуется с помощью 1 интерфейса и доступно уже лет 11…
          • 0

            Все так, но раньше не было и этого

          • 0
            Я пытаюсь похожим образом использовать Architecture Components, но вместо LiveData использую Rx.
            Как пример: https://github.com/Popalay/Cardme/blob/develop/app/src/main/kotlin/com/popalay/cardme/presentation/screens/cards/CardsViewModel.kt
            • 0
              Я бы в классе ProfileViewModel в методе onChanged заменил «if (loggedIn!!)» на «if(loggedIn == true)», чтобы не было НПЕ, а в случае если loggedIn == null считалось бы, что пользователь не залогинен.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое