Flutter Web. Часть 1

Привет, меня зовут Максим, я Flutter-разработчик в компании Surf. 

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

Зачем нужен Flutter Web

Flutter Web — не замена Html/Css/Js. Он совершенно не подходит для создания классических веб-сайтов. Блоги, портфолио, доски объявлений, лендинги — там Flutter себя не проявит. 

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

При этом Flutter — прекрасный инструмент для разработки веб-приложений. 

Например, если вы — банк, и вас выгнали из магазина приложений.

Или мессенджер, и хотите быть доступны клиентам не только на мобильных устройства, но и встраиваться в веб-страницы CRM?  

Или же вы — облачный векторный редактор и хотите дать возможность работать пользователям без установки, в любой ос и из любой точки мира прямо в браузере?  

Мы не будем рассматривать особенности разработки приложений на Flutter — они не отличаются от разработки для мобильных ОС. Мы сконцентрируемся на взаимодействии с платформой. В нашем случае, это — веб-браузер и нативный для этой среды язык — JavaScript.

Browser API

Dart планировался как «убийца» js, но что-то пошло не так. 

Он не стал популярен в сообществе. А специальная версия Google Chrome с нативной поддержкой Dart в качестве основного языка в итоге переродилась во Flutter. 

Это сомнительное прошлое до сих пор служит на пользу Flutter и позволяет напрямую вызывать Browser API. Для этих целей есть пакет web, который в Dart 3.4 пришёл на смену целому пулу встроенных в dart-sdk плагинов:  

 Все эти пакеты помечены как устаревшие, их поддержка скоро будет ограничена. И в конечном итоге они будут удалены из Dart SDK.

Browser API — мощный инструмент, который позволяет нам взаимодействовать с браузером. В каком-то смысле при разработке мобильных приложений мы можем воспринимать его как Android/IOS SDK. 

Он позволяет получить доступ к хранилищу кеша, Indexed DB, адресной строке, микрофону, веб-камере. Подробнее о возможностях Browser API  тут

Вызов Browser API из Dart

Начнём  знакомство с Flutter Web. Для этого добавим одноимённый пакет в наш проект.

dart pub add web

Импортируем зависимость в файл.

import 'package:web/web.dart' as html;

Префикс HTML используется для избегания конфликтов имен с другими пакетами и встроенными возможностями языка. Самое простое, что мы можем сделать, это узнать User-Agent пользователя и определить веб-браузер, с которого он использует наше приложение.

Text('Browser: ${userAgent2Browser(html.window.navigator.userAgent)}')

Так мы получаем объект Window из пакета web, используя префикс html. 

Из Navigator мы получаем User-Agent и достаём из него название браузера с помощью метода userAgent2Browser, который реализовали сами.

Просто, не правда ли?

Просто, не правда ли?

URL launcher

Теперь посложнее.  Откроем внешнюю ссылку в соседней вкладке. Для реализации аналога пакета url_launcher в браузере достаточно обратится к объекту Window и передать нужный веб-адрес

 html.window.open('https://www.google.com', 'Google')

bfe182106eebe06a9814d0ac9f185747.gif

Отметим, что метод open () позволяет передавать в него параметры и открывать новую вкладку не просто вкладкой, а отдельным окном, и даже задать размеры или положение.

5a0c1b11672926409318d34bca5b8f0d.gif

html.window.open('https://www.google.com', 'Google', 'left=100, top=100, width=500, height=300, 'popup');

Alert

Не менее классический пример простого использования Browser API — отображение диалога.

final isAccess = html.window.confirm("Open Google?");

if (isAccess) {
  html.window.open("https://www.google.com", "Google");
} else {
  html.window.alert("=(");
}

2d47a55936e30cf6fbe7ad7fca957ad3.gif

Эти примеры показывают, что взаимодействовать с браузером из Dart достаточно просто. 

Но такой подход покрывает только базовые возможности. Если мы хотим сделать нечто более интересное и сложное, нам не обойтись без пакета dart: js_interop

JavaScript interoperability

JavaScript interoperability — это механизм, который обеспечивает совместимость Dart и JavaScript. Он позволяет двум языкам обмениваться данными, вызывать методы друг друга напрямую и с помощью дополнительных обёрток и интерфейсов. 

Обеспечивается этот механизм пакетом dart: js_interop, который уже включен в Dart SDK. Для использования достаточно добавить его импорт.

import 'dart:js_interop';

Этот пакет содержит множество объектов и методов, помогающих выстроить взаимодействие между js и dart.

Пакеты dart: js_interop и dart: js_interop_unsafe в Dart 3.4 пришли на смену package: js, dart: js и dart: js_util. 

Именно их сегодня используют разработчики., Они позволяют не только взаимодействовать с js-слоем, но и подготовить веб-приложение к компиляции в WebAssembly. 

У старых же пакетов ограниченная поддержка. И есть вероятность, что их удалят в новых версиях языка. Так что если вы используете эти пакеты, самое время задуматься о миграции. Кому нужны проблемы в будущем?

Вызов Js из Dart

Начнём со знакомого метода window.alert()

// Указываем название JS-метода
// @JS('window.alert')
// на самом деле вызов можно упростить и вызывать alert
// на прямую, браузер автоматически ищет методы и объекты в window

@JS('alert')
// Указываем что метод  внешний
// и объявляем входные параметры и возвращаемый тип
external void showAlert(String message);

...
ElevatedButton(
      onPressed: () {
        // используем так, будто это метод dart
        showAlert("Hello from dart");
      },

      child: const Text("alert"),
    ),
...

Возможности dart: js_interop позволяют нам описать интерфейс этого метода. И вызывать его так, будто это dart-метод. 

И здесь нам даже не нужно преобразовывать типы, все сделают за нас в процессе компиляции.

А что, если нужно наоборот? Попробуем вызвать метод Dart из Js.

Вызов Dart из JS

/// Объявляем метод
void dartPrint(String message) {
  print('JS say: $message');
}

/// Регистрируем в текущем контексте
html.window.setProperty(
    'printOnDart'.toJS, // Даем название 
    dartPrint.toJS, // Указываем, какой метод будет вызван
);

Здесь обратите свое внимание на геттер .toJS его предоставляет библиотека dart: js_interop, он служит для приведения типов из Dart в Js.

6c8c516b8505b9ee16a874494f5a03b1.gif

Похожим образом мы можем регистрировать коллбэк и обрабатывать события js на стороне Dart.

void onWindowEvent(html.Event e) {
  print(e.type);
}

html.window.onblur = onWindowEvent.toJS;
html.window.onfocus = onWindowEvent.toJS;

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

569bc9fe2cd755bd30455ee8848ecdb1.gif

Использование JS библиотек

Усложним и разнообразим наши примеры — используем стороннюю js-библиотеку для отображения push-уведомлений из dart. 

Для начала подключим её в web/index.html — добавим её в в блок

      <...>
     

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

ab232784c199d4ffbee579ab2074975b.gif

Вызов библиотек не отличается от того, что мы уже пробовали с объектом window. Но тут есть одно усложнение. Взглянем на описание метода Push.create() в документации:

Push.create("Hello world!", {
    body: "How's it hangin'?",
    icon: "/icon.png",
    timeout: 4000,
    onClick: function () {
        window.focus();
        this.close();
    }
});

Метод принимает в себя 2 параметра:

1. Строка, которая будет заголовком оповещения.

2. Некоторая структура, в которой находятся именованные параметры body, icon, timeout и onClick.

  • Может показаться , что второй параметр очень похож на Map. Но это не совсем так.

  • При использовании JS-interop мы налаживаем коммуникацию между двумя разными языками, с разной системой типов. Разработчики Dart SDK предусмотрели авто-приведение для простых и некоторых промежуточных типов данных:

  • базовые типы типы dart: void, bool, num, double, int, String;

  • ссылки на скомпилированные в js объекты dart ExternalDartReference;

  • JSAny и наследники, стандартные типы js (JSString, JSFunction, JSArrayи другие).

Полный список тут

Типа  Map в этом списке нет. Но мы можем преобразовать Map в JSON (JavaScript Object Notation), что и станет эквивалентом Map в js.

Опишем интерфейс для взаимодействия с js-объектом Push:

/// Указываем что данный объект является JS объектом
@JS() 
/// Для использования, нам не нужен инстанс этого объекта,
/// мы вызываем только статические методы
@staticInterop 
class Push {
    /// Объявляем интерфейс внешнего метода
    /// В данном случае нам нужно помочь dart 
    /// определится с типами. 
    /// JSAny? - это родительский тип для всех типов в JS
    /// Можем считать его аналогом Object? в Dart
    external static void create(String title, JSAny? options);
}

Нам остаётся только преобразовать Map в JSON:

 Push.create(
     'Title', {
         'body': 'Hello, World!',
    }.jsify()
);

Этот подход работает, но использовать его нужно осторожно. Метод jsify() и dartify(), служащие для обратного преобразования, скорее всего, будут удалены в ближайших версиях библиотеки dart:js_interop.

Геттер toJS недоступен для структур, да и указывать ключи строкой — не самая безопасная идея — легко опечататься. Так как преобразовывать такие объекты?

Описываем структуру и отмечаем ее как внешнюю @JSExport():

@JSExport()
class Options {
  final String? body;
  final String? icon;

  Options({
    this.body,
    this.icon,
  });
}

Добавляем расширение типа для JSObject с теми же полями и типами данных, что у основной структуры:

extension type OptionsExternal(JSObject _) implements JSObject {
  external String? body;
  external String? icon;
}

Используем метод для регистрации объекта dart как js-объекта createJSInteropWrapper(objectInstance):

Push.create(
    'Title',
    createJSInteropWrapper(
        Options(
            body: 'Hello, World!',
            icon: 'path.to/icon.png',
        ),
    ) as OptionsExternal,
);

c169b008ca1da1f42c7b2f3d8cf427ed.gif

Конец первой части

Межъязыковое взаимодействие Js-Dart устроено достаточно просто. Но писать большой функционал только на Dart не всегда удобно. Как минимум — из-за отсутствия подсказок IDE о доступных методах в Js-объектах. Как максимум — из-за многословного преобразования комплексных объектов. Большие модули гораздо удобнее разрабатывать на нативном языке (Js) и оставлять простой интерфейс для взаимодействия с Dart.

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

Больше полезного про Flutter — в Telegram-канале Surf Flutter Team. 

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

© Habrahabr.ru