Миграция с Flutter Web на Jaspr: наш опыт

от автора

Flutter и жизненные коллизии

Flutter и жизненные коллизии

– Открывай dart.dev! – Обычная Димина напористость сегодня была приправлена доброй горстью нетерпеливости. – Открыл? Теперь листай вниз! Что ты видишь в правом нижнем углу?
– Ссылки всякие: на Github там, Twitter и т.п. – в растерянности я пытался угадать, на что именно нужно обратить внимание.
Кажется, Дима по ту сторону звонка схватился за голову.
– Видишь баннер “Built with Jaspr”?!
Я молча кивнул и кликнул по баннеру.

Уже несколько лет мы занимаемся развитием клиентского приложения на Flutter: начинали с мобильных платформ, когда веб и десктоп вышли в бету – портировали его и на эти платформы. Последние два года PWA крутится в проде и в некоторых регионах является самой популярной платформой для нашего приложения.

Недавно мы попробовали мигрировать наше приложение с Flutter Web на Jaspr – под катом расскажем о том, почему мы это сделали, с какими трудностями столкнулись и какие выводы сделали.

Что за Jaspr такой?

Jaspr — это современный веб-фреймворк с открытым исходным кодом, написанный на языке Dart и предназначенный для создания быстрых, производительных веб-сайтов и веб-приложений.

Главная идея Jaspr заключается в том, чтобы перенести компонентный подход и реактивную модель разработки из Flutter в нативный веб. В отличие от классического Flutter Web, который рендерит интерфейс на Canvas, Jaspr генерирует стандартный HTML и CSS, как это делают React, Vue или Angular.

Фреймворк поддерживает рендеринг на стороне сервера (SSR), генерацию статических сайтов (SSG) и классические одностраничные приложения (SPA).

Очень рекомендую посмотреть сайт проекта, там наглядно показано, чем Jaspr похож на Flutter, а в чём они отличаются.

Что не так с Flutter Web?

Для нас главная ахиллесова пята приложений на Flutter Web – долгий холодный старт. Дело в том, что PWA, написанное на Flutter Web, должно загрузить canvaskit.wasm (несколько МБ) и некоторые другие файлы прежде, чем сможет нарисовать первый кадр. При хорошем интернете это не проблема, но в регионах, где нестабильный 3G – это максимум, на который можно рассчитывать, становится больно. Первый запуск занимает несколько секунд, при плохом раскладе – несколько десятков.

Timeline приложения на Flutter Web

Timeline приложения на Flutter Web
Угнать за 6 секунд

Почти 6 секунд потребовалось, чтобы загрузить необходимые ресурсы до первого запроса к API. Да, скорость интернета в данном случае была паршивой, но ведь пользователям не интересны причины.

Аналитика показала, что пользователь не готов столько ждать – если загрузка длилась более 6 секунд, то мы теряем потенциального клиента (напомню, речь о первом старте приложения).

Ещё одно неудобство для конечных пользователей – приложение ведёт себя не как “обычный” сайт: нельзя воспользоваться поиском по странице, не работает выделение текста (без дополнительных приседаний со стороны разработчиков) и т.п. Особенно это становится заметно в десктопном варианте, где пользовательский опыт существенно отличается от мобильного.

Вот тут-то на сцену и выходит Jaspr: с его рендером в нативный DOM, в теории, можно избежать загрузки лишнего рантайма и бонусом получить привычный для пользователя “обычный сайт”.

Jaspr: Proof of Concept

Основное наше клиентское приложение имеет внушительную (по меркам Flutter проектов) кодовую базу и включает в себя работу с SignalR с кастомными протоколами, нетривиальную логику подписок на события и т.д. Объём передаваемых по сокетам данных может измеряться мегабайтами и вся эта красота должна оперативно обновлять ряд экранов в рантайме.

Для проверки концепции я выбрал один из самых нагруженных экранов, для которого как раз и требовалась корректная работа с SignalR – благо модульная структура нашего приложения позволяла.

Jaspr не может рисовать виджеты Flutter, поэтому план был таким:

  1. Вынести в чисто дартовые пакеты общий код (работа с сетью, бизнес-логика и т.п.);

  2. Перевести “старые” Flutter модули на использование этих дартовых пакетов;

  3. Реализовать Jaspr версии “старых” модулей и собрать Jaspr приложение.

Схема разделения пакетов

Схема разделения пакетов
Немного о структуре проекта

На самом деле у нас не одно приложение, а несколько. И собираются они из нескольких десятков пакетов – такой подход позволяет, с одной стороны, изолировать код, а с другой – использовать его повторно. Наши пакеты, за редким исключением имеют одинаковую структуру:

my_package/├── lib/│   ├── src/│   │   ├── api/          # работа с сетью, DTO│   │   ├── domain/       # бизнес-логика -- репозитории, сервисы│   │   ├── models/       # доменные модели данных│   │   └── presentation/ # экраны, виджеты, утилиты для представления│   └── my_package.dart└── pubspec.yaml

api, domain и models, как правило, не требуют зависимости от Flutter SDK. Их можно безболезненно вынести в отдельный Dart пакет. Мы в команде договорились к таким пакетам добавлять суффикс shared.

Вечером я расчехлил Cursor и взялся за работу. Довольно быстро появились первые инсайты:

  • Наш код имел гораздо большую связанность, чем я предполагал;

  • Зависимость на Flutter SDK имели даже те пакеты, которым, казалось бы, никакой Flutter не нужен;

  • Web платформа имеет ряд своих особенностей (ладно, это я знал и раньше, но никогда не сталкивался в таких масштабах);

  • Я совершенно не разбираюсь в современной веб вёрстке.

Стало очевидно, что требуемый объём работ сильно больше, чем я ожидал первоначально. Для proof of concept пришлось сузить вводные: пока плюём на работоспособность Flutter версии, заменяем моками или упрощёнными реализациями ряд компонентов. Оставляем только основной сценарий: инициализация -> подключение к SignalR -> подписка на события -> отрисовка данных на экране с real time обновлениями.

Спустя несколько часов и полностью выжженные лимиты 20-долларовой подписки Cursor, мой прототип был готов. Визуально экран был очень далёк от flutter оригинала, линтер ругался как ненормальный на полученный код, тесты были сломаны, а diff включал в себя больше тысячи изменённых файлов – Bitbucket был просто не в состоянии показать их все.

Но этот прототип работал. И работал хорошо: за вычетом внешнего вида, экран вёл себя ровно так, как и требовалось.

И как же быстро он загружался! После Flutter PWA это был как глоток свежего воздуха – холодный старт занимал 1-1,5 секунды, а после включения экспериментальной компиляции в WASM так и вовсе измерялся в миллисекундах.

Да, конечно, прототип был сильно меньше оригинального Flutter приложения, но он показал очень хороший потенциал.

Мы обсудили с ребятами результаты, прикинули возможные подводные камни, подготовили демо и пошли защищаться перед бизнесом.

Jaspr: MVP

Бизнес очертил перечень модулей, которые необходимы в MVP, чтобы эксперимент считался успешным и дал вводные: команда из двух с половиной флаттеристов (с учётом отпусков), срок – квартал.

К этим вводным мы добавили свои:

  • пишем максимально приближенный к Flutter реализации код, чтобы уменьшить сложность перехода флаттеристов;

  • не используем условные импорты (у Jaspr не может быть io платформ);

  • стили компонентов описываем в отдельных CSS/SCSS файлах, не вмешивая сырые строки со стилями в dart коде.

Про стили

Jaspr из коробки предоставляет несколько подходов к стилизации компонентов. Но мы пошли своим путём не из-за их фатального недостатка: на встрече с бизнесом прозвучало пожелание сделать так, чтобы что-то из проделанной работы можно было использовать с условным React – если результаты будут признаны неудовлетворительными.
С паршивой овцы – хоть шерсти клок, ага.
Портировать вёрстку показалось делом неблагодарным, а вот CSS файлы вполне могли бы использоваться как есть.

Поэтому нам пришлось портировать некоторые используемые пакеты на Jaspr: например, с опорой на используемый нами в Flutter приложении пакет elementary мы запилили свой jaspr_elementary. Также потребовалось создать несколько локальных утилит, вроде генератора CSS стилей с watch режимом.

Про генерацию стилей

Допустим мы хотим создать компонент-контейнер ListGroup. Создаём файл стилей list_group.scss:

.list-group {  display: flex;  flex-direction: column;  align-items: stretch;  width: 100%;  box-sizing: border-box;  border-radius: var(--radius-m, 16px);  background-color: var(--component-base-card-background);  overflow: hidden;  & > :not(:last-child) {    border-bottom: 1px solid var(--component-divider-low);  }}

Выходные псевдоклассы будут иметь вид:

._72db67ef_list_group__list-group {  display: flex;  flex-direction: column;  align-items: stretch;  width: 100%;  box-sizing: border-box;  border-radius: var(--radius-m, 16px);  background-color: var(--component-base-card-background);  overflow: hidden;}._72db67ef_list_group__list-group > :not(:last-child) {  border-bottom: 1px solid var(--component-divider-low);}

Хэш-префикс в начале зависит только от пути к исходному файлу, за счёт чего гарантируется уникальность имён псевдоклассов. Это также позволяет обойтись без шума в диффах – хэш будет одинаковым на любой платформе у всех разработчиков.

Помимо выходных *.css генерируется ещё файл *.styles.dart с константами псевдоклассов:

abstract class ListGroupStyles {  static const listGroup = '_72db67ef_list_group__list-group';}

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

Теперь можно собрать всё вместе и использовать в Jaspr компоненте:

import 'package:jaspr/dom.dart';import 'package:jaspr/jaspr.dart';import 'list_group.styles.dart';class ListGroup extends StatelessComponent {  final List<Component> children;  const ListGroup({    super.key,    required this.children,  });  @override  Component build(BuildContext context) => div(    children,    classes: ListGroupStyles.listGroup,  );}

И остался последний элемент системы: watch процесс, который отслеживает изменения в *.scss файлах, вызывает для них инкрементальную компиляцию и генерацию новых *.css и *.styles.dart файлов. А затем копирует выходные .css файлы в каталог web: jaspr serve подхватит изменение веб-ассетов и обновит страницу.

Благодаря этому инструменту вёрстка компонентов стала почти такой же удобной, как и во Flutter.

В целом план реализации MVP практически совпадал с оным для прототипа:

  • Выносим общий код в Dart пакеты;

  • Мигрируем на них Flutter приложение;

  • Реализуем Jaspr версию приложения.

Только теперь без срезанных углов. Мы составили примерную диаграмму Ганта и, засучив рукава, взялись за работу.

Первые месяц-полтора мы сосредоточились на распутывании наших зависимостей, выделении общего кода и подготовки инструментов. Показывать бизнесу в тот период было практически нечего, но зато улучшили качество кода в целом, плюс получили новые инсайты, улучшающие и Flutter приложение. Пожалуй, именно в это время мы сполна ощутили профиты от использования модульности и Clean Architecture подхода.

И да – оказывается, мы очень недурно проектируем и пишем Dart код! Большая часть написанного нами без сучка, без задоринки было готово работать с Jaspr. Пользуясь случаем, передаю ребятам привет – вы очень крутые разработчики!

Затем мы приступили к портированию экранов. И опять же, значительная часть логики UI (которая в Flutter версии у нас хранилась в WidgetModel) прямо кусками переносилась в ComponentModel компонентов Jaspr – тут хорошо сыграло решение с портированием Elementary.

Кстати, хозяйке на заметку: если Cursor сдобрить несколькими специфичными для Jaspr правилами и скиллами, то он очень неплохо готовит Jaspr компоненты на основе референса Flutter виджетов. Это сильно сократило время реализации.

И если к концу первого месяца даже у разработчиков были сомнения, что мы уложимся в дедлайн, то к концу второго месяца стало очевидно – MVP будет готов. Возможно с некоторыми отличиями от макетов, но в функциональном смысле готов на 100%.

Результаты и выводы

Результат полностью оправдал наши ожидания: несмотря на то, что MVP был сильно больше прототипа, холодный старт сохранился на уровне 1-1,5 секунд (без компиляции в WASM).

Timeline приложения на Jaspr

Timeline приложения на Jaspr
Есть только миг

Чуть больше секунды на холодный старт до первого запроса к API. При тех же условиях версия на Flutter Web давала 6 секунд.

Бонусом мы неплохо отрефакторили код, а ряд идей, появившихся в процессе работы над Jaspr версией, были портированы и во Flutter приложение.

Мы сознательно упростили себе жизнь и использовали client mode, но Jaspr умеет и в SSR. А значит потенциально можно добиться ещё более лучших результатов.

Почему не SSR?

Как-то так получилось, что среди наших флаттеристов не было никого с опытом в веб-разработке. С учётом сжатых сроков и новой для нас технологии, мы решили, что погружаться в дебри SSR было бы нерационально. Но в дальнейшем мы всё-таки планируем его использовать.

При этом важно отметить сроки реализации: по нашим оценкам разработка аналогичного React приложения заняла бы кратно больше времени – это как раз тот случай, когда кросс-платформенность и переиспользование кода даёт огромный прирост.

Несколько выводов, которые мы сделали по результатам этого эксперимента:

  • Чистая архитектура и модульность – это реально очень важно! Не срезайте углы, потом может стать больно;

  • ИИ-инструменты хорошо помогают тем, кто их настраивает под проект. У остальных – жрёт токены;

  • Jaspr – действительно неплохая альтернатива Flutter Web для ряда сценариев;

  • Если вы работаете с вебом, вам не помешает иметь веб-разработчика в команде.

В целом Jaspr очень крутой проект, который позволяет Flutter разработчикам создавать веб-приложения с использованием имеющегося опыта. Но нужно трезво подходить к выбору технологий – Jaspr (как и Flutter Web) хорошо подходит, если у вас есть сильная Dart команда и вы планируете переиспользовать большую часть кода.

ссылка на оригинал статьи https://habr.com/ru/articles/1057654/