Пролог
Кто-нибудь пробовал использовать Dart / Flutter как на клиенте на нескольких платформах, так и в качестве сервера? Кто-то, конечно. пробовал, хотя далеко не каждый за этим приходил к Flutter. Я на своём pet-проекте провёл такой эксперимент, и хотел бы поделиться результатами и выводами.
Так уж вышло, что тема кроссплатформы преследует меня довольно давно:
Ещё в институте занимался разработкой десктопных приложений на Qt4. В дальнейшем пробовал возможности Qt на мобильных устройствах на примере фреймворка Felgo.
Сам разработал и вывел в продакшн несколько информационных мобильных приложений на DrupalGap и Ionic.
В рамках одного из стартапов моей компании реализовано простенькое мобильное приложение на Vue-фреймворке Quazar.
Для одного из клиентских проектов делали Desktop-версию React-приложения, заворачивая его в Electron. А позднее и адаптировали под мобилки (вернее, электронные доски в школах, но там ведь Android).
Поэтому, прочитав многочисленные материалы о возможностях Flutter работать на всех платформах, я уже не мог оставаться равнодушным, хотелось лично познакомиться с технологией, хотя бы поверхностно, и понять, насколько и правда короссплатформенна её кроссплатформа?
В этой статье не будет столь любимых сообществом обсуждений, как же на самом деле надо хранить состояние, какова идеальная архитектура приложения и т.п. Более того, я бы сказал, что многие архитектурные и инфраструктурные решения, описанные здесь, крайне не рекомендуются к применению. Однако, они демонстрируют возможности Dart и Flutter работать в различных условиях, и именно в этом я вижу их ценность.
Наш подопытный
Классический ToDo-туториал написан много раз кучей различных способов на огромном количестве фротненд-фреймворков. Этот кейс настолько заезжен, что ставить на нем эксперименты нет ни малейшего желания.
К счастью, у меня под рукой была «реальная» задача из жизни: «оцифровать» настольную игру, суть которой – совместное составление единой истории. Чем-то напоминает Dungeons & Dragons, но все-таки существенно от него отличается: есть три колоды карт (место, персонаж и событие). Перед стартом игры выбирается тема повествования, а дальше игроки по очереди тянут карту из любой колоды и продолжают начатую гейм-мастером историю, подстраиваясь под рандом, который выпал им в карточке. Собственного персонажа никто не имеет, можно в принципе вытворять что угодно с любым аспектом вселенной – лишь бы это соответствовало вытянутой карте. Степень серьёзности повествования определяется игроками «на берегу», тут можно как упороться, так и попробовать серьёзно что-то моделировать.
Как собраться оффлайн и тянуть карты из колоды, когда всех рассадили на карантин в пандемию? Мы пробовали показывать карты в камеру Zoom, но по причине очень разного качества связи у игроков это была плохая идея. Кроме того, даже в оффлайне бывали случаи, что народ собрался, и мы бы даже сыграли, но обладатель колоды карт не пришел, или карты с собой не взял. Обидно, однако!
Часть 1. Backend. Telegram-бот.
Написать чат-бота – наверное, самое простое на сегодняшний день решение, если нужно сетевое взаимодействие между игроками, а игра из себя представляет что-то вроде текстового квеста. Бэкенд и база данных для такой задачи практически не нужны. Впрочем, я пытался сделать по-разному… не вдаваясь в подробности моих исканий в процессе разработки, дам обратную связь по технологиям и библиотекам. С которыми мне довелось при этом работать:
TeleDart - библиотека для создания бота для Телеграм. Впечатления положительные, работает как в режиме long-polling, так и веб-хуком. Дорабатывается и обновляется автором, но не могу сказать, что прям «динамически развивается». Впрочем, оно и к лучшему, будет стабильнее код, лишь бы автор не забросил.
Dartis - клиент Redis для Dart. Отлично работает, но вот как раз тот случай, когда либа была написана однажды, и далее заброшена автором. Уже сейчас в ней нет поддержки null-safety, что исключает беспроблемное использование в своих проектах, а в перспективе оно будет всё менее и менее совместимо с актуальной версией Dart. Да и не все возможности Redis поддерживаются. Альтернативные либы есть, но они ещё в более плачевном и заброшенном состоянии, чем эта. Хотя в целом впечатления от использования приятные, в последствии решил всё-таки его выпилить и хранить всё, что нужно, в переменных внутри приложения, хотя это и не очень круто, особенно на случай креша.
Mysql1 - ещё одна моя попытка найти постоянное хранилище данных, более-менее традиционное для серверных приложений. Увы, и тут беда: либа работает, но функционал также в полудописанном состоянии. Тем, кто привык к мощным ORM, будет больно и грустно. Впрочем, у меня возникли проблемы даже с обработкой собственноручно написанных SQL запросов: в библиотеке не реализована обработка некоторых типов колонок (в моём случае был blob), а такие колонки просто игнорируются в выборке. Печально, но, боюсь, это направление тоже ещё очень долго будет не юзабельным, если вообще не отомрёт.
Firebase. Это решение я не использовал, хотя, похоже, тут как раз всё хорошо. Но меня не устроил вариант коммититься на хранилище данных, которое я не смогу, в случае необходимости, перенести на свой хостинг. Кроме того, оттолкнула очень долгая схема авторизации, в то время как у меня простое серверное приложение, и достаточно было бы авторизации по секретному токену. Простите мне мою лень.
Parse server SDK. Вот этот вариант показался мне гораздо более дружелюбным, в итоге и остановился на нем. Есть куча облачных продуктов, к которым можно подключиться на бесплатном тарифном плане, если не хочется поднимать у себя. Но и у себя можно поднять opensource-сервер, который будет преспокойно работать по единому стандарту API. Библиотека поддерживает (де)сериализацию объектов, есть гибкое API для поисковых запросов, а в конечном итоге это всё может сохраняться как раз в SQL базу данных. В общем, похоже, я нашел, что искал, пусть и через «сервис-посредник», но по крайней мере опенсорсный.
Пакеты File и Archive - пользоваться просто и удобно, мне нужно было всего лишь распаковать набор карточек из zip-архива, прочитать JSON и отправить это на сервер, превратив в сущности Parse.
В конечном итоге мой телеграмм-бот использовал пакет на основе TeleDart - teledart_app - который я сам же и написал. Parse server SDK для общения с бэкендом, API для работы с файлами и архивами.
Впечатления от бэкенд-разработки бота:
Благодаря системе типов крайне приятно, что код сразу работает ровно так, как задумано… если, конечно, не делать все переменные dynamic – тогда Dart превратится в PHP с его худшей стороны.
Нужно ловить исключения, причём отдельно в синхронном, а отдельно в асинхронном коде. И с асинхронным кодом сложнее, т.к. там нельзя просто обернуть всё содержимое main в try – catch и закрыть тем самым все проблемы. Приходится каждый асинхронный вызов «лично» оборачивать, это и утомительно и загромождает код.
Привычных на бэкенде хранилищ для данных нету, для продакшн-проекта это может стать решающим аргументом «против», т.к. невозможно легко и быстро подвязаться к имеющейся инфраструктуре.
Сам бот можно пощупать тут, написав ему в личке /startgame, либо собрать в телеграмме группу, добавить туда бота и запустив игру на несколько игроков.
Часть 2. Backend. REST API
С точки зрения бэкендера могу сказать, что возможность создавать API, хотя бы простейший REST – это куда важнее всяких там ботов. Поэтому следующим шагом я вынес из телеграмм-бота основную логику игры в REST-сервер. К тому же хотелось иметь «слой бизнес-логики», который не будет ничего знать о том, в каком конкретно приложении его используют.
В поисках библиотеки для API-сервера гугл, видимо по старой памяти, навёл меня на Aqueduct. Впоследствии выяснилось, что его поддержка и развитие давно прекратили, библиотека, можно сказать, мертва. Ещё один неприятный пример, как у «всеплатформенного» языка отмирают «непрофильные» библиотеки.
В итоге решил остановиться на Shelf – по крайней мере есть впечатление, что это решение будет жить долго и счастливо и не закроется завтра.
По функционалу rest-сервера возможности минимальны. Если сравнивать с возможностями даже самого простого php-фреймворка, то в Dart всё выглядит бледно и печально. Все-таки мне показалось, он максимум годится для создания крохотных сервисов, решающих одну-несколько мелких задач, без обширного API и «сложносочинённых» запросов. В частности, мне не понравилось негибкость в настройке роутов, без дополнительных пакетов тут не разрулиться, а кто знает, не постигнет ли эти пакеты завтра судьба того же Aqueduct? Подход к унифицированной валидации данных запроса тоже пришлось выдумывать и реализовывать самому.
Зато для Shelf было приятно писать тесты. Прежде всего потому, что не нужно было поднимать сервер отдельно и слать к нему настоящие запросы. Shelf может быть запущен как просто класс, не слушающий никакой адрес и порт, а принимающий данные прямо из кода. Этим я и воспользовался здесь: https://github.com/litgame/rest-server/tree/main/test . Хотя, если очень хочется, то можно и поднять сервер в изоляте перед запуском тестов...
Часть 3. Backend. Стабильность и потребление памяти.
То, что телеграмм-бот у меня работает по схеме long-polling, уже само по себе исключает тестирование производительности, т.к. сколько бы запросов в очередь ни поставили, бот всё равно будет обрабатывать их «в своём темпе», соответственно не сильно и нагружая rest-сервер.
Кроме того, для правильного теста на производительность хорошо бы с чем-то сравнивать, а я не готов переписывать проект повторно на том же PHP или node.js, чтобы померяться с ними скоростью.
Поэтому посмотрим только память. Прежде всего, взглянем на данные мониторинга на production-площадке:
Конечно, у меня там не сотни пользователей, но видно, что за два месяца работы оба бэкенд-сервиса не сильно выходили за свои рамки по памяти. Просадка на верхнем графике скорее всего связана с пересборкой и перезапуском docker-контейнера, в остальном телеграмм-бот занимает 60-70 мегабайт и большего не просит.
«Лесенка» на нижнем графике – это rest-сервер. Он существенно легче и занимает гораздо меньше памяти, и тут явно прослеживается особенность работы GC в Dart: он не станет лишний раз тратить ресурсы на освобождение памяти, если потребление памяти значительно не выросло.
Чтобы посмотреть на работу GC «в лабораторных условиях», я написал фейковый телеграмм-сервер, который запускал одновременно 100 игр по 5 игроков, и они там немного играли, после чего все игры завершались – должна была происходить очистка ресурсов. Не такая уж большая нагрузка, но на бОльших значениях приходилось слишком долго ждать (long polling же, писал выше), и «DevTools Memory page» совсем зависала задолго до окончания процесса.
Вот что я увидел на графике телеграм-бота:
Обратите внимание на выделенную позицию на таймлайне. В первой версии теста именно здесь у меня завершались все начатые игры, но очистки памяти при этом не происходило. Dart продолжал занимать ненужные ресурсы, не вызывая GC, т.к. не происходило существенного роста потребления этих самых ресурсов. Чтобы заставить Dart очистить занятую память, пришлось запустить второй проход «тестовых игр». И только когда пошли новые запросы и потребовалось ещё больше памяти, пришел GC и очистил занятое предыдущими 100 играми место.
Хорошо это или плохо – ну, для серверного приложения, особенно если там запущен ряд конкурирующих за ресурсы процессов, явно не очень. В целом, можно сделать вывод, что хоть утечек памяти и нет, но толком контролировать её расход вы тоже не сможете. Вручную запустить GC в продакшн-сборке способа тоже нет, так что остаётся полагаться только на то, что у вашего железа достаточно памяти и на неё нет других претендентов.
От запуска к запуску поведение GC может меняться – видимо, зависит от других процессов, запущенных на машине. Например, сразу же запустив тест повторно, для телеграм-бота я получил уже немного другой график с «артефактом»:
С REST-сервером картина сходная, но из-за относительной «разгруженности» сервера GC туда приходит крайне редко. Так что, после того как приложение «набрало килограммы», активно выполняя запросы, оно так и остаётся висеть со всем лишним хламом в памяти. Я так и не дождался автоматической очистки. Это всё так и будет болтаться балластом в памяти до ближайшего возрастания нагрузки... На скриншоте как раз показано место, где я не вытерпел и вручную вызвал GC:
Вот снимок памяти до ручного вызова GC:
Выделенные сущности уже должны быть удалены, но Dart всё ещё держит это в памяти. И только после ручного GC это всё исчезает, остаётся только два вида сущностей, которые специально закешированы:
Часть 4. Frontend. Мобильное и прочие приложения
Фронтенду и так посвящено достаточно внимания, так что обозначу самое основное:
В разработке я решил не использовать ничего, кроме setState. Такое бешеное обилие стейт-менеджеров, каждый ориентирован на какие-то привычки фронтендеров с неведомых мне фреймворков… Меня же, как ex-бэкендера, setState вполне устраивает, всё прозрачно и понятно.
Я избегал использования платформо-специфичных библиотек, особенно ориентированных исключительно на мобильную разработку: какая же это кроссплатформа, если не заработает на десктопе и в браузере?
Для тех, кто полезет в код - да, я знаю, что там в итоге получилось страшное спагетти. Но мне сейчас главное, что оно работает. Увы, я уже слишком устал от проекта, чтобы расщедриться на рефакторинг.
Первый же эксперимент, который был проведён – это вживление REST-сервера внутрь приложения. Я выше писал, что shelf может и не принимать данные по сети, а получать напрямую JSON на обработку. Вот это и позволило просто взять готовый слой «бизнес-логики» и воткнуть его в качестве зависимости без каких-либо правок под платформу. Это не самое хорошее решение для production, но именно как иллюстрация универсальности кода на Dart – отлично подойдёт.
Что меня удивило в процессе разработки, так это то, что такие естественные вещи для мобилок, как управление вибрацией и звуком – это не часть Flutter, а библиотеки, написанные сообществом. Но в целом всё работает.
Так же у приложения есть блок загрузки карт для оффлайн использования… здесь удалось поработать с изолятами (чтобы не использовать платформо-зависимые плагины для мобилок).
Кроме вибрации, звука, фоновой загрузки в изоляте и встроенного rest-сервера, ничего технически примечательного в приложении нет – обычные экраны с material-виджетами и анимациями. Получившуюся программу удалось собрать:
Под Android
Под Windows. Здесь пришлось сделать одну платформо-ориентированную вставку, чтобы по кнопке «Выход» вызывался «exit()», а не SystemNavigator.pop(). И добавить перехват исключения на вызов функций модуля Vibration.
Под Web. Здесь пришлось в принципе спрятать кнопку выхода, а также убрать из интерфейса кнопку для загрузки карт в оффлайн, потому что изоляты в вебе не работают. И немного пошаманить с HTML, чтобы было подобие Splash Screen. Кроме того, относительно новый модуль для вибрации пришлось заменить на относительно старый, но с поддержкой веба. Плюс были проблемы с .env файлом – до меня долго не доходило, что не открывается он по сети именно из-за точки, и нужно просто переименовать.
Под Linux не делал сборку, но и не вижу смысла, потому что серверная часть уже работает на Linux, и там всё в порядке – тест на кроссплатформенность считаю пройдённым. Ну и сборку под Mac / iOS не сделал и не смогу сделать, потому что у меня неть.
В заключение добавлю, что для любой платформы собранное приложение обладает весьма скромным весом – от 10 до 34 мегабайт в зависимости от платформы. Сравнивая с решениями на Cordova, особенно если выкачать Crosswalk – это 10 и более мегабайт просто на HelloWorld, сравнивать с решениями на Qt так вообще страшно – там за сотню перевалит.
Часть 5. Релизы.
В целом ничего особенного. Для серверных приложений без проблем можно собрать всё в Docker, как и было сделано для REST-сервера и Telegram-бота. Для андроида всё необходимое описано в официальном мануале.
Повозиться пришлось разве что только с Windows. Прежде всего, встал вопрос инсталлятора, и тут выяснилось, что их целый зоопарк, а прорваться в маркетплейс в качестве разработчика не так-то просто, как в Google Play. Моя заявка до сих пор «на рассмотрении», уже месяца три или четыре …
Второй сюрприз был связан с внешними плагинами. Для проигрывания звуков у меня используется kplayer, набор биндингов для VLC. И с момента добавления этой зависимости в релизной сборке у меня теперь появляется каталог на 335 мегабайт со всеми возможными кодеками для VLC!! Дело не только в размере, а ещё и в где-то 5-секундном фризе перед первым проигрыванием файла, потому что все эти «300 спартанцев» разом начали ломиться в Фермопильский проход загружаться в память… К сожалению, более быстрого/адекватного способа, чем «удалять лишние файлы, пока звук не перестанет работать» я не нашел… в итоге осталось 4 dll суммарно на 15 мегабайт. В принципе, я-то не умер, просто несколько демотивирует тот факт, что 15 лет назад приходилось заниматься тем же самым, и с тех пор прогресс не шагнул вперёд…
Часть 6. Выводы
Итого, если грубо набросать, получился вот такой набор приложений, и всё используя один язык программирования.
Плюс бизнес-логика используется для всех одна, из единой кодовой базы.
Моё личное мнение – какими бы болячками ни болели сейчас Dart и Flutter, на данный момент это наиболее удачное кроссплатформенное решение, которое я видел. Для портирования на другие платформы требуется минимальное количество усилий, много кода можно просто таскать копипастом, и это будет работать везде одинаково хорошо. Да, не хватает тонкого контроля, нативным разработчиком, судя по отзывам – за нативным функционалом, меня лично вот ситуация с памятью расстроила.
Я рад тому, что у меня появился личный «швейцарский нож» для решения мелких бытовых проблем различного характера. Но у большого бизнеса другие потребности, что явно иллюстрирует постоянная полемика между нативщиками и кроссплатформщиками. Тем не менее, считаю, такой простой и гибкий инструмент непременно должен найти и прочно занять свою нишу создания быстрых кроссплатформенных решений… только бы внешних плагинов на все основные нужды хватило.