Последнее обновление:
создаем полнофункциональный браузер

Создаем полнофункциональный браузер используя WebView

Flutter

В этой статье я покажу, как создать полнофункциональный мобильный браузер, такой, как браузер Google Chrome, с использованием функций, предлагаемых плагином flutter_inappwebview.

Вот что мы собираемся реализовать:

  • Вкладка WebView с настраиваемым предварительным просмотром ссылок/изображений при длительном нажатии и возможностью перехода с одной вкладки на другую
  • Верхняя панель (AppBar) браузера с текущим URL-адресом и всеми действиями во всплывающем меню, такими как открытие новой вкладки , режим инкогнито , сохранение текущего URL-адреса в списке избранного , сохранение страницы для автономного использования , просмотр SSL-сертификата используемого веб-сайтом, режим для ПК и т.д. (все функции аналогичны приложению Google Chrome)
  • Консоль разработчика , где можно выполнить код JavaScript , увидеть некоторую сетевую информацию , управлять хранилищем браузера: cookies ,  window.localStorage, и т.д.
  • Страница настроек, где можно обновить общие настройки браузера и включить/отключить функции для каждой вкладки, такие как включение/отключение JavaScript, кэширование, полосы прокрутки, настройка пользовательского агента и т.д.
  • Сохранение и восстановление текущего состояние браузера.

Мы будем использовать provider для управления состоянием приложения.

Две основные модели, используемые для реализации нашего приложения, - это BrowserModel и WebViewModel, которые содержат информацию и данные браузера, такие как список всех вкладок WebView и текущая отображаемая вкладка WebView. Они также будут использоваться для сохранения и восстановления состояния браузера. Для сохранения и восстановления этих моделей мы воспользуемся пакетом shared_preferences.

Поскольку код может быть очень длинным, я собираюсь показать только небольшие части кода или просто псевдокод. Однако полный код доступен на Github по адресу https://github.com/pichillilorenzo/flutter_browser_app.

Также это приложение доступно в Google Play Store: https://play.google.com/store/apps/details?id=com.pichillilorenzo.flutter_browser.

Вкладка WebView

Первое, что мы собираемся реализовать, - это вкладка WebView. Учитывая, что нам необходимо поддерживать состояние каждой вкладки WebView и возможность перехода от одной вкладки к другой, мы будем использовать виджет IndexedStack, где index - это текущая вкладка WebView, которую мы хотим показать, и children список всех вкладок WebView, открытых в браузере.

Мы также покажем индикатор выполнения, который показывает текущий прогресс загрузки WebView, поэтому используем виджет Stack, в котором первый дочерний элемент - это IndexedStack, созданный ранее, а второй - виджет LinearProgressIndicator. Выглядит это примерно так:

var stackChildren = <Widget>[
  IndexedStack(
    index: browserModel.getCurrentTabIndex(),
    children: browserModel.webViewTabs,
  ),
  LinearProgressIndicator()
];

return Stack(
  children: stackChildren,
);

Каждая вкладка WebView будет экземпляром нашего настраиваемого WebViewTab, StatefulWidget которого содержит экземпляр InAppWebView (настоящий WeView) и элемент InAppWebViewController для управления им. Каждая вкладка WebView имеет GlobalKey т.к. должна отличаться от других вкладок браузера и иметь свою WebViewModel, содержащую необходимые данные, такие как текущий URL-адрес и настройки WebView, чтобы иметь возможность восстановить их, когда приложение будет полностью закрыто и повторно открыто пользователем.

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

Для того, чтобы отслеживать изменения URL, мы используем onLoadStart, onLoadStop и onUpdateVisitedHistory . Чтобы отслеживать прогресс загрузки сайта, мы используем событие onProgressChanged. Когда веб-сайт загружен, мы используем onLoadStop для получения названия веб-сайта и его значка, и проверяем, является ли он безопасным на основе протокола HTTPS, наличия сертификата SSL ( X509Certificate ) и любых ошибок SSL.

Кроме того, мы реализуем предварительный просмотр ссылок/изображений. Для этого мы используем onLongPressHitTestResult событие, которое отслеживает длительное нажатие пользователем внутри WebView. Оно возвращает, hitTestResult который содержит полезную информацию о том, что пользователь щелкнул, например, является ли это изображением или ссылкой. Проверяем это и показываем AlertDialog:

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

Кроме того, мы хотим приостановить/возобновить WebView, когда приложение приостанавливается/возобновляется пользователем, и приостановить/возобновить выполнение JavaScript при переходе от одной вкладки WebView к другой.

Для приостановки/возобновления WebView на Android, можно использовать InAppWebViewController.android.pause() и InAppWebViewController.android.resume() методы. А для того, чтобы приостановить/возобновить выполнение JavaScript, мы можем использовать InAppWebViewController.pauseTimers() и InAppWebViewController.resumeTimers() методы.

В Android вызов методов таймера паузы/возобновления будет приостанавливать/возобновлять выполнение JavaScript для всех WebView, а в iOS - только для определенного WebView.

Эти методы будут вызываться при изменении состояния жизненного цикла приложения, то есть когда событие didChangeAppLifecycleState запускается:

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (_webViewController != null) {
    if (state == AppLifecycleState.paused) {
      if (Platform.isAndroid) {
        _webViewController?.android?.pause();
      }
      _webViewController?.pauseTimers();
    } else {
      if (Platform.isAndroid) {
        if (Platform.isAndroid) {
          _webViewController?.android?.resume();
        }
        _webViewController?.resumeTimers();
      } else if (Platform.isIOS) {
        var currentWebViewModel =
          Provider.of<WebViewModel>(context, listen: false);
        if (widget.webViewModel.tabIndex ==
          currentWebViewModel.tabIndex) {
          _webViewController?.resumeTimers();
        } else {
          _webViewController?.pauseTimers();
        }
      }
    }
  }
}

Кроме того, мы хотим отображать свою страницу ошибок при ошибке во время загрузки. В этом случае мы можем прослушать onLoadError событие и, например, загрузить собственный HTML-код в WebView (или что угодно):

onLoadError: (controller, url, code, message) async {
  if (Platform.isIOS && code == -999) {
    // NSURLErrorDomain
    return;
  }

  url = url ?? 'about:blank';

  _webViewController?.loadData(data: """
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <style>
    ${await _webViewController?.getTRexRunnerCss()}
    </style>
    <style>
    .interstitial-wrapper {
        box-sizing: border-box;
        font-size: 1em;
        line-height: 1.6em;
        margin: 0 auto 0;
        max-width: 600px;
        width: 100%;
    }
    </style>
</head>
<body>
    ${await _webViewController?.getTRexRunnerHtml()}
    <div class="interstitial-wrapper">
      <h1>Website not available</h1>
      <p>Could not load web pages at <strong>$url</strong> because:</p>
      <p>$message</p>
    </div>
</body>
    """, baseUrl: url, androidHistoryUrl: url);
},

На Android мы также можем отключить страницу ошибок по умолчанию, используя специальный параметр Android disableDefaultErrorPage: true. Вот результат:

страница ошибки загрузки
Пользовательская страница ошибки с игрой T-Rex Runner.

Верхняя панель браузера

AppBar браузера и его действия копируют мобильное приложение Google Chrome. Как видите, он состоит из строки поиска, прямоугольника показывающего количество вкладок, и раскрывающегося меню.

На рисунке показан пример AppBar когда:

  • опция Домашняя страница включена, ведущим элементом является IconButton
  • заголовок - это Stack виджет, состоящий из простого TextField, прослушивающего onSubmitted событие, и элемента IconButton который указывает, является ли текущий веб-сайт безопасным или нет, или это автономный веб-сайт (веб-архив)
  • элементы действий - это виджет InkWell, который содержит количество текущих вкладок и предоставляет доступ для перехода на другую вкладку или ее закрытия, и виджет PopupMenuButton, содержащий список всех доступных действий, таких как Новая вкладка, Новая вкладка в режиме инкогнито, Настройки, Консоль разработчика и т.д.
пример AppBar
Пример AppBar браузера

Как видите, действия раскрывающегося списка аналогичны мобильному приложению Google Chrome: мы можем перемещаться по истории WebView, сохранять текущую страницу в виде веб-архива, сохранять веб-сайт в списке избранных, включать режим для ПК, перезагружать страницу, посмотреть ее код и др.

BrowserModel содержит список избранного, который представляет собой простой список экземпляров FavoriteModel, который содержит фавиконку, URL и название каждой страницы сохраненной в избранном. BrowserModel содержит также, Map<String, WebArchiveModel> что представляет собой веб-архивы, сохраненные с помощью метода InAppWebViewController.android.saveWebArchive (на данный момент, этот метод доступен только на Android) для загрузки веб-страниц, чтобы использовать их в автономном режиме.

История вкладки WebView предоставляется методом InAppWebViewController.getCopyBackForwardList, который возвращает список посещенных URL-адресов и заголовков во время текущего сеанса.

Если мы нажмем на Найти на странице, на верхней панели браузера отобразится соответствующий виджет, позволяя нам искать слова с помощью метода InAppWebViewController.findAllAsync(find: ""), переходя от одного результата к другому с помощью InAppWebViewController.findNext(forward: ) и очищать совпадения, найденные с помощью метода InAppWebViewController.clearMatches.

поиск по странице
Поиск по странице

Итак, AppBar браузера будет виджетом, который реализует PreferredSizeWidet:

class BrowserAppBar extends StatefulWidget
    implements PreferredSizeWidget {
  BrowserAppBar({Key key})
      : preferredSize = Size.fromHeight(kToolbarHeight),
        super(key: key);

  @override
  _BrowserAppBarState createState() => _BrowserAppBarState();

  @override
  final Size preferredSize;
}

class _BrowserAppBarState extends State<BrowserAppBar> {
  bool _isFindingOnPage = false;

  @override
  Widget build(BuildContext context) {
    return _isFindingOnPage
        ? FindOnPageAppBar(
            hideFindOnPage: () {
              setState(() {
                _isFindingOnPage = false;
              });
            },
          )
        : WebViewTabAppBar(
            showFindOnPage: () {
              setState(() {
                _isFindingOnPage = true;
              });
            },
          );
  }
}

где FindOnPageAppBar и WebViewTabAppBar - экземпляры AppBar.

Средство просмотра сертификатов SSL

просмотр сертификатов

Как вы можете видеть, в левой части URL-адреса веб-сайта будет значок зеленого замка, если текущий веб-сайт использует действующий сертификат SSL (мы проверяем его с помощью onReceivedServerTrustAuthRequest события WebView) или серый замок если это локальный контент.

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

Если нажать Подробности, а затем Информация о сертификате, появится AlertDialog со всеми сведениями о сертификате X509.

 

Переход от одной вкладки WebView к другой

Поскольку у нас есть список всех вкладок WebView сохраненных в BrowserModel и каждая вкладка имеет свой GlobalKey, мы можем изменить отображаемый WebView, обновив индекс текущей вкладки на тот, который мы хотим отобразить.

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

В этом примере приложения я реализовал это с помощью настраиваемого виджета TabViewer который использует GestureDetector для прослушивания событий вертикального перетаскивания, чтобы имитировать поведение мобильного приложения Google Chrome. Используя класс Timer и Transform, я получил такой эффект:

просмотр вкладок
Средство просмотра вкладок

Консоль разработчика

Страница консоли разработчика содержит три вкладки TabBarView.

  • Консоль JavaScript: где можно просматривать журналы консоли и выполнять код JavaScript, так же, как это организованно в настольной версии Google Chrome
  • Сетевая информация: где можно увидеть все ресурсы, загруженные для основного фрейма, такие как изображения, XMLHttpRequests и т.д.
  • Диспетчер хранилища: где можно управлять файлами cookie, веб-хранилищем и учетными данными HTTP-аутентификации

Для прослушивания журналов консоли JavaScript мы используем событие onConsoleMessage на нашей вкладке WebView, а для выполнения кода JavaScript мы используем метод InAppWebViewController.evaluateJavascript(source: "").

консоль разработчика
Страница консоли разработчика

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

Чтобы прослушивать загрузку ресурсов, мы используем событие onLoadResource на вкладке WebView. Каждый ресурс будет добавлен в список, отображаемый на странице Информация о сети.

Вкладка Управление хранилищем будет использовать класс CookieManager для управления файлами cookie, InAppWebViewController.webStorage для управления локальным хранилищем и хранилищем сеансов, HttpAuthCredentialDatabase для управления учетными данными HTTP Auth, а также для управления веб-хранилищем в целом, например, API кэша приложений, API базы данных Web SQL и HTML5 Web Storage API (на Android он реализован с использованием WebStorage , а на iOS - с помощью WKWebsiteDataStore.default ()).

Страница настроек

Эта страница содержит все настройки браузера, а также настройки текущей вкладки WebView. Она также содержит три вкладки TabBarView :

  • Кросс-платформенные параметры, такие как User-Agent, включение/отключение JavaScript, включение/отключение поддержки масштабирования и т.д.
  • Параметры, специфичные для Android, такие как включение/отключение API хранилища DOM и API хранилища баз данных, режим кеширования и т.д.
  • Параметры iOS, такие как включение/отключение прокрутки, предварительный просмотр ссылок и т.д.
страница настроек
Страница настроек браузера

Каждая вкладка содержит ListView с дочерними  ListTile и SwitchListTile или Container с DropdownButton, который представляет собой вариант WebView с небольшим описанием.

Сохранение и восстановление состояния браузера

Как было сказано ранее, для сохранения и восстановления состояния браузера, такого как текущий список вкладок WebView, мы собираемся использовать пакет shared_preference.

Каждый раз, когда приложение распознает, что что-то изменено в BrowserModel или текущем WebViewModel (например, текущий URL-адрес или заголовок) с помощью метода addListener, предоставленного классом ChangeNotifier, мы кодируем экземпляр BrowserModel в строку JSON и сохраняем его с помощью shared_preferences.

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

Когда мы восстанавливаем состояние браузера во время перезапуска приложения, мы декодируем строку JSON и добавляем/переопределяем все необходимые данные в текущий экземпляр BrowserModel.

Мы можем добиться этого, используя следующие три метода (сохранение, сброс и восстановление), определенных внутри класса BrowserModel:

DateTime _lastTrySave = DateTime.now();
Timer _timerSave;
Future<void> save() async {
  _timerSave?.cancel();

  if (DateTime.now().difference(_lastTrySave) >= Duration(milliseconds: 400)) {
    _lastTrySave = DateTime.now();
    await flush();
  } else {
    _lastTrySave = DateTime.now();
    _timerSave = Timer(Duration(milliseconds: 500), () {
      save();
    });
  }
}

Future<void> flush() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setString("browser", json.encode(toJson()));
}

Future<void> restore() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  Map<String, dynamic> browserData;
  try {
    browserData = await json.decode(prefs.getString("browser"));
  } catch (e) {
    print(e);
    return;
  }

  this.clearFavorites();
  this.closeAllTabs();
  this.clearWebArchives();

  List<Map<String, dynamic>> favoritesList = browserData["favorites"]?.cast<Map<String, dynamic>>();
  List<FavoriteModel> favorites = favoritesList?.map((e) => FavoriteModel.fromMap(e))?.toList() ?? [];

  Map<String, dynamic> webArchivesMap = browserData["webArchives"]?.cast<String, dynamic>() ?? {};
  Map<String, WebArchiveModel> webArchives = webArchivesMap.map((key, value) =>
      MapEntry(key, WebArchiveModel.fromMap(value?.cast<String, dynamic>())));

  BrowserSettings settings = BrowserSettings.fromMap(browserData["settings"]?.cast<String, dynamic>()) ?? BrowserSettings();
  List<Map<String, dynamic>> webViewTabList = browserData["webViewTabs"]?.cast<Map<String, dynamic>>();
  List<WebViewTab> webViewTabs = webViewTabList
      ?.map((e) => WebViewTab(
        key: GlobalKey(),
        webViewModel: WebViewModel.fromMap(e),
      ))
      ?.toList() ?? [];
  webViewTabs.sort((a, b) => a.webViewModel.tabIndex.compareTo(b.webViewModel.tabIndex));


  this.addFavorites(favorites);
  this.addWebArchives(webArchives);
  this.updateSettings(settings);
  this.addTabs(webViewTabs);

  int currentTabIndex = browserData["currentTabIndex"] ?? this._currentTabIndex;
  currentTabIndex = min(currentTabIndex, this._webViewTabs.length - 1);

  if (currentTabIndex >= 0)
    this.showTab(currentTabIndex);
}

Заключение

В этой статье мы использовали плагин flutter_inappwebview для создания полнофункционального браузера. Плагин находится в непрерывной разработке (на момент написания этой статьи это последняя версия 4.0.0+4), и я рекомендую вам ознакомиться со Справочником по API, чтобы подробно изучить все его функции.

Комментарии