ViewModel и LiveData: паттерны и антипаттерны
View и ViewModel
Распределение ответственностей
Типичное взаимодействие объектов приложения, построенное с помощью Архитектурных Компонентов:
В идеале ViewModel не должна ничего знать про Android. Это улучшает тестируемость и модульность, снижает кол-во утечек памяти. Основное правило — в Вашей ViewModel не должно быть импортов android.*
(за исключением android.arch.*
). Это относится и к Presenter.
ViewModel (и Presenter) не должны знать о классах фреймворка Android.
Условные операторы, циклы и общие решения должны проводиться в ViewModel или прочих слоях приложения, не в активити или фрагментах. View обычно не подвергается unit-тестированию (кроме случаев, когда используется Robolectric), поэтому чем меньше строчек кода — тем лучше. View должны только отображать данные и посылать действия пользователя в ViewModel (или Presenter). Этот паттерн называется Passive View.
Activity и фрагменты должны содержать минимум логики.
Ссылки на View в ViewModel
Скоуп ViewModels отличается от скоупа активити или фрагмента. В то время как ViewModel инициализирована и работает, активити может пройти через несколько состояний жизненного цикла. ViewModel может ничего не знать о том, что активити и фрагменты были уничтожены и созданы.
ViewModel сохраняется при изменениях конфигурации:
Передача ссылки на View (активити или фрагмент) в ViewModel является серьезным риском. Предположим, ViewModel запросила данные из сети, и они придут чуть позже. В этот момент ссылка на View может быть уничтожена или активити может больше не отображаться — это приведет к утечке памяти, а возможно и к крэшу приложения.
Избегайте ссылок на View в ViewModels.
Рекомендуемый способ коммуникации между ViewModel и View — использование observer pattern, при помощи LiveData или observable из других библиотек.
Observer Pattern (шаблон проектирования «Наблюдатель»)
Удобным способом дизайна презентационного слоя в Android является подход, когда View (активити или фрагмент) наблюдает (подписана на изменения в) ViewModel. Т.к. ViewModel не знает ничего про Android, она также не знает о том, как часто Android убивает View. У этого подхода есть несколько преимуществ:
- ViewModel сохраняются при изменении конфигурации, поэтому нет нужды в повторном запросе внешних источников данных (к примеру, базы данных или сетевого хранилища) при произведенном повороте устройства.
- Когда заканчивается какая-либо длительная операция, observable в ViewModel обновляются. Не важно, велось ли наблюдение за данными или нет. NPE не произойдет даже при попытке обновления несуществующего View.
- ViewModel не содержат ссылки на View, что снижает риск возникновения утечек памяти.
Типичные подписки от активити или фрагмента:
private fun subscribeToModel() { // Observe product data viewModel.getObservableProduct().observe(this, Observer { product -> title.setText(product.title) } }
Вместо того чтобы отсылать данные в UI, пусть UI наблюдает за изменениями в данных.
«Жирные» ViewModel
Разделение ответственностей — всегда хорошая идея. Если Ваш ViewModel содержит в себе слишком много кода или имеет слишком много ответственностей, подумайте о том, что возможно следует:
- Переместить часть логики в Presenter с тем же scope, что у ViewModel. Именно он будет связываться с другими частями приложения и обновлять LiveData в ViewModel.
- Добавить слой Domain и адаптировать приложение под Clean Architecture. Это облегчит проведение тестов и поддержку кода. Также это обычно приводит к тому, что большая часть логики не выполняется в главном треде. С примером Clean Architecture можно ознакомиться в Architecture Blueprints.
Распределяйте ответственности, добавьте слой domain, если требуется.
Использование репозитория для данных
Как видно в руководствах по архитектуре приложений, большинство приложений имеют несколько источников данных. Таких как:
- Удаленный: сеть или облако
- Локальный: база данных или файл
- Кэш
Создать в приложении слой данных, который ничего не знает о слое presentation — хорошая идея. Алгоритмы синхронизации кэша и базы данных не тривиальны. Поэтому рекомендуется создать класс репозитория, который послужит единственной точкой входа для борьбы с этой сложностью.
Если у вас несколько моделей данных и они достаточно сложны — имеет смысл добавления нескольких репозиториев.
Добавьте репозиторий данных в качестве единственной точки входа для данных
Обработка состояний данных
Представьте следующее: Вы подписаны на обновления LiveData, предоставленной ViewModel, которая содержит список для отображения. Как View сможет отличить загруженные данные от сетевой ошибки или пустого списка?
Вы можете предоставить доступ к LiveData через ViewModel. К примеру, MyDataState может содержать информацию о том, корректно ли загружаются данные, загрузились ли окончательно или загрузка была прервана.
Вы можете обернуть данные в класс, который содержит в себе состояния и другие метаданные (например, сообщение о ошибке). См. класс Resource в предоставленных примерах.
Раскрывайте информацию о состоянии данных, используя обертку или другую LiveData.
Сохраняя состояние активити
Состояние активити – это информация, которую потребуется пересоздать на экране, если активити была уничтожена или был убит содержащий ее процесс. Поворот экрана – это самый очевидный случай, и этот вопрос закрыт ViewModel. Состояние хранится в ViewModel в полной безопасности.
Однако восстановление состояния может понадобиться в других сценариях, в которых ViewModel была также уничтожена: когда ресурсы операционной системы заканчиваются и она убивает процесс приложения.
Для эффективного сохранения и восстановления состояния UI используйте комбинацию сохранения данных, onSaveInstanceState() и ViewModel.
Для примера см. ViewModels: Persistence, onSaveInstanceState(), Restoring UI State and Loaders Events
События
Событие – это что-то, что произошло единожды. ViewModel предоставляет доступ к данным, но что насчет событий? К примеру, события навигации или показ сообщений в Snackbar являются действиями, которые выполняются лишь один раз.
Концепция События не вписывается в то, как LiveData хранит и воссоздает данные. Представим себе ViewModel с таким полем:
val snackbarMessage = MutableLiveData<String>()
Активити подписывается на изменения этой ViewModel, и когда ViewModel заканчивает операцию, должно появиться сообщение:
snackbarMessage.setValue("Объект сохранен!")
Активити получает значение и показывает Snackbar. Вроде всё работает.
Не смотря на это, если пользователь повернет телефон — будет создана новая активити, которая также подпишется на изменения ViewModel. Когда произойдет подписка на изменения в LiveData, активити немедленно получит старое значение и сообщение отобразится снова!
Для того чтобы решить эту проблему, в одном из примеров мы создали класс SingleLiveEvent (класс наследуется от LiveData). Он посылает только те обновления, которые произошли после подписки на изменения. Также обратите внимание, что класс поддерживает только одного подписчика.
Для таких событий, как показ сообщений в Snackbar или навигации, используйте observable вроде SingleLiveEvent.
Утечки памяти в ViewModels
Парадигма реактивного программирования отлично работает в Android, т.к. предоставляет удобную связь между UI и прочими слоями архитектуры приложения. LiveData является ключевым компонентом этой структуры, поэтому обычно Ваши активити и фрагменты будут подписаны на изменения инстансов LiveData.
То, как ViewModel будет сообщаться с прочими компонентами приложения — решать Вам, но остерегайтесь утечек памяти и пограничных состояний. В приведенной ниже диаграмме слой Presentation использует observer pattern и слой Data, который получает колбэки.
Observer pattern в UI и колбэки в слой Data:
Если пользователь выходит из приложения, ViewModel перестает кем бы то ни было наблюдаться. Если репозиторий реализован как синглтон или каким-то другим образом привязан к скоупу приложения, он не будет уничтожен, пока процесс приложения не будет убит. Это произойдет, только когда системе потребуются ресурсы или пользователь вручную убьет приложение. Если репозиторий ссылается на колбэк в ViewModel – будет создана утечка памяти.
Активити закончила работу, но ViewModel продолжает существовать:
Эта утечка не так важна, если ViewModel легкая или операция гарантированно завершится в течение короткого отрезка времени. В идеале, ViewModel'и должны освобождаться во всех случаях, когда отсутствуют View, которые за ними наблюдают:
Этого можно достигнуть многими способами:
- С помощью ViewModel.onCleared() Вы можете сообщить репозиторию о том, что он должен сбросить колбэк к ViewModel.
- В репозитории Вы можете использовать WeakReference или Event Bus (использование обоих может проводиться некорректно и даже считается вредным).
- Используйте LiveData для коммуникации между репозиторием и ViewModel так же, как используете ее для коммуникации между View и ViewModel.
Учитывайте пограничные случаи, утечки памяти и то, как долгие операции могут повлиять на инстансы в Вашей архитектуре.
Не помещайте в ViewModel логику, которая является критически важной для сохранения чистоты архитектуры или если она связана с данными. Каждый вызов, сделанный от ViewModel может быть последним.
LiveData в репозиториях
Чтобы избежать утечек в ViewModel и «ада колбэков», репозитории могут наблюдаться следующим образом:
Когда ViewModel очищена или когда жизненный цикл view прекратился, подписка очищается:
В этом способе есть одна тонкость: как подписаться к репозиторию от ViewModel, если нет доступа к LifecycleOwner? С помощью Трансформаций.
Transformations.switchMap позволяет создать LiveData, которая реагирует на изменения в других инстансах LiveData. За счет этого информация передается через observer жизненного цикла по цепочке:
val repo = Transformations.switchMap(repoIdLiveData) { repoId -> if (repoId.isEmpty()) { return AbsentLiveData.create() } return repository.loadRepo(repoId) )
Пример трансформации можно посмотреть здесь. В этом примере, когда триггер получает обновление, выполняется функция и результат каскадируется. Активити наблюдает за репозиторием, и тот же LifecycleOwner будет использован для вызова repository.loadRepo(id)
.
Каждый раз когда Вы думаете, что Вам нужен объект Lifecycle внутри ViewModel, использование Transformation скорее всего поможет этого избежать и решит проблему.
Наследуясь от LiveData
Самый частый вид использования LiveData – это использование MutableLiveData в ViewModel и представление ее как LiveData, для того чтобы сделать ее неизменяемой для наблюдателей.
Если Вам нужно больше функционала, наследование от LiveData позволит узнать, присутствуют ли активные наблюдатели. К примеру, это может быть полезно, если вы хотите начать прослушивать сервис локации или сенсоры устройства.
class MyLiveData(context: Context) : LiveData<MyData>() { init { // Initialize service } override fun onActive() { // Start listening } override fun onInactive() { // Stop listening } }
Когда не надо наследоваться от LiveData
Вы также можете использовать onActive() для начала какого-то сервиса, который загружает данные, но если у Вас для этого нет хорошей причины, Вам не надо ждать, пока LiveData начнет наблюдаться. Самые распространенные паттерны:
– Добавьте метод start() в ViewModel и вызовите его как можно скорее (см. пример с Blueprints).
– Назначьте поле, которое прекращает загрузку (см. пример GithubBrowserExample).
Обычно наследование от LiveData не нужно. Пусть активити или фрагмент подскажет ViewModel, когда начать загружать данные.