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

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

Что такое Flutter Web

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

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

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

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

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

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

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

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 КБ–МБ).

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

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

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

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

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

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

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

UX: мобилка против веба

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

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

Но веб-пользователь может сразу попасть на любой 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. И ничего с этим не поделать.

  • Основные трудности Flutter Web: вес бандла и UX-отличия. Зато у него есть адаптивные паттерны — Abstract–Measure–Branch. А это очень удобно!

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

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

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

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


  1. Mastersland
    17.06.2025 12:56

    опять про уведомления ни слова. Я, правда, писал о них, но хотелось бы диалога)) Но статья интересная, спасибо!