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()  // создается лишний объект
    
    Аркадия
    65,35
    Заказная разработка, IT-консалтинг
    Поделиться публикацией

    Комментарии 5

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

      А кто сказал, что это все необходимо при использовании Data Binding? Все что требуется это объявить переменную в начале файла, и затем ссылаться на нее в XML:
      android:text=@{viewModel.text}

      все функции обработки должны быть вынесены в сторонние классы и файлы («Presenters»), дабы не загромождать ViewModel и не отвлекать от сути.

      Почему не Interactor или Usecase?
      В результате архитектура стала кристально чистой, что является большим плюсом.

      Если под чистой вы имеете ввиду Clean Architecture, то нет, получилась совсем не она.
      Выглядит она примерно так: View -> ViewModel -> Interactor -> Repository. Уж точно без всяких классов Presenter, принадлежащих фрагментам. View должен делать одно и только одно: мапить уже подготовленные данные в лэйаут, без каких либо дополнительных преобразований, неважно используя синтетические импорты ли или Data Binding.
      Kotlin старается защитить программиста от некорректного написания кода, поэтому мы должны либо сразу сказать, что объект может быть null, либо поставить lateinit.


      вот прям кусок кода из оф документации без того и другого:
      val lazyValue: String by lazy {
          println("computed!")
          "Hello"
      }


      В общем текст неплохой, но уж больно безапелляционный, с кучей маркетинговых словечек и фактических ошибок. Извините.

        0
        Спасибо за ответ. Приятно, когда статью не только читают, но и рассматривают критически.

        Все что требуется это объявить переменную в начале файла, и затем ссылаться на нее в XML
        + обернуть все в

        <layout xmlns:android="http://schemas.android.com/apk/res/android"
                xmlns:app="http://schemas.android.com/apk/res-auto">
            <data>
                <variable
                    name="viewmodel"
                    type="com.myapp.data.ViewModel" />
            </data>
            <ConstraintLayout... /> <!-- UI layout's root element -->
        </layout>

        И конечно, это самый простой случай использования data binding. Если же нужно проверить и обработать данные та же google-документация рекомендует писать так:

        android:text="@{String.valueOf(index + 1)}"
        android:visibility="@{age > 13 ? View.GONE : View.VISIBLE}"
        android:transitionName='@{"image_" + id}'


        Не знаю, как Вас, но меня скручивает от возможности написать такое. Это выглядит программированием из 2000-х или даже из 90-ых. Синтаксис и конструкции морально устарели, и нуждаются в качественном осовременивании. На всякий случае, еще раз скажу — я не против data binding, но только там, где он по-настоящему уместен. Без острой нужды, я не стану им пользоваться.

        Почему не Interactor или Usecase?

        При использовании Interactor получается идеально кристальные фрагменты и активити, которые служат только для обновления UI (если все сделать правильно). Полагаю, для крупных проектов такой подход имеет очевидные преимущества, но для небольших проектов разница едва ли будет заметна. И в случае с Presenter, и в случае с Interactor результат будет аналогичным — фрагменты и активити будут чисты от data-преобразований.

        val lazyValue: String by lazy {
            println("computed!")
            "Hello"
        }


        На сколько я помню, этот код выполняет отложенную инициализацию: при первом обращении к переменной lazyValue, присвоить ей значение «Hello» и вывести в консоль «computed!». Переменная так или иначе была проинициализирована, она не обрели 3-го состояния между Null и lateinit. Разумеется, Kotlin не защитит разработчика от желания стрелять себе в ноги, но значительно снизить эту вероятность. Думаю Вы с этим согласитесь.

        P.S. искренни благодарен за feedback.
        0
        Если же нужно проверить и обработать данные та же google-документация рекомендует писать так:

        Это все прекрасно может жить во ViewModel. Поверьте, и Гугл может быть не прав ))
        При использовании Interactor получается идеально кристальные фрагменты и активити, которые служат только для обновления UI (если все сделать правильно). Полагаю, для крупных проектов такой подход имеет очевидные преимущества, но для небольших проектов разница едва ли будет заметна.

        То есть если надо один гвоздь забить, а не десять — это можно делать тяп-ляп?))

        Переменная так или иначе была проинициализирована, она не обрели 3-го состояния между Null и lateinit.

        Хорошо, в каком она состоянии до первого обращения к ней?
          0
          Если проект отличается от используемого вами патерна, это совсем не значит проект написан плохо. Послушать вас, так все MVP-проекты — «тяп-ляп», там знаете ли тоже presenter-ы есть)) Что же касается ленивого объявления, то в каком именно состоянии находится переменная до инициализация делигатом — документация умалчивает. Логично было бы предположить, что до вызова setValue (метода делигата) переменная находится в неопределенном состоянии. Однако, без вскрытия компилятора вы никак не получите ее в этом состоянии. Отсюда у меня возникает логичный вопрос: зачем было приводить этот пример в самом начале, если ленивая инициализация не меняет принципов разработки? Идея lateinit и объявления переменной null заключается в том, чтобы свести вероятность NullPointerException к нулю, и конечно lazy-инициализация не нарушает этой идеи.
          –1
          Если проект отличается от используемого вами патерна, это совсем не значит проект написан плохо. Послушать вас, так все MVP-проекты — «тяп-ляп», там знаете ли тоже presenter-ы есть))

          Пардон, но статья о MVVM, а там Presenter отсутствует в принципе.
          Отсюда у меня возникает логичный вопрос: зачем было приводить этот пример в самом начале, если ленивая инициализация не меняет принципов разработки?

          Затем, чтобы опровергнуть постулат о том, что мы ДОЛЖНЫ использовать или Nullable переменную или lateinit.
          Что же касается ленивого объявления, то в каком именно состоянии находится переменная до инициализация делигатом — документация умалчивает.

          Можно написать простейший код, поставить точку останова и посмотреть, если интересно.

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

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