Аркадия corporate blog
Development for Android
Kotlin
16 July 2019

Meeting Room L̶i̶t̶t̶l̶e̶ Helper v 2

Данная статья подробно описывает этапы разработки мобильного приложения Meeting Room Helper: от зарождения идеи до релиза. Приложение написано на Kotlin и построено по упрощённой MVVM архитектуре, без использования data binding. Обновление UI-части происходит с помощью LiveData-объектов. Причины отказа от data binding подробно разобраны и объяснены. В архитектуре используется ряд интересных решений, позволяющих логично разбить программу на небольшие файлы, что в конечном счете упрощает поддержку кода.



Описание проекта


3 года назад в нашей компании появилась идея разработать небольшой проект для моментального бронирования переговорок. Большинство HR-ов и менеджеров «Аркадии» предпочитает использовать для подобных целей календарь Outlook, но как быть остальным?

Приведу 2 примера из жизни разработчика

  1. У любой команды периодически возникает спонтанное желание провести быстрый митинг на 5-10 минут. Это желание может настичь разработчиков в любом уголке офиса, и, дабы не отвлекать коллег вокруг себя, они (разработчики и не только) начинают искать свободную переговорку. Мигрируя от комнаты к комнате (в нашем офисе переговорки расположены в ряд), коллеги «аккуратно проверяют», какое из помещений свободно в данный момент. В результате они отвлекают коллег внутри. Такие ребята всегда и везде были и будут, даже если за прерывание митинга в корпоративном уставе введут расстрел. Кто понял, тот поймет.
  2. А вот другой случай. Вы только что вышли из столовой и направляетесь к себе, но тут вас перехватывает ваш коллега (или менеджер) из другого отдела. Он хочет рассказать вам что-то срочное, и для этих целей вам нужна переговорка. Согласно регламенту, вы должны сначала забронировать комнату (с телефона либо компьютера) и только после этого ее занимать. Хорошо, если у вас есть с собой телефон с мобильным Outlook. А если нет? Идти назад, к компьютеру, чтобы потом вновь возвращаться к переговорке? Заставить каждого сотрудника поставить на телефон Outlook Express и следить за тем, чтобы все носили с собой телефоны? Это не наши методы.

Именно поэтому 2,5 года назад каждую из переговорок оснастили собственным планшетом:



Для этого проекта мой коллега разработал первую версию приложения: Meeting Room Little Helper (здесь можно об этом почитать). MRLH позволял бронировать переговорку, отменять и продлевать бронь, показывал статусы остальных переговорок. Инновационной «фишкой» стало распознавание личности сотрудника (с помощью облачного сервиса Microsoft Face API и наших внутренних анализаторов). Приложение получилось добротным и прослужило компании верой и правдой 2,5 года.

Но время шло… Появились новые идеи. Захотелось чего-то свежего, и потому приложение решили переписать.

Техническое задание


Как это часто бывает — но, к сожалению, не всегда — разработка началась с составления технического задания. Первым делом мы позвали ребят, которые чаще всего используют планшеты для бронирования. Так уж вышло, что больше всего к ним пристрастились HR-ы и менеджеры, которые до этого пользовались исключительно Outlook. От них мы получили следующий feedback (из требований сразу понятно, что просили HR-ы, а что — менеджеры):

  • необходимо добавить возможность бронирования любой переговорки с любого планшета (ранее каждый планшет позволял бронировать только свою комнату);
  • было бы круто просмотреть расписание митингов для переговорки на весь день (в идеальном варианте — на любой день);
  • весь цикл разработки нужно провести в сжатые сроки (за 6-7 недель).

С желаниями заказчика всё понятно, но что насчёт технических требований и задела на будущее? Добавим несколько требований к проекту от гильдии разработчиков:

  • Система должна работать как с уже существующими планшетами, так и с новыми;
  • масштабируемость системы — от 50 переговорок и выше (этого должно хватить с запасом для большинства заказчиков, если систему начнут тиражировать);
  • сохранение прежней функциональности (первая версия приложения использовала Java API для общения с сервисами Outlook, и мы планировали заменить его на специализированный Microsoft Graph API, поэтому важно было не утратить функциональность);
  • минимизация энергопотребления (планшеты питаются от внешнего аккумулятора, т.к. бизнес-центр не разрешил сверлить свои стены для прокладки наших проводов);
  • новый UX/UI дизайн, эргономично отражающий все нововведения.

Итого 8 пунктов. Требования довольно справедливые. Дополнительно оговорим общие правила разработки:

  • использовать только передовые технологии (это позволит развиваться команде как специалистам и не топтаться на одном месте, одновременно упростив поддержку проекта в обозримом будущем);
  • следовать best practices, но не принимать их слепо на веру, т.к. главное правило любого профессионала (и разработчика, стремящегося к этому) — всё оценивать критически;
  • писать чистый и аккуратный код (пожалуй, это самое сложное, когда пытаешься совместить инновации и сжатые сроки разработки).

Начало положено. Оно, как и всегда, полно энтузиазма! Посмотрим, что будет дальше.

Дизайн


Дизайн приложения, разработанный UX-дизайнером:




Это главный экран. Он будет отображаться большую часть времени. Здесь эргономично расположилась вся необходимая информация:

  • название комнаты и её номер;
  • текущий статус;
  • время до следующего митинга (либо до его окончания);
  • статусы остальных комнат в нижней части экрана.

Обратите внимание: циферблат отображает только 12 часов, т.к. система настроена под нужды компании (планшеты «Аркадии» работают с 8 утра до 8 вечера, включаются и выключаются автоматически)




Для того чтобы забронировать комнату, достаточно вызвать окно бронирования и указать длительность митинга. Действия для бронирования остальных комнат аналогичны, только начинаются с нажатия на иконку комнаты.



Если вы хотите назначить митинг на конкретное время, то переходите на соседнюю вкладку, в список митингов, которые пройдут сегодня в переговорке, и кликаете на свободное время. Далее всё как в первом случае.

Полное дерево переходов должно выглядеть как-то так:




Попробуем это грамотно реализовать.

Стек технологий


Приёмы разработки довольно быстро развиваются и меняются. Еще 2 года Java была официальным языком Android-разработки. Все писали на Java и использовали data binding. Сейчас, как мне кажется, мы отходим в направлении реактивного программирования и Kotlin. Java — отличный язык, но она имеет некоторое несовершенство в сравнении с тем, что может предложить Kotlin и AndroidX. Kotlin и AndroidX способны сократить использование data binding до минимума, если не исключить его полностью. Ниже я попробую объяснить свою точку зрения.

Kotlin


Думаю, многие Android-разработчики уже перешли на Kotlin, и потому согласятся со мной, что писать в 2019 году новый Android-проект на любом другом языке, кроме Kotlin — всё равно что бороться с морем. Конечно, вы можете возразить, а как же Flutter и Dart? Как же С++, C# и даже Cordova? На что я отвечу: выбор всегда остаётся за вами.

В 480 г. до н.э. персидский царь Ксеркс велел своим солдатам сечь море в наказание за то, что оно погубило часть его армии во время шторма, а пятью веками позже римский император Калигула объявил войну Посейдону. Дело вкуса. Для 9 из 10 проектов Kotlin — это хорошо, а для 10-го может оказаться плохо. Всё зависит от вас, от ваших желаний и стремлений.

Kotlin — мой выбор. Язык прост и красив. Писать на нём легко и приятно, а главное, нет необходимости писать лишнее: data class, object, опциональность setter и getter, простые lambda-выражения и функции-расширения. Это только крошечная часть из того, что может предложить этот язык. Если вы ещё не перешли на Kotlin — смело переходите! В разделе с практикой я продемонстрирую некоторые преимущества языка (не является рекламной офертой).

Model-View-ViewModel


На данный момент MVVM — это рекомендуемая архитектура приложения от Google. В ходе разработки мы будем придерживаться именно этого паттерна, однако полностью его соблюдать не станем, так как MVVM рекомендует использовать data binding, мы же от него отказываемся.

Плюсы MVVM

  • Разграничение бизнес-логики и UI. В корректной реализации MVVM во ViewModel не должно быть ни одного import android, за исключением LiveData-объектов из пакетов AndroidX или Jetpack. Правильное использование автоматически оставляет всю работу с UI внутри fragments и activities. Не правда ли, здорово?
  • Прокачивается уровень инкапсуляции. Работать командой будет проще: теперь вы можете работать все вместе на одном экране и не мешать друг другу. Пока один разработчик работает с экраном, другой может строить ViewModel, третий писать запросы в Repository.
  • MVVM положительно сказывается на написании unit-тестов. Этот пункт как бы вытекает из предыдущего. Если все классы и методы инкапсулированы от работы с UI, они легко могут быть протестированы.
  • Естественное решение с поворотом экрана. Как бы это странно ни прозвучало, но эта возможность приобретается автоматически, с переходом на MVVM (т.к. данные хранятся во ViewModel). Если вы проверите довольно популярные приложения (VK, Telegram, Сбербанк-Online и Aviasales), то окажется, что ровно половина из них не способны повернуть экран. Что вызывает у меня некоторое удивление и непонимание, как у пользователя этих приложений.

Чем опасен MVVM?

  • Утечка памяти. Эта опасная ошибка случается, если вы нарушаете законы использования LiveData и observer. Мы подробно рассмотрим эту ошибку в разделе практики.
  • Разрастающаяся ViewModel. Если вы попытаетесь уместить во ViewModel всю бизнес-логику, то получите нечитабельный код. Выходом из этого положения может стать дробление ViewModel на иерархии, либо использование «Presenter-ов». Именно так я и поступил.

Правила работы с MVVM

Начнём с наиболее грубых ошибок и пойдём к менее грубым:

  • тело запроса не должно находиться во ViewModel (только в Repository);
  • LiveData объекты определены именно во ViewModel, они не прокидываются внутрь Repository, т.к. запросы в Repository обрабатываются посредством Rx-Java (либо coroutines);
  • все функции обработки должны быть вынесены в сторонние классы и файлы («Presenters»), дабы не загромождать ViewModel и не отвлекать от сути.

LiveData


LiveData is an observable data holder class. Unlike a regular observable, LiveData is lifecycle-aware, meaning it respects the lifecycle of other app components, such as activities, fragments, or services. This awareness ensures LiveData only updates app component observers that are in an active lifecycle state.
Источник: developer.android.com/topic/libraries/architecture/livedata

Из определения можно сделать простой вывод: LiveData — это надёжный инструмент реактивного программирования. Мы будем использовать его для обновления UI-части без data binding. Почему так?

Структура XML-файлов не позволяет лаконично распределять данные, полученные из <data>…</data>. Если с небольшими файлами всё понятно, то как быть с большими файлами? Что делать со сложными экранами, множественным include и передачей множества полей? Использовать всюду модели? Получать жёсткие привязки по полям? А в случае, если поле должно быть отформатировано, вызывать методы из Java-пакетов? Это безнадежно и окончательно превращает код в спагетти. Совсем не то, что обещал MVVM.

Отказ от data binding сделает изменения UI-части прозрачными. Все обновления будут происходить непосредственно внутри observer-ов. Т.к. код на Kolin лаконичен и понятен, то проблемы с раздутыми observer-ами мы не получим. Писать и поддерживать код станет проще. XML-файлы будут использоваться только для дизайна — никаких property внутри.

Data binding — это мощный инструмент. Он отлично подходит для решения некоторых проблем, а ещё хорошо гармонирует с Java, но с Kotlin… С Kotlin в большинстве случаев data binding просто рудиментарен. Data binding только усложняет код и не даёт никаких конкурентных преимуществ.

В Java у вас был выбор: либо использовать data binding, либо писать много некрасивого кода. В Kotlin можно обращаться к view-элементам напрямую, минуя findViewById(), равно как и к его property. Например:

// Instead of TextView textView = findViewById<TextView>(R.id.textView) 
textView.text = "Hello, world!"
textView.visibility = View.VISIBLE 

Возникает логичный вопрос: зачем городить огород с прокидыванием моделей внутрь XML-файлов, вызывать в XML-файлах Java-методы, перегружать логику XML-части, если всего этого можно избежать?

Coroutines вместо Thread() и Rx-Java


Coroutines невероятно легковесны и просты в использовании. Они идеально подходят для большинства простых асинхронных задач: обработки результатов запросов, обновления UI и т.д.

Coroutines способны эффективно заменить Thread() и Rx-Java в случаях, где не требуется высокая производительность, т.к. за легковесность они расплачиваются быстродействием. Rx-Java, бесспорно, более функциональна, однако для простых задач всех её активов не требуется.

Microsoft и остальные


Для работы с сервисами Outlook будет использован Microsoft Graph API. При соответствующих разрешениях через него можно получить всю необходимую информацию о сотрудниках, комнатах и event-ах (митингах). Для распознавания лиц будет использован облачный сервис Microsoft Face API.

Забегая немного вперед, скажу, что для решения проблемы масштабируемости было использовано облачное хранилище Firebase. Об этом речь пойдет ниже.

Архитектура


Проблемы масштабируемости


Довольно сложно сделать систему полностью или частично масштабируемой. Особенно непросто это сделать, если первая версия приложения не была масштабируемой, а вторая должна стать. Приложение v1 отправляло запросы сразу ко всем комнатам. Каждый из планшетов регулярно посылал запросы к серверу на обновление всех данных. При этом устройства никак не синхронизировались друг с другом, т.к. у проекта просто нет собственного сервера.

Разумеется, если мы пойдём по этому же пути и будем отправлять по N запросов с каждого из N планшетов, то в какой-то момент либо опрокинем Microsoft Graph API, либо получим зависание своей системы.

Было бы логично использовать клиент-серверное решение, в котором сервер опрашивает граф, накапливает данные и по запросу отдаёт информацию планшетам, однако здесь нас встречает реальность. Команда проекта состоит из 2 человек (Android-разработчик и дизайнер). Им нужно уложиться в 7 недель и наличие бэкенда не предусматривается, т.к. масштабирование — это требование от разработчика. Но ведь это не значит, что от идеи нужно отказываться?

Наверное, единственно верным решением в данной ситуации станет использование облачного хранилища. Firebase заменит сервер и будет выступать в качестве буфера. Тогда получается следующее: каждый планшет опрашивает только свой адрес у Microsoft Graph API, и при необходимости синхронизирует данные в облачном хранилище, откуда они могут быть считаны остальными устройствами.

Плюсом такой реализации станет быстрый отклик, т.к. Firebase работает в режиме real-time. Мы в N раз снизим количество запросов, отправляемых на сервер, а значит устройство проработает от батареи чуть дольше. С финансовой точки зрения, проект не подорожал, т.к. для данного проекта бесплатной версии Firebase хватает с многократным запасом: 1 GB хранилища, 10 тыс. авторизаций в месяц и 100 подключений единовременно. К минусам можно было бы отнести зависимость от стороннего фреймворка, но Firebase вызывает у нас доверие, т.к. это стабильный продукт, который поддерживается и развивается Google.

Общая идея новой системы получилась следующей: N планшетов и облачная платформа для синхронизации данных в режиме реального времени. Приступим к проектированию самого приложения.

LiveData в Repository


Казалось бы, я недавно установил правила хорошего тона и сразу же нарушаю одно из них. В отличие от рекомендуемого использования LiveData внутри ViewModel, в этом проекте LiveData-объекты инициализированы в repository, а все repositories объявлены как singleton. Почему так?

Подобное решение связано с режимом работы приложения. Планшеты работают с 8 утра до 8 вечера. Всё это время на них запущен только Meeting Room Helper. Как следствие, многие объекты могут и должны быть долгоживущими (именно поэтому все repository оформлены в виде singleton).

В ходе работы UI-контент регулярно переключается, что, в свою очередь, влечёт создание и пересоздание ViewModel-объектов. Получается, если использовать LiveData внутри ViewModel, то на каждый созданный фрагмент будет создаваться своя собственная ViewModel с набором заданных LiveData-объектов. Если на экране одновременно отображается 2 схожих фрагмента, с разными ViewModel и общей Base-ViewModel, то при инициализации произойдёт дублирование LiveData-объектов из Base-ViewModel. В дальнейшем эти дубликаты будут занимать место в памяти вплоть до их уничтожения «сборщиком мусора». Т.к. у нас уже есть repository в виде singleton и мы хотим минимизировать затраты на пересоздание экранов, то было бы разумно перенести LiveData объекты внутрь singleton-repository, тем самым облегчив объекты ViewModel и ускорив работу приложения.

Конечно, это не означает, что нужно перенести все LiveData из ViewModel в repository, однако стоит более вдумчиво подходить к этому вопросу и делать выбор осознанно. Минусом такого подхода является увеличение числа долгоживущих объектов, т.к. все repository определены как singleton и в каждом из них хранятся LiveData-объекты. Но в конкретном случае Meeting Room Helper это не является минусом, т.к. приложение работает non-stop весь день, без переключения контекста на другие приложения.

Итоговая архитектура




  • Все запросы выполняются в репозиториях. Все репозитории (в Meeting Room Helper их 11) оформлены в виде singleton. Они разделены по типам возвращаемых объектов и скрыты за фасадами.
  • Бизнес-логика располагается во ViewModel. Благодаря использованию «Presenter-ов» суммарный размер всех ViewModel (в проекте их 6) получился менее 120 строк.
  • Activity и fragment занимаются только изменением UI-части, с помощью observer и LiveData, возвращаемых из ViewModel.
  • Функции обработки и генерации данных хранятся в «presenter-ах». Активно используются функции разрешения из Kotlin для обработки данных.

Background-логика была вынесена в Intent-Service:

  • Event-Update-Service. Сервис, отвечающий за синхронизацию данных текущей комнаты в Firebase и Graph API.
  • User-Recognize-Service. Запускается только на мастерском планшете. Отвечает за добавление нового персонала в систему. Сверяет список уже обученных лиц со списком из Active Directory. Если появились новые люди, сервис добавляет их в Face API и переобучает нейронную сеть. По завершении операции — отключается. Запускается при старте приложения.
  • Online-Notification-Service оповещает остальные планшеты о том, что данный планшет функционирует, т.е. внешний аккумулятор не разрядился. Работает через Firebase.

Получилась довольно гибкая и корректная с точки зрения распределения обязанностей архитектура, отвечающая всем требованиям современной разработки. Если в будущем мы откажемся от Microsoft Graph API, Firebase или любого другого модуля, их легко можно будет заменить на новые, не вмешиваясь в работу остального приложения. Наличие разветвлённой системы «презентеров» позволило вынести все функции обработки данных за рамки ядра. В результате архитектура стала кристально чистой, что является большим плюсом. Полностью исчезла проблема разросшейся ViewModel.

Ниже я приведу пример используемой повсеместно связки в разработанном приложении.

Практика. Обновления циферблата


В зависимости от состояния переговорки циферблат показывает одно из следующих состояний:




Кроме того, по контуру циферблата расположены временные дуги митингов, а по центру идёт обратный отсчёт до конца совещания либо до начала следующего митинга. Всем этим занимается разработанная нами canvas-библиотека. В случае если сетка митингов изменилась, мы должны обновить данные в библиотеке.

Так как LiveData объявлены в Repositories, логичнее всего будет начать именно с них.

Repositories


FirebaseRoomRepository — класс, отвечающий за отправку и обработку запросов в Firebase, связанных с моделью Room.

// 1. 
object FirebaseRoomRepository { 
    private val database = FirebaseFactory.database 
    val rooms: MutableList<Room> = ArrayList()

    // 2. 
    var currentRoom: MutableLiveData<Room?> = MutableLiveData() 
    val onlineStatus: MediatorLiveData<HashMap<String, Boolean>> = MediatorLiveData() 
    var otherRooms: MutableLiveData<List<Room>> = MutableLiveData() 
    var ownRoom: MutableLiveData<Room> = MutableLiveData() 

    // 3. 
    private val roomsListener = object : ValueEventListener { 
        override fun onDataChange(dataSnapshot: DataSnapshot) { 
            updateRooms(dataSnapshot) 
        } 
        override fun onCancelled(error: DatabaseError) {} 
    } 

    init { 
        // 4. 
        database.getReference(ROOMS_CURRENT_STATES)
                            .addValueEventListener(roomsListener) 
    } 
 
    // 5. 
    private fun updateRooms(dataSnapshot: DataSnapshot) { 
        rooms.updateRooms(dataSnapshot) 
        otherRooms.updateOtherRooms(rooms) 
        ownRoom.updateOwnRoom(rooms) 
        currentRoom.updateCurrentRoom(rooms, ownRoom) 
    } 
} 

Для демонстрации код инициализации listener firebase был слегка упрощён (удалена функция переподключения). Разберём по пунктам, что здесь происходит:

  1. репозиторий оформлен как singleton (в Kotlin для этого достаточно заменить ключевое слово class на object);
  2. инициализация LiveData-объектов;
  3. ValueEventListener объявлен в качестве переменной для того, чтобы избежать повторного создания анонимного класса в случае переподключения (помните, я упростил инициализацию, убрав переподключение в случае обрыва связи);
  4. инициализация ValueEventListener (если данные в Firebase изменятся, listener тут же отработает и обновит данные в LiveData-объектах);
  5. обновления LiveData-объектов.

Сами функции вынесены в отдельный файл FirebaseRoomRepositoryPresenter и оформлены в качестве функций расширения.

fun MutableLiveData<List<Room>>.updateOtherRooms(rooms: MutableList<Room>) { 
    this.postValue(rooms.filter { !it.isOwnRoom() }) 
} 

Пример функции расширения из FirebaseRoomRepositoryPresenter

Также для общего понимания картины приведу листинг объекта Room.

// 1. 
data class Room(var number: String = "", 
                var nickName: String = "", 
                var email: String? = null, 
                var imgSmall: String? = null, 
                var imgOffline: String? = null, 
                var imgFree: String? = null, 
                var imgWait: String? = null, 
                var imgBusy: String? = null, 
                var events: List<Event.Short> = emptyList()) // 2. 

  1. Data class. Данный модификатор автоматически генерирует и переопределяет методы toString(), HashCode() и equal(). Больше нет нужды переопределять их самостоятельно.
  2. Список Events из объекта Room. Именно этот список требуется для обновления данных в библиотеке циферблата.

Все классы-Repositories скрыты за классом-фасадом.

object Repository { 
    // 1.  
    private val firebaseRoomRepository = FirebaseRoomRepository 
    // ......... 
     
    /** 
     * Rooms queries 
     */ 
    fun getOtherRooms() = firebaseRoomRepository.otherRooms 
 
    fun getOwnRoom() = firebaseRoomRepository.ownRoom 
 
    fun getAllRooms() = firebaseRoomRepository.rooms 
    // 2. 
    fun getCurrentRoom() = firebaseRoomRepository.currentRoom 
     
    // Другие репозитории 
    // ....... 
}

  1. Сверху можно видеть список всех используемых классов-репозиториев и фасадов второго уровня. Это упрощает общее понимание кода и наглядно демонстрирует список всех подключённых классов-repository.
  2. Список методов, возвращающих ссылки на LiveData-объекты из FirebaseRoomRepository. Setter-ы и Getter-ы в Kotlin опциональны, поэтому писать без нужды их не нужно.

Подобная организация позволяет комфортно уместить от 20 до 30 запросов в одном корневом репозитории. Если ваше приложение насчитывает большее количество запросов, вам придется разделить корневой фасад на 2 или более.

ViewModel


BaseViewModel — это базовая ViewModel, от которой наследуются все ViewModels. Она включает в себя один единственный объект currentRoom, используемый повсеместно.

// 1. 
open class BaseViewModel : ViewModel() { 
    // 2. 
    fun getCurrentRoom() = Repository.getCurrentRoom() 
} 

  1. Маркер open означает, что от класса можно наследоваться. По умолчанию в Kotlin все классы и методы являются final, т.е. от классов нельзя наследоваться, а методы нельзя переопределять. Это сделано для защиты от случайных несовместимых версионных изменений. Приведу пример.

    Вы разрабатываете новую версию библиотеки. В какой-то момент по той или иной причине вы решили переименовать класс или изменить сигнатуру какого-то метода. Изменив его, вы случайно создали несовместимость версий. Упс… Если бы вы наверняка знали, что метод может быть кем-то переопределён, а класс унаследован, вы наверняка были бы более аккуратным и вряд ли бы выстрелили себе в ногу. Для этого в Kotlin по умолчанию всё объявлено как final, а для отмены существует модификатор «open».
  2. Метод getCurrentRoom() возвращает ссылку на LiveData-объект текущей комнаты из Repository, который, в свою очередь, взят из FirebaseRoomRepository. При вызове этого метода вернётся объект Room, содержащий всю информацию о комнате, в том числе список событий.

Для того чтобы преобразовать данные из одного формата в другой, воспользуемся трансформацией. Для этого создадим MainFragmentViewModel и наследуем его от BaseViewModel.

MainFragmentViewModel — это класс-наследник от BaseViewModel. Данная ViewModel используется только в MainFragment.

// 1. 
class MainFragmentViewModel: BaseViewModel () { 
    // 2. 
    var currentRoomEvents = Transformations.switchMap(getCurrentRoom()) { 
        val events: MutableLiveData<List<Event.Short>> = MutableLiveData()
        // some business logic
        events.postValue(it?.eventsList) 
        events 
    } 

    // 3. 
    val currentRoomEvents2 = MediatorLiveData<List<Event.Short>>().apply { 
        addSource(getCurrentRoom()) { room -> 
            // some business logic 
            postValue(room?.eventsList) 
        } 
    } 
} 

  1. Обратите внимание на отсутствие модификатора open. Это означает, что от класса никто не наследуется
  2. currentRoomEvents — объект, полученный с помощью трансформации. Как только объект текущей комнаты изменится, выполнится трансформация и объект currentRoomEvents обновится.
  3. MediatorLiveData. Результат идентичен трансформации (приведён для ознакомления).

Первый вариант используется для преобразования данных из одного типа в другой, что нам и требовалось, а второй вариант нужен для выполнения некоторой бизнес-логики. При этом преобразования данных не происходит. Помните, что android import во ViewModel — это недопустимо. Поэтому я запускаю отсюда дополнительные запросы либо перезапускаю сервисы по необходимости.

Важное замечание! Для того, чтобы трансформация или медиатор отработали, на них должен быть кто-то подписан из fragment или activity. В противном случае код не выполнится, т.к. никто не будет ожидать результата (это observer объекты).

MainFragment


Последний этап на пути преобразования данных в результат. MainFragment включает в себя библиотеку циферблата и View-Pager в нижней части экрана.

class MainFragment : BaseFragment() { 
    // 1. 
    private lateinit var viewModel: MainFragmentViewModel 

    // 2. 
    private val currentRoomObserver = Observer<List<Event.Short>> { 
        clockView.updateArcs(it) 
    } 
 
    override fun onAttach(context: Context?) { 
        super.onAttach(context) 
        // 3. 
        viewModel = ViewModelProviders.of(this).get(MainFragmentViewModel::class.java) 
    } 
 
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, 
                              savedInstanceState: Bundle?): View? { 
        return inflater.inflate(R.layout.fragment_main, container, false) 
    } 
     
    override fun onActivityCreated(savedInstanceState: Bundle?) { 
        super.onActivityCreated(savedInstanceState) 
        // 4. 
        viewModel.currentRoomEvents.observe(viewLifecycleOwner, currentRoomObserver) 
    } 
} 

  1. Предварительная инициализация MainFragmentViewModel. Модификатор lateinit указывает на то, что мы обещаем инициализировать этот объект позже, до того, как будем использовать. Kotlin старается защитить программиста от некорректного написания кода, поэтому мы должны либо сразу сказать, что объект может быть null, либо поставить lateinit. В данном случае ViewModel обязательно должно быть инициализировано объектом.
  2. Observer-listener для обновления циферблата.
  3. Инициализация ViewModel. Обратите внимание, это происходит сразу после того, как фрагмент прикрепился к activity.
  4. После того как activity будет создана, мы подписываемся на изменения объекта currentRoomEvents. Обратите внимание, что я подписываюсь не на жизненный цикл фрагмента (this), а на объект viewLifecycleOwner. Дело в том, что в support library 28.0.0 и AndroidX 1.0.0 обнаружился баг при «отписывании» observer. Для решения этой проблемы была выпущена заплатка в виде viewLifecycleOwner, и Google рекомендует подписываться именно на него. Это исправляет проблему зомби-observer-а, когда фрагмент умер, а observer продолжает работать. Если вы всё ещё используете this, обязательно замените его на viewLifecycleOwner.

Таким образом, я хочу продемонстрировать простоту и красоту MVVM и LiveData без использования data binding. Прошу учесть, что в данном проекте я нарушаю общепринятое правило, располагая LiveData в Repository в силу особенностей проекта. Однако, если бы мы переместили их во ViewModel, общая картина осталась бы неизменной.

В качестве вишенки на тортике я подготовил для вас небольшой ролик с демонстрацией (имена замазаны в соответствии с требованиями безопасности, приношу свои извинения):




Итоги


В результате работы приложения в первый месяц были выявлены некоторые баги в отображении перекрестных митингов (Outlook разрешает создавать несколько событий на одно и то же время, в то время как наша система — нет). Сейчас система работает уже 3 месяца. Ошибок или сбоев не наблюдается.

P.S. Спасибо jericho_code за замечание. В Kotlin, можно и нужно инициализировать List<> в моделе с помощью emptyList(), тогда не создается лишний объект.
var events: List<Event.Short> = emptyList() // функция возвращает ссылку на синглтон EmptyList
var events: List<Event.Short> = ArrayList()  // создается лишний объект

+10
2k 19
Comments 5
Top of the day