Узнайте, как преобразовать 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) поддержка открытого текста по умолчанию отключена:
- Ознакомьтесь с официальным разделом Конфигурация сетевой безопасности — «Отказ от открытого трафика».
- Кроме того, проверьте этот ответ на вопрос StackOverflow: Открытый текстовый HTTP-трафик не разрешен.
На 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>
- Полностью отключить ATS (Официальная вики). Добавьте следующий код в файл
Info.plist
:
<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). Я хочу поблагодарить всех людей, которые так или иначе поддерживают проект. Большое спасибо всем вам! 💙