Flutter — классный кроссплатформенный мобильный фреймворк, который позволяет разрабатывать качественные приложения для телефонов. Но про другие платформы, что поддерживаются Flutter, известно достаточно мало — в частности, про Flutter Web. 

Вместе с Самиром, Flutter-разработчиком в Surf, мы разберём, что за зверь этот Flutter Web. Посмотрим, что происходит «под капотом», какие трудности возникают в проде, как адаптировать UX под веб и какие виджеты помогут сделать интерфейс удобным.

Мини-содержание

  • Что такое Flutter Web, и как он рендерит UI

  • Основные проблемы производительности и загрузки

  • Отличия UX мобильных и веб-приложений

  • Виджеты и приёмы для адаптивности под десктоп

Что такое Flutter Web

Прежде чем говорить о Flutter Web, вспомним классический Web: любая страница состоит из HTML, CSS и JavaScript. Браузер парсит их и отрисовывает. В таком подходе очевидна зависимость от движка браузера, DOM-структуры и множества API, часть которых могут не поддерживаться в старых версиях или даже в разных браузерах.

Проблемы производительности и загрузки

Есть 3 основные метрики, которые оценивают качество загрузки и работы любой веб страницы:

  • LCP (Largest Contentful Paint) — время загрузки самого большого элемента;

  • INP (Interaction to Next Paint) — время отклика на действия;

  • CLS (Cumulative Layout Shift) — стабильность вёрстки при загрузке.

Эти метрики — основные для оценки качества веб-страницы не только для пользователей, но и для поисковых движков. Чем лучше эти 3 метрики, тем выше сайт отображается в поисковой выдаче. И тем проще он ищется.

Flutter Web работает следующим образом: Dart-код и UI-логика компилируются в JavaScript, а отрисовку выполняет графический движок — CanvasKit  на WebAssembly или рендеринг через Canvas API). То есть Flutter рендерит всё в единый canvas на всю страницу, минуя полноценную DOM структуру. 

Во Flutter какое-то время был HTML-рендерер, но его убрали буквально в феврале этого года — в версии Flutter 3.29. 

По сути, приложение компилируется, подключает canvaskit.js/canvaskit.wasm (~4–5 МБ) и рисует интерфейс как на мобильной платформе. Это не хорошо, и не плохо — так спроектирован Flutter.

Почему это ненастоящий Web?

  • Нет стандартного HTML/CSS — всё рисуется на canvas;

  • Не DOM-элементы — нет привычных селекторов и SEO-навигации;

  • Размер бандла — загрузка движка и приложения получается тяжелее.

Flutter Web-бандл включает:

  1. canvaskit.js/wasm (около 4–5 МБ).

  2. Скомпилированный Dart-код (около нескольких сотен КБ–МБ).

Это существенно больше типичного бандла примерно на 200–300 КБ. При медленном интернете (150 КБ/с) первая загрузка может занимать больше 10 секунд, что серьезно портит основные Web метрики. А они очень важны для SEO. 

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

После первой загрузки необходимых для работы Flutter Web скриптов в последующих перезагрузках подгружается скрипт main.dart.js (который весит меньше 1 МБ), что уже не так страшно.

В последующие перезагрузки страницы бандл canvaskit кешируется браузером, что обеспечивает очень быструю перезагрузку.

Чтобы решить проблему дробления, вспомним про механизм deferred loading. Правда, у него есть существенный недостаток — мобильные платформы с использованием этого API не компилируются. Поэтому оптимизировать загрузку можно только в случае, когда разработка ведется только под Flutter Web. А это не самый большой набор кейсов.

Flutter Web поддерживает  не так много браузеров и их версий, хотя настолько ограниченная поддержка может показаться недопустимой в остальном мире Web-разработки.

Здесь вспомним про позиционирование Flutter Web самими Google — упор на перенос существующих приложений на Web платформу. В таком случае все эти метрики и ограничения действительно не имеют значения, и Flutter Web — всё-таки очень мощный инструмент.

UX: мобильное vs. веб

Точки входа и навигация

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

Но веб-пользователь может сразу попасть на любой URL, используя путь в браузере. А ещё пользователь может в любой момент перезагрузить страницу. С первого взгляда это кажется не особенно страшным, но в мобильной разработке такое поведение неожиданно и непредсказуемо. Поэтому приходится совмещать логику веба и мобильной разработки.

Решение — хранить состояние в URL (queryParameters или path) и использовать auto_route с includePrefixMatches для восстановления стека навигации.

Хоть это решение не идеально и может привести к проблемам с производительностью (в частности, весь стек виджетов инициализируется заново), обеспечение ожидаемого поведения пользователю — более приоритетная задача.

Отдельно отметим, что использование Router API в вебе — это мастхев. Router API даёт возможность работать с URL в адресной строке, а это чуть ли не основное функциональное требование к сайту.

return MaterialApp.router(
     routerConfig: _appRouter.config(includePrefixMatches: true),
   );
  . . .
  app_router.dart:
   /// Debug route.
   AutoRoute(
     page: DebugRoute.page,
     path: AppRoutePaths.debugPath, /// Обязательно указывать путь!
     children: [AutoRoute(page: DebugScreenRoute.page, initial: true)],
   ),

Этот параметр после перезагрузки страницы восстанавливает весь стек навигации. Пользователю снова будут доступны возможности закрытия и возвращения на предыдущие экраны. 

Однако у многих экранов есть начальные параметры, которые для полноценного восстановления должны быть читаемы из URL. Дело в том, что  при перезагрузке конкретные объекты восстановить невозможно. Делается это также через auto_route:

/// {@template structure_screen.class}
/// SomeScreen.
/// {@endtemplate}
@RoutePage()
class SomeScreen extends StatelessWidget
   implements AutoRouteWrapper {
 /// {@macro structure_screen.class}
 SomeScreen({
   @PathParam('departmentId') required String departmentId,
   @QueryParam('departmentName') String? departmentName,
   super.key,
 });


 ...


}

Модальные окна и Bottom Sheets

Для всплывающего контента для мобильной и веб-разработки ожидания пользователя отличаются:

  • мобайл: BottomSheet привычен;

  • веб или десктоп: полноэкранные или боковые диалоги и сайдбары.

Решение: применять шаблон Abstract–Measure–Branch.

Подход рекомендует и сама команда Flutter:

  1. Abstract: общая логика в view model.

  2. Measure: MediaQuery.sizeOf(context).width > 800.

  3. Branch: выбирать BottomSheet или Dialog.

Пример:

  1. Выделяем 2 разных виджета, которые предоставляют верстку для мобильной и десктопной версии. Логика у них общая, поэтому польза от распространенных паттернов мобильной разработки — например, MWWM — при этом подходе становится ещё больше.

  2. Вводим проверку для показа мобильного или десктопного виджетов. В нашем случае создаём специальный extension на BuildContext, в котором проверяется ширина устройства. Если она больше 600 пикселей — показываем десктопную версию, иначе — мобильную.

    Есть соблазн просто взять константу kIsWeb и на основе ее показывать десктопный контент, но не стоит забывать, что сайт можно открыть не только с компьютера, но и с телефона, а приложение можно открыть на планшете, именно поэтому основной метрикой здесь выступает логическая ширина экрана.

  3. В зависимости от перечисленных факторов показываем контент для разных устройств.

Future<List<AttendeeLongEntitity>?> showRejectMeeting({
 required BuildContext context,
 ...
}) {
 /// Branch.
 if (context.isDesktop) { /// Measure.
   return showAppDialog(
     context,
     /// Abstract.
     RejectMeetingDesktopWidget(
       . . .
     ),
     dialogSize: const Size(AppSizes.double500, AppSizes.double488),
   );
 }


 /// Branch.


 return showAppBottomSheet<List<AttendeeLongEntitity>>(
   context,
   /// Abstract.
   RejectMeetingMobileWidget(
       . . .
   ),
 );
}

Dropdown

Распространенный компонент веб-страниц —  Dropdown-виджет. Во Flutter есть инструменты для его реализации, а именно — связка OverlayPortal и CompositedTransformTarget/Follower.

class __TimeRangeFieldState extends State<_TimeRangeField> {
 final _controller = OverlayPortalController();


 final _link = LayerLink();


 final _timeController = ValueNotifier<TimeOfDay?>(null);


 @override
 Widget build(BuildContext context) {
   return OverlayPortal(
     controller: _controller,
     overlayChildBuilder:
         (ctx) => CompositedTransformFollower(
           link: _link,
           offset: AppConsts.kDefaultShadowOffset,
           targetAnchor: Alignment.bottomLeft,
           child: ValueListenableBuilder(
             valueListenable: _timeController,
             builder: (ct, value, child) {
               return Align(
                 alignment: Alignment.topLeft,
                 child: TapRegion(
                   onTapOutside: (_) => _controller.hide(),
                   child: _TimeDropdown(
                     ...
                   ),
                 ),
               );
             },
           ),
         ),
     child: CompositedTransformTarget(
       link: _link,
       child: TextFormField(onTap: _controller.toggle),
     ),
   );
 }
}

Итак, что здесь происходит:

  1. Виджет OverlayPortal используется для отображения контента поверх основного контента. Работает как обычный Overlay, но вместо управления OverlayEntry и его жизненным циклом используется удобный контроллер для переключения видимости — OverlayPortalController.

  2. Виджеты CompositedTransformTarget/CompositedTransformFollower решают другую проблему. Контент дропдауна должен следить за виджетом, именно для этого используется эта пара виджетов. Устроено всё следующим образом: Target передает свою позицию Follower через общий элемент LayerLink. Так, связка виджетов позволяет задавать глобальную позицию в зависимости от виджета в дереве. Также можно использовать дополнительные поля targetAnchor, offset и виджет Align, чтобы задавать сторону, с которойDropdown будет следить за виджетом

Это поверхностное описание рекомендуемого пути реализации Dropdown. Очень рекомендуем посмотреть реализацию под капотом — много полезного можно узнать про работу Flutter.

Ограничение ширины и центрирование

Если грамотно разделять компоненты на мобильные и десктопные, то мы не увернёмся от большого UI Kit-а с общими для обеих платформ компонентами. 

Например, экран для мобильной версии, если его растянуть на всю десктопную ширину, будет выглядеть, признаемся, слабовато. Для решения этой проблемы возьмём следующий виджет:

/// {@template desktop_contrainted_box.dart}
/// Layout widget to wrap scaffold body for desktop.
/// {@endtemplate}
class DesktopConstrainedBox extends StatelessWidget {
 /// {@macro desktop_contrainted_box.dart}
 const DesktopConstrainedBox({
   required this.child,
   this.maxWidth,
   this.wrapWithScrollbar = false,
   super.key,
 });


 /// Max width of the widget.
 final double? maxWidth;


 /// Child [Widget].
 final Widget child;


 /// Should this widget be wrapped with [Scrollbar].
 final bool wrapWithScrollbar;


 @override
 Widget build(BuildContext context) {
   return ConditionalWrapper(
     condition: wrapWithScrollbar,
     onAddWrapper: (wrappedChild) => Scrollbar(child: wrappedChild),
     child: Align(
       child: ConstrainedBox(
         constraints: BoxConstraints(
           maxWidth: maxWidth ?? context.appSizesScheme.mobileWidth,
         ),
         child: child,
       ),
     ),
   );
 }
}

Он ограничит ширину контента по максимальной ширине мобильной версии и центрирует ее.

Изменение курсора 

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

Это несложно сделать с помощью виджета Mouse Region:

  return MouseRegion(
     cursor: SystemMouseCursors.click /// SystemMouseCursors.wait, SystemMouseCursors.progress...,
     child: ...,
   );

Ограничение скролла

Для пользователя сайтов скролл через drag нажатием мышкой — не самое  ожидаемое поведение. Обратите внимание, например, на Google календарь — дни и недели нельзя скроллить, можно только нажимать на кнопку. 

Для отключения такого поведения на Flutter можно реализовать свой Scroll Behavior:

/// Scroll behavior without thumb.
class NoThumbScrollBehavior extends ScrollBehavior {
 @override
 Set<PointerDeviceKind> get dragDevices => {
   PointerDeviceKind.touch,


   /// Disabling dragging/scrolling from any device on web for default.
   ///
   /// As it is expected behavior for average user of desktop platforms to not have
   /// any drag/scroll behavior, we disable it on web only.
   if (!kIsWeb) ...{
     PointerDeviceKind.mouse,
     PointerDeviceKind.stylus,
     PointerDeviceKind.trackpad,
   },
 };


 /// Scroll behavior without thumb.
 const NoThumbScrollBehavior();
}


...


return MaterialApp(
 scrollBehavior: const NoThumbScrollBehavior(),
);

Выбор текста

Во Flutter Web текст нельзя выделять по умолчанию, как на обычном сайте. Такой подлянки пользователь не ждёт, , поэтому возьмём  виджеты для поддержки выделения текста. 

Для этого во Flutter есть логика для выделения текста. Но нам хватит виджета SelectionArea. Он позволяет нам добавить функцию выделения на все дочерние виджеты— то есть реализует интерфейс Selectable — через системный диалог.

  return SelectionArea(
     child: Row(
       children: [
         Text('Hello'), /// implements `Selectable`
         Icon(Icons.phone), /// does not implement `Selectable`
       ],
     ),
   );

Что в итоге

  • Flutter Web — это UI на canvas, а не классический HTML/CSS/JS.

  • Основные трудности: вес бандла и UX-отличия.

  • Адаптивные паттерны (Abstract–Measure–Branch) — вещь!

  • Используем специальные виджеты для десктопа: SelectionArea, MouseRegion

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

Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!

Комментарии (0)