Узнайте, как преобразовать PWA в приложение Flutter с помощью плагина Flutter InAppWebView 6.

В этой статье мы собираемся преобразовать PWA (прогрессивное веб-приложение) в мобильное приложение Flutter для Android и iOS, используя последнюю версию 6 плагина flutter_inappwebview.

Что такое прогрессивные веб-приложения?

Прогрессивное веб-приложение — это термин, который относится к веб-приложениям, которые разрабатываются и загружаются как обычные веб-страницы, но ведут себя аналогично нативным приложениям при использовании на мобильном устройстве.

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

Разработчики могут опубликовать веб-приложение в Интернете, убедиться, что оно соответствует базовым требованиям к установке, а пользователи могут добавить приложение на главный экран. Публикация приложения в цифровых системах распространения, таких как Apple App Store или Google Play, не является обязательной.

Что такое гибридные приложения?

Гибридные приложения — это приложения, которые сочетают в себе функции как собственных приложений, так и веб-приложений. Они запускаются внутри контейнера, в данном случае WebView.

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

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

Как преобразовать PWA в приложение Flutter

В качестве примера PWA мы будем использовать https://mdn.github.io/pwa-examples/js13kpwa/ (репозиторий GitHub: js13kpwa), который является полнофункциональным PWA с автономной поддержкой.

js13kpwa — это список записей A-Frame, представленных на конкурс js13kGames 2017, который используется в качестве примера для статей MDN о прогрессивных веб-приложениях. js13kPWA имеет структуру оболочки приложения, работает в автономном режиме с работником службы, устанавливается благодаря файлу манифеста и функции Добавить на главный экран, а также повторно включается с помощью уведомлений и push-уведомлений.

Кроме того, для этого варианта использования мы добавим простую двустороннюю связь между JavaScript и Flutter/Dart.

Работники сферы услуг

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

Проверьте https://caniuse.com/serviceworkers на доступность JavaScript Service Worker API в зависимости от версии WebView/Browser.

Сервисные работники доступны на Android, начиная с «Android 5–6.x WebView: Chromium 107», и на iOS, начиная с iOS 14.0+.

В iOS для включения Service Worker API требуется дополнительная настройка с использованием App-Bound Domains (подробнее читайте в статье WebKit — App-Bound Domains).

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

Вы можете указать до десяти доменов, привязанных к приложению, используя ключ Info.plist WKAppBoundDomains.

Итак, нам нужно добавить к нему домен нашего PWA. В противном случае Service Worker API работать не будет. Для нашего варианта использования нам нужно добавить домен mdn.github.io. Вот пример файла ios/Runner/Info.plist:

<dict>
  <!-- ... -->
  <key>WKAppBoundDomains</key>
  <array>
    <string>mdn.github.io</string>
  </array>
  <!-- ... -->
</dict>

Обнаружение интернет-сети

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

Чтобы проверить, есть ли действующее соединение, то есть сотовая сеть или Wi-Fi, мы будем использовать плагин connectivity_plus. Вместо этого, чтобы проверить, подключена ли сеть к Интернету, мы можем попытаться найти адрес хоста, например https://example.com/.

Вот полное обнаружение кода:

Future<bool> isNetworkAvailable() async {
  // check if there is a valid network connection
  final connectivityResult = await Connectivity().checkConnectivity();
  if (connectivityResult != ConnectivityResult.mobile &&
      connectivityResult != ConnectivityResult.wifi) {
    return false;
  }

  // check if the network is really connected to Internet
  try {
    final result = await InternetAddress.lookup('example.com');
    if (result.isEmpty || result[0].rawAddress.isEmpty) {
      return false;
    }
  } on SocketException catch (_) {
    return false;
  }

  return true;
}

Основные настройки InAppWebView

Чтобы InAppWebView работал правильно, нам нужно установить некоторые основные настройки:

InAppWebViewSettings(
    // enable opening windows support
    supportMultipleWindows: true,
    javaScriptCanOpenWindowsAutomatically: true,

    // useful for identifying traffic, e.g. in Google Analytics.
    applicationNameForUserAgent: 'My PWA App Name',
    // Override the User Agent, otherwise some external APIs, such as Google and Facebook logins, will not work
    // because they recognize the default WebView User Agent.
    userAgent:
        'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.5304.105 Mobile Safari/537.36',

    disableDefaultErrorPage: true,

    // enable iOS service worker feature limited to defined App Bound Domains
    limitsNavigationsToAppBoundDomains: true
);

Измените его в зависимости от ваших потребностей.

В этом примере мы включаем поддержку нескольких окон на случай, если мы хотим открыть всплывающие WebView окон.

В некоторых случаях вам также может потребоваться переопределить пользовательский агент на значение, отличное от значения по умолчанию, чтобы иметь возможность использовать некоторые внешние API, такие как входы в Google и Facebook. В противном случае они не будут работать, поскольку распознают и блокируют пользовательский агент WebView по умолчанию.

Кроме того, вы должны установить для параметра limitsNavigationsToAppBoundDomains значение true для включения Service Worker API на iOS.

Поддержка HTTP (не HTTPS)

Начиная с Android 9 (уровень API 28) поддержка открытого текста по умолчанию отключена:

На iOS необходимо отключить функцию Apple Transport Security (ATS). Есть два варианта:

  • Отключить ATS только для определенного домена (Официальная вики): (добавьте следующий код в ваш файл Info.plist)
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSExceptionDomains</key>
  <dict>
    <key>www.yourserver.com</key>
    <dict>
      <!-- add this key to enable subdomains such as sub.yourserver.com -->
      <key>NSIncludesSubdomains</key>
      <true/>
      <!-- add this key to allow standard HTTP requests, thus negating the ATS -->
      <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
      <true/>
      <!-- add this key to specify the minimum TLS version to accept -->
      <key>NSTemporaryExceptionMinimumTLSVersion</key>
      <string>TLSv1.1</string>
    </dict>
  </dict>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key><true/>
</dict>

Другие полезные свойства Info.plist:

  • NSAllowsLocalNetworking: логическое значение, указывающее, разрешать ли загрузку локальных ресурсов (Официальная вики)
  • NSAllowsArbitraryLoadsInWebContent: логическое значение, указывающее, отключены ли все ограничения App Transport Security для запросов, сделанных из веб-представлений (Официальная вики)

ВиджетыBindingObserver

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

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

Вот простая реализация didChangeAppLifecycleState:

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (!kIsWeb) {
    if (webViewController != null &&
        defaultTargetPlatform == TargetPlatform.android) {
      if (state == AppLifecycleState.paused) {
        pauseAll();
      } else {
        resumeAll();
      }
    }
  }
}

void pauseAll() {
  if (defaultTargetPlatform == TargetPlatform.android) {
    webViewController?.pause();
  }
  webViewController?.pauseTimers();
}

void resumeAll() {
  if (defaultTargetPlatform == TargetPlatform.android) {
    webViewController?.resume();
  }
  webViewController?.resumeTimers();
}

УиллПопСкоп

Чтобы обнаруживать нажатия кнопки «Назад» в Android, мы заключаем наше основное приложение виджета Scaffold в виджет WillPopScope и реализуем метод onWillPop, чтобы вернуться в историю WebView.

Вот пример реализации:

@override
Widget build(BuildContext context) {
  return WillPopScope(
    onWillPop: () async {
      // detect Android back button click
      final controller = webViewController;
      if (controller != null) {
        if (await controller.canGoBack()) {
          controller.goBack();
          return false;
        }
      }
      return true;
    },
    child: Scaffold(
        appBar: AppBar(
          // remove the toolbar
          toolbarHeight: 0,
        ),
        body: // ...
    ),
  );
}

Оболочка WebView PWA

Перед загрузкой URL-адреса PWA внутри обертки InAppWebView мы проверяем, доступно ли интернет-соединение с помощью определенной ранее утилиты, и на ее основе нам нужно установить режим кэширования и политику для Android и iOS следующим образом:

// Android-only
final cacheMode = networkAvailable
    ? CacheMode.LOAD_DEFAULT
    : CacheMode.LOAD_CACHE_ELSE_NETWORK;

// iOS-only
final cachePolicy = networkAvailable
    ? URLRequestCachePolicy.USE_PROTOCOL_CACHE_POLICY
    : URLRequestCachePolicy.RETURN_CACHE_DATA_ELSE_LOAD;

cacheMode будет использоваться в свойстве initialSettings, а cachePolicy будет использоваться в URLRequest свойства initialUrlRequest.

Эта логика позволяет нам загружать кешированные данные при недоступном интернет-соединении.

Чтобы ограничить навигацию только хостом PWA, мы реализуем метод shouldOverrideUrlLoading, чтобы проверить, не соответствует ли конкретный HTTP-запрос для основного фрейма хосту PWA, поэтому мы откроем этот запрос в сторонних приложениях с помощью плагина url_launcher:

shouldOverrideUrlLoading:
    (controller, navigationAction) async {
  // restrict navigation to target host, open external links in 3rd party apps
  final uri = navigationAction.request.url;
  if (uri != null &&
      navigationAction.isForMainFrame &&
      uri.host != kPwaHost &&
      await canLaunchUrl(uri)) {
    launchUrl(uri);
    return NavigationActionPolicy.CANCEL;
  }
  return NavigationActionPolicy.ALLOW;
},

Чтобы определить, правильно ли «установлено» PWA в первый раз, мы реализуем метод onLoadStop WebView для проверки доступности интернет-соединения и того, было ли уже установлено PWA:

onLoadStop: (controller, url) async {
  if (await isNetworkAvailable() && !(await isPWAInstalled())) {
    // if network is available and this is the first time
    setPWAInstalled();
  }
},

Две утилиты, isPWAInstalled и setPWAInstalled, можно реализовать следующим образом, используя подключаемый модуль shared_preferences для получения и сохранения статуса установки PWA:

Future<bool> isPWAInstalled() async {
  final prefs = await SharedPreferences.getInstance();
  return prefs.getBool('isInstalled') ?? false;
}

void setPWAInstalled({bool installed = true}) async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setBool('isInstalled', installed);
}

Все эти утилиты позволяют нам определять доступность сети и состояние установки PWA, чтобы мы могли реализовать пользовательскую страницу ошибок, как показано ниже:

onReceivedError: (controller, request, error) async {
  final isForMainFrame = request.isForMainFrame ?? true;
  if (isForMainFrame && !(await isNetworkAvailable())) {
    if (!(await isPWAInstalled())) {
      await controller.loadData(
          data: kHTMLErrorPageNotInstalled);
    }
  }
},

где kHTMLErrorPageNotInstalled — это строка, содержащая наш пользовательский HTML.

Если вам нужно поддерживать JavaScript API веб-уведомлений, к сожалению, Android-родной WebView и iOS-родной WKWebView изначально не поддерживают эту функцию, поэтому мы должны реализовать ее сами! Для примера реализации вы можете посмотреть Пример проекта веб-уведомления. Он использует UserScript для внедрения пользовательского кода JavaScript при запуске веб-страницы для реализации API веб-уведомлений.

Внедренный код JavaScript пытается создать полифилл для оконного объекта Notification и взаимодействовать со стороной Flutter/Dart с помощью обработчиков JavaScript для управления и реализации соответствующего пользовательского интерфейса уведомлений, например, когда вы запрашиваете разрешение с помощью Notification.requestPermission() или когда вы хотите показать уведомление.

Кроме того, если вам нужно поддерживать использование камеры и микрофона (например, приложение WebRTC), вам необходимо реализовать событие onPermissionRequest и запросить разрешения, используя, например, плагин permission_handler. Для получения более подробной информации посетите официальное руководство по WebRTC и Пример проекта WebRTC.

Чтобы управлять запросами, открывающими новое окно, с помощью JavaScript (window.open()) или атрибута target в ссылке (например, target="_blank"), мы должны реализовать событие onCreateWindow и вернуть true, чтобы объявить, что мы обрабатываем запрос. Вот простой пример:

onCreateWindow: (controller, createWindowAction) async {
  showDialog(
    context: context,
    builder: (context) {
      final popupWebViewSettings =
          sharedSettings.copy();
      popupWebViewSettings.supportMultipleWindows =
          false;
      popupWebViewSettings
              .javaScriptCanOpenWindowsAutomatically =
          false;

      return WebViewPopup(
          createWindowAction: createWindowAction,
          popupWebViewSettings: popupWebViewSettings);
    },
  );
  return true;
},

WebViewPopup — это еще один экземпляр InAppWebView внутри виджета AlertDialog, который принимает на вход createWindowAction, чтобы получить windowId для использования в новом WebView. windowId — это идентификатор, используемый нативной стороной для получения правильной ссылки WebView, которую должен показывать Flutter. Всплывающее окно WebView также реализует onCloseWindow для прослушивания, когда всплывающее окно должно быть закрыто и удалено из дерева виджетов:

onCloseWindow: (controller) {
  Navigator.pop(context);
},

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

Чтобы реализовать двустороннюю связь между JavaScript и Flutter/Dart, мы будем использовать функцию JavaScript Handlers.

В нашем случае мы хотим прослушивать клики по HTML-элементу кнопки «Запросить фиктивные уведомления» с идентификатором notifications и отображать SnackBar со случайным текстом, сгенерированным JavaScript.

Для этого мы создаем простой Пользовательский скрипт и внедряем его после загрузки страницы:

initialUserScripts: UnmodifiableListView<UserScript>([
  UserScript(
      source: """
      document.getElementById('notifications').addEventListener('click', function(event) {
        var randomText = Math.random().toString(36).slice(2, 7);
        window.flutter_inappwebview.callHandler('requestDummyNotification', randomText);
      });
      """,
      injectionTime:
          UserScriptInjectionTime.AT_DOCUMENT_END)
]),

Затем мы добавляем соответствующий обработчик JavaScript сразу после создания экземпляра WebView:

onWebViewCreated: (controller) {
  webViewController = controller;

  controller.addJavaScriptHandler(
    handlerName: 'requestDummyNotification',
    callback: (arguments) {
      final String randomText =
          arguments.isNotEmpty ? arguments[0] : '';
      ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(randomText)));
    },
  );
},

Вот результат:

Полный пример кода проекта доступен по адресу https://github.com/pichillilorenzo/flutter_inappwebview_examples/tree/main/pwa_to_flutter_app.

Это все на сегодня!

Вы используете этот плагин? Отправьте свое приложение через страницу Отправить приложение и следуйте инструкциям. Посетите страницу Витрина, чтобы узнать, кто уже использует ее!

Данный проект следует спецификации все участники (contributors). Я хочу поблагодарить всех людей, которые так или иначе поддерживают проект. Большое спасибо всем вам! 💙