将 WebView 添加到您的 Flutter 应用

1. 简介

上次更新时间:2021 年 10 月 19 日

借助 WebView Flutter 插件,您可以向 Android 或 iOS 版 Flutter 应用添加 WebView 微件/小组件。在 iOS 版中,WebView 小组件由 WKWebView 提供支持;而在 Android 版中,WebView 微件由 WebView 提供支持。该插件可以通过网页视图渲染 Flutter 微件。例如,可以通过网页视图呈现下拉菜单。

构建内容

在此 Codelab 中,您将使用 Flutter SDK 逐步构建一个包含 WebView 的移动应用。您的应用将:

  • WebView 中显示网页内容
  • 通过 WebView 显示堆叠的 Flutter 微件
  • 对网页加载进度事件做出响应
  • 通过 WebViewController 控制 WebView
  • 使用 NavigationDelegate 屏蔽网站
  • 评估 JavaScript 表达式
  • 使用 JavascriptChannels 处理 JavaScript 回调
  • 设置、移除、添加或显示 Cookie
  • 加载并显示资源、文件中的 HTML 或包含 HTML 的字符串

学习内容

在此 Codelab 中,您将学习如何通过多种方式使用 webview_flutter 插件,包括:

  • 如何配置 webview_flutter 插件
  • 如何监听网页加载进度事件
  • 如何控制网页导航
  • 如何命令 WebView 向前和向后浏览其历史记录
  • 如何评估 JavaScript,包括使用返回的结果
  • 如何注册回调以从 JavaScript 调用 Dart 代码
  • 如何管理 Cookie
  • 如何加载并显示资源或文件中的 HTML 页面或包含 HTML 的字符串

所需条件

2. 设置您的 Flutter 环境

您需要使用两款软件才能完成此 Codelab:Flutter SDK一款编辑器

您可以使用以下任意设备运行此 Codelab:

  • 连接到计算机并设为开发者模式的真机移动设备(Android 或 iOS)。
  • iOS 模拟器(仅限 macOS,且需要安装 Xcode 工具。)
  • Android 模拟器(需要在 Android Studio 中进行设置)。

3. 开始

开始使用 Flutter

您可以通过多种方式创建新的 Flutter 项目,Android StudioVisual Studio Code 均提供用于完成此任务的工具。请按照链接的步骤创建项目,或在方便易用的命令行终端中执行以下命令。

$ flutter create webview_in_flutter
Creating project webview_in_flutter...
[Listing of created files elided]
Wrote 81 files.

All done!
In order to run your application, type:

  $ cd webview_in_flutter
  $ flutter run

Your application code is in webview_in_flutter\lib\main.dart.

将 WebView Flutter 插件作为依赖项添加

使用 Pub 软件包可以轻松为 Flutter 应用添加额外的功能。在此 Codelab 中,您将向项目中添加 webview_flutter 插件。在终端中运行以下命令。

$ cd webview_in_flutter
$ flutter pub add webview_flutter
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
  characters 1.1.0 (1.2.0 available)
  matcher 0.12.10 (0.12.11 available)
+ plugin_platform_interface 2.0.2
  test_api 0.4.2 (0.4.8 available)
  vector_math 2.1.0 (2.1.1 available)
+ webview_flutter 3.0.0
+ webview_flutter_android 2.8.0
+ webview_flutter_platform_interface 1.8.0
+ webview_flutter_wkwebview 2.7.0
Downloading webview_flutter 3.0.0...
Downloading webview_flutter_wkwebview 2.7.0...
Downloading webview_flutter_android 2.8.0...
Changed 5 dependencies!

如果您检查 pubspec.yaml,现在将在 webview_flutter 插件的依赖项部分看到有一行内容。

配置 Android minSDK

如需在 Android 上使用 webview_flutter 插件,您需要将 minSDK 设置为 19 或 20,具体取决于您要使用的 Android 平台视图。如需详细了解 Android 平台视图,请访问 webview_flutter 插件页面。将 android/app/build.gradle 文件修改为以下所示:

android/app/build.gradle

defaultConfig {
    // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
    applicationId "com.example.webview_in_flutter"
    minSdkVersion 20        // MODIFY
    targetSdkVersion 30
    versionCode flutterVersionCode.toInteger()
    versionName flutterVersionName
}

4. 将 WebView 微件添加到 Flutter 应用

在此步骤中,您将向应用添加一个 WebView。WebView 是托管的原生视图,作为应用开发者,您可以选择如何在应用中托管这些原生视图。在 Android 上,您可以选择虚拟显示模式(目前为 Android 的默认设置)和混合集成模式。但 iOS 始终使用混合集成模式。

如需深入地了解虚拟显示模式与混合集成模式的差异,请参阅 Hosting native Android and iOS views in your Flutter app with Platform Views(使用平台视图在 Flutter 应用中托管原生 Android 和 iOS 视图)的文档。

将 WebView 放到屏幕上

lib/main.dart 的内容替换为以下内容:

lib/main.dart

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

void main() {
  runApp(
    const MaterialApp(
      home: WebViewApp(),
    ),
  );
}

class WebViewApp extends StatefulWidget {
  const WebViewApp({Key? key}) : super(key: key);

  @override
  State<WebViewApp> createState() => _WebViewAppState();
}

class _WebViewAppState extends State<WebViewApp> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter WebView'),
      ),
      body: const WebView(
        initialUrl: 'https://flutter.dev',
      ),
    );
  }
}

在 iOS 或 Android 上运行以上代码时,WebView 将在您的设备上以全宽浏览器窗口显示,这意味着浏览器将在设备上全屏显示,不会出现任何形式的边框或边距。在滚动时,您会看到页面的某些部分可能看起来有些奇怪。这是因为 JavaScript 目前已停用,而适当呈现 flutter.dev 需要 JavaScript。

启用混合集成模式

如果您想在 Android 设备上使用混合集成模式,只需稍作修改即可。将 lib/main.dart 修改为以下所示:

lib/main.dart

import 'dart:io';                            // Add this import.
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

void main() {
  runApp(
    const MaterialApp(
      home: WebViewApp(),
    ),
  );
}

class WebViewApp extends StatefulWidget {
  const WebViewApp({Key? key}) : super(key: key);

  @override
  State<WebViewApp> createState() => _WebViewAppState();
}

class _WebViewAppState extends State<WebViewApp> {
  // Add from here ...
  @override
  void initState() {
    if (Platform.isAndroid) {
      WebView.platform = SurfaceAndroidWebView();
    }
    super.initState();
  }
  // ... to here.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter WebView'),
      ),
      body: const WebView(
        initialUrl: 'https://flutter.dev',
      ),
    );
  }
}

如果您想使用混合集成模式平台视图,不要忘记将 build.gradle 中的 minSdkVersion 更改为 19。

运行应用

在 iOS 或 Android 中运行 Flutter 应用以查看 WebView,其会显示 flutter.dev 网站。或者,在 Android 模拟器或 iOS 模拟器中运行该应用。您可随时将用作示例的初始 WebView 网址替换为您自己的网站。

$ flutter run

假设您已运行了相应的模拟器,或连接了真机设备,在编译应用并将其部署到设备后,您应看到如下内容:

5. 监听网页加载事件

WebView 微件提供多个网页加载进度事件的信息,您的应用可监听这些事件。在 WebView 网页加载周期内,会触发三种不同的网页加载事件:onPageStartedonProgressonPageFinished。在此步骤中,您将实现一个网页加载指示器。此外,您可以在 WebView 内容区域上呈现 Flutter 内容。

在您的应用中添加网页加载事件

lib/src/web_view_stack.dart 中创建一个新的源文件,并在其中填充以下内容:

lib/src/web_view_stack.dart

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class WebViewStack extends StatefulWidget {
  const WebViewStack({Key? key}) : super(key: key);

  @override
  State<WebViewStack> createState() => _WebViewStackState();
}

class _WebViewStackState extends State<WebViewStack> {
  var loadingPercentage = 0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        WebView(
          initialUrl: 'https://flutter.dev',
          onPageStarted: (url) {
            setState(() {
              loadingPercentage = 0;
            });
          },
          onProgress: (progress) {
            setState(() {
              loadingPercentage = progress;
            });
          },
          onPageFinished: (url) {
            setState(() {
              loadingPercentage = 100;
            });
          },
        ),
        if (loadingPercentage < 100)
          LinearProgressIndicator(
            value: loadingPercentage / 100.0,
          ),
      ],
    );
  }
}

此代码已将 WebView 微件封装在 Stack 中,当网页加载百分比低于 100% 时,会有条件地使用 LinearProgressIndicator 覆盖 WebView。这涉及随时间变化的程序状态,您已将此状态存储在与 StatefulWidget 关联的 State 类中。

如需使用该新 WebViewStack 微件,请将 lib/main.dart 修改为如下所示:

import 'package:flutter/material.dart';
// Delete the package:webview_flutter/webview_flutter.dart import
import 'src/web_view_stack.dart';  // Add this import

void main() {
  runApp(
    const MaterialApp(
      home: WebViewApp(),
    ),
  );
}

class WebViewApp extends StatefulWidget {
  const WebViewApp({Key? key}) : super(key: key);

  @override
  State<WebViewApp> createState() => _WebViewAppState();
}

class _WebViewAppState extends State<WebViewApp> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter WebView'),
      ),
      body: const WebViewStack(),   // Replace the WebView widget with WebViewStack
    );
  }
}

运行应用时,您会看到网页加载指示器叠加在 WebView 内容区域之上(取决于您的网络条件以及浏览器是否缓存了要导航到的页面)。

6. 使用 WebViewController

通过 WebView 微件访问 WebViewController

WebView 微件支持使用 WebViewController 进行程序化控制。在通过回调完成 WebView 的构建后,此控制器已可用。此控制器的可用性具有异步特性,因此成为了 Dart 异步 Completer<T> 类的主要候选对象。

更新 lib/src/web_view_stack.dart,具体代码如下所示:

lib/src/web_view_stack.dart

import 'dart:async';     // Add this import for Completer
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class WebViewStack extends StatefulWidget {
  const WebViewStack({required this.controller, Key? key}) : super(key: key); // Modify

  final Completer<WebViewController> controller;   // Add this attribute

  @override
  State<WebViewStack> createState() => _WebViewStackState();
}

class _WebViewStackState extends State<WebViewStack> {
  var loadingPercentage = 0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        WebView(
          initialUrl: 'https://flutter.dev',
          // Add from here ...
          onWebViewCreated: (webViewController) {
            widget.controller.complete(webViewController);
          },
          // ... to here.
          onPageStarted: (url) {
            setState(() {
              loadingPercentage = 0;
            });
          },
          onProgress: (progress) {
            setState(() {
              loadingPercentage = progress;
            });
          },
          onPageFinished: (url) {
            setState(() {
              loadingPercentage = 100;
            });
          },
        ),
        if (loadingPercentage < 100)
          LinearProgressIndicator(
            value: loadingPercentage / 100.0,
          ),
      ],
    );
  }
}

WebViewStack 微件现在会发布使用 Completer<WebViewController> 以异步方式创建的控制器。相对于创建回调函数参数,这是一种更轻量的替代方案,可向应用的其余部分提供该控制器。

创建导航控件

WebView 能够正常运行很重要,但如果能够添加向前和向后浏览页面历史记录和重新加载页面实用功能,则是锦上添花。幸好,借助 WebViewController,您可以向应用添加这类功能。

lib/src/navigation_controls.dart 中创建一个新的源文件,并在其中填充以下内容:

lib/src/navigation_controls.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class NavigationControls extends StatelessWidget {
  const NavigationControls({required this.controller, Key? key})
      : super(key: key);

  final Completer<WebViewController> controller;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<WebViewController>(
      future: controller.future,
      builder: (context, snapshot) {
        final WebViewController? controller = snapshot.data;
        if (snapshot.connectionState != ConnectionState.done ||
            controller == null) {
          return Row(
            children: const <Widget>[
              Icon(Icons.arrow_back_ios),
              Icon(Icons.arrow_forward_ios),
              Icon(Icons.replay),
            ],
          );
        }

        return Row(
          children: <Widget>[
            IconButton(
              icon: const Icon(Icons.arrow_back_ios),
              onPressed: () async {
                if (await controller.canGoBack()) {
                  await controller.goBack();
                } else {
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('No back history item')),
                  );
                  return;
                }
              },
            ),
            IconButton(
              icon: const Icon(Icons.arrow_forward_ios),
              onPressed: () async {
                if (await controller.canGoForward()) {
                  await controller.goForward();
                } else {
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('No forward history item')),
                  );
                  return;
                }
              },
            ),
            IconButton(
              icon: const Icon(Icons.replay),
              onPressed: () {
                controller.reload();
              },
            ),
          ],
        );
      },
    );
  }
}

在此控制器可用后,此微件会使用 FutureBuilder<T> 微件自行进行适当重绘。在等待控制器可用时,系统会呈现一行图标(由三个图标组成),但在控制器显示后,系统会将其替换为一Row IconButton(包含使用 controlleronPressed 处理程序)来实现其功能。

为 AppBar 添加导航控件

在经过更新的 WebViewStack 和新创建的 NavigationControls 可用后,您现在可以在经过更新的 WebViewApp 中将它们整合为一体。之前,您已了解 Completer<T> 的使用方法,但不知道其创建位置。当 WebViewApp 位于此应用中的微件树顶部附近时,则适合在此级别创建。

lib/main.dart 文件更新为如下所示:

lib/main.dart

import 'dart:async';                                    // Add this import

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';  // Add this import back

import 'src/navigation_controls.dart';                  // Add this import
import 'src/web_view_stack.dart';

void main() {
  runApp(
    const MaterialApp(
      home: WebViewApp(),
    ),
  );
}

class WebViewApp extends StatefulWidget {
  const WebViewApp({Key? key}) : super(key: key);

  @override
  State<WebViewApp> createState() => _WebViewAppState();
}

class _WebViewAppState extends State<WebViewApp> {
  final controller = Completer<WebViewController>();    // Instantiate the controller

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter WebView'),
        // Add from here ...
        actions: [
          NavigationControls(controller: controller),
        ],
        // ... to here.
      ),
      body: WebViewStack(controller: controller),       // Add the controller argument
    );
  }
}

运行应用后,系统应会显示一个包含控件的网页:

7. 使用 NavigationDelegate 跟踪导航

WebView 为您的应用提供 NavigationDelegate,,可让应用跟踪和控制 WebView 微件的网页导航。在导航通过 WebView, 启动后,例如当某个用户点击某个链接后,系统会调用 NavigationDelegateNavigationDelegate 回调可用于控制 WebView 是否继续进行导航。

注册自定义 NavigationDelegate

在此步骤中,您将注册一个 NavigationDelegate 回调以阻止导航到 YouTube.com。请注意,这种简单的实现还阻止了内嵌的 YouTube 内容(显示在各种 Flutter API 文档页面中)。

lib/src/web_view_stack.dart 更新为如下所示:

lib/src/web_view_stack.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class WebViewStack extends StatefulWidget {
  const WebViewStack({required this.controller, Key? key}) : super(key: key);

  final Completer<WebViewController> controller;

  @override
  State<WebViewStack> createState() => _WebViewStackState();
}

class _WebViewStackState extends State<WebViewStack> {
  var loadingPercentage = 0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        WebView(
          initialUrl: 'https://flutter.dev',
          onWebViewCreated: (webViewController) {
            widget.controller.complete(webViewController);
          },
          onPageStarted: (url) {
            setState(() {
              loadingPercentage = 0;
            });
          },
          onProgress: (progress) {
            setState(() {
              loadingPercentage = progress;
            });
          },
          onPageFinished: (url) {
            setState(() {
              loadingPercentage = 100;
            });
          },
          // Add from here ...
          navigationDelegate: (navigation) {
            final host = Uri.parse(navigation.url).host;
            if (host.contains('youtube.com')) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(
                    'Blocking navigation to $host',
                  ),
                ),
              );
              return NavigationDecision.prevent;
            }
            return NavigationDecision.navigate;
          },
          // ... to here.
        ),
        if (loadingPercentage < 100)
          LinearProgressIndicator(
            value: loadingPercentage / 100.0,
          ),
      ],
    );
  }
}

在下一步中,您将添加一个菜单项,以使用 WebViewController 类测试 NavigationDelegate。这是作为练习留给读者的,以增强对回调逻辑的理解:回调仅会阻止转向 YouTube.com 的全网页导航,但仍允许 API 文档中的内嵌 YouTube 内容。

8. 向 AppBar 添加菜单按钮

在接下来的几个步骤中,您将在 AppBar 微件中创建一个菜单按钮,用于评估 JavaScript、调用 JavaScript 渠道和管理 Cookie。总而言之,一个很实用的菜单。

lib/src/menu.dart 中创建一个新的源文件,并在其中填充以下内容:

lib/src/menu.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

enum _MenuOptions {
  navigationDelegate,
}

class Menu extends StatelessWidget {
  const Menu({required this.controller, Key? key}) : super(key: key);

  final Completer<WebViewController> controller;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<WebViewController>(
      future: controller.future,
      builder: (context, controller) {
        return PopupMenuButton<_MenuOptions>(
          onSelected: (value) async {
            switch (value) {
              case _MenuOptions.navigationDelegate:
                controller.data!.loadUrl('https://youtube.com');
                break;
            }
          },
          itemBuilder: (context) => [
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.navigationDelegate,
              child: Text('Navigate to YouTube'),
            ),
          ],
        );
      },
    );
  }
}

当用户选择 Navigate to YouTube(导航到 YouTube)菜单选项后,系统会执行 WebViewControllerloadUrl 方法。您在上一步中创建的 navigationDelegate 回调将阻止此导航。

如需将该菜单添加到 WebViewApp 的屏幕,请将 lib/main.dart 修改为如下所示:

lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

import 'src/menu.dart';                                // Add this import
import 'src/navigation_controls.dart';
import 'src/web_view_stack.dart';

void main() {
  runApp(
    const MaterialApp(
      home: WebViewApp(),
    ),
  );
}

class WebViewApp extends StatefulWidget {
  const WebViewApp({Key? key}) : super(key: key);

  @override
  State<WebViewApp> createState() => _WebViewAppState();
}

class _WebViewAppState extends State<WebViewApp> {
  final controller = Completer<WebViewController>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter WebView'),
        actions: [
          NavigationControls(controller: controller),
          Menu(controller: controller),                // Add this line
        ],
      ),
      body: WebViewStack(controller: controller),
    );
  }
}

运行应用,然后点按 Navigate to YouTube(导航到 YouTube)菜单项。系统应会显示 SnackBar,通知您导航控制器已阻止导航到 YouTube。

9. JavaScript 求值

WebViewController 可以在当前页面的上下文中对 JavaScript 表达式求值。对 JavaScript 求值有两种不同方法:对于不返回值的 JavaScript 代码,使用 runJavaScript;对于返回值的 JavaScript 代码,使用 runJavaScriptReturningResult

如需启用 JavaScript,您需要配置 WebView 微件,并将 javaScriptMode 属性设置为 JavascriptMode.unrestricted。默认情况下,javascriptMode 设置为 JavascriptMode.disabled

通过添加 javascriptMode 设置来更新 _WebViewStackState 类,如下所示:

lib/src/web_view_stack.dart

class _WebViewStackState extends State<WebViewStack> {
  var loadingPercentage = 0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        WebView(
          initialUrl: 'https://flutter.dev',
          onWebViewCreated: (webViewController) {
            widget.controller.complete(webViewController);
          },
          onPageStarted: (url) {
            setState(() {
              loadingPercentage = 0;
            });
          },
          onProgress: (progress) {
            setState(() {
              loadingPercentage = progress;
            });
          },
          onPageFinished: (url) {
            setState(() {
              loadingPercentage = 100;
            });
          },
          navigationDelegate: (navigation) {
            final host = Uri.parse(navigation.url).host;
            if (host.contains('youtube.com')) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(
                    'Blocking navigation to $host',
                  ),
                ),
              );
              return NavigationDecision.prevent;
            }
            return NavigationDecision.navigate;
          },
          javascriptMode: JavascriptMode.unrestricted,        // Add this line
        ),
        if (loadingPercentage < 100)
          LinearProgressIndicator(
            value: loadingPercentage / 100.0,
          ),
      ],
    );
  }
}

现在,WebView 可以执行 JavaScript,您可以向菜单中添加一个选项,以使用 runJavaScriptReturningResult 方法。

lib/src/menu.dart 修改为如下所示:

lib/src/menu.dart

enum _MenuOptions {
  navigationDelegate,
  userAgent,                                          // Add this line
}

class Menu extends StatelessWidget {
  const Menu({required this.controller, Key? key}) : super(key: key);

  final Completer<WebViewController> controller;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<WebViewController>(
      future: controller.future,
      builder: (context, controller) {
        return PopupMenuButton<_MenuOptions>(
          onSelected: (value) async {
            switch (value) {
              case _MenuOptions.navigationDelegate:
                controller.data!.loadUrl('https://youtube.com');
                break;
              // Add from here ...
              case _MenuOptions.userAgent:
                final userAgent = await controller.data!
                    .runJavascriptReturningResult('navigator.userAgent');
                ScaffoldMessenger.of(context).showSnackBar(SnackBar(
                  content: Text(userAgent),
                ));
                break;
              // ... to here.
            }
          },
          itemBuilder: (context) => [
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.navigationDelegate,
              child: Text('Navigate to YouTube'),
            ),
            // Add from here ...
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.userAgent,
              child: Text('Show user-agent'),
            ),
            // ... to here.
          ],
        );
      },
    );
  }
}

当您点按“Show user-agent'”(显示用户代理)菜单选项后,执行 JavaScript 表达式 navigator.userAgent 的结果将显示在 Snackbar 中。运行应用时,您可能会注意到 Flutter.dev 页面看起来有所不同。这是在启用 JavaScript 的情况下运行的结果。

10. 使用 JavaScript 渠道

借助 JavascriptChannel,您的应用可以在 WebView 的 JavaScript 上下文中注册回调处理程序,可以调用这些回调处理程序将值传递回应用的 Dart 代码。在此步骤中,您将注册一个使用 XMLHttpRequest 的结果调用的 SnackBar 渠道。

WebViewStack 类更新为如下所示:

lib/src/web_view_stack.dart

class WebViewStack extends StatefulWidget {
  const WebViewStack({required this.controller, Key? key}) : super(key: key);

  final Completer<WebViewController> controller;

  @override
  State<WebViewStack> createState() => _WebViewStackState();
}

class _WebViewStackState extends State<WebViewStack> {
  var loadingPercentage = 0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        WebView(
          initialUrl: 'https://flutter.dev',
          onWebViewCreated: (webViewController) {
            widget.controller.complete(webViewController);
          },
          onPageStarted: (url) {
            setState(() {
              loadingPercentage = 0;
            });
          },
          onProgress: (progress) {
            setState(() {
              loadingPercentage = progress;
            });
          },
          onPageFinished: (url) {
            setState(() {
              loadingPercentage = 100;
            });
          },
          navigationDelegate: (navigation) {
            final host = Uri.parse(navigation.url).host;
            if (host.contains('youtube.com')) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(
                    'Blocking navigation to $host',
                  ),
                ),
              );
              return NavigationDecision.prevent;
            }
            return NavigationDecision.navigate;
          },
          javascriptMode: JavascriptMode.unrestricted,
          javascriptChannels: _createJavascriptChannels(context),  // Add this line
        ),
        if (loadingPercentage < 100)
          LinearProgressIndicator(
            value: loadingPercentage / 100.0,
          ),
      ],
    );
  }

  // Add from here ...
  Set<JavascriptChannel> _createJavascriptChannels(BuildContext context) {
    return {
      JavascriptChannel(
        name: 'SnackBar',
        onMessageReceived: (message) {
          ScaffoldMessenger.of(context)
              .showSnackBar(SnackBar(content: Text(message.message)));
        },
      ),
    };
  }
  // ... to here.
}

对于 Set 中的每个 JavascriptChannel,渠道对象会在 JavaScript 上下文中以与 JavascriptChannel.name 同名的窗口属性的形式提供。如需在 JavaScript 上下文中使用此渠道,则需要在 JavascriptChannel 上调用 postMessage,以发送一条消息,该消息会传递到已命名的 JavascriptChannelonMessageReceived 回调处理程序。

如需使用上文添加的 JavascriptChannel,请再添加一个菜单项,以便在 JavaScript 上下文中执行 XMLHttpRequest,并使用 SnackBar JavascriptChannel 传回结果。

现在,WebView 已了解 JavascriptChannels,。接下来,您将添加一个示例以进一步扩展该应用。为此,请向 Menu 类添加额外的 PopupMenuItem,并添加额外的功能。

通过添加 javascriptChannel 枚举值,使用额外菜单选项更新 _MenuOptions,并向 Menu 类添加实现,如下所示:

lib/src/menu.dart

enum _MenuOptions {
  navigationDelegate,
  userAgent,
  javascriptChannel,                                    // Add this line
}

class Menu extends StatelessWidget {
  const Menu({required this.controller, Key? key}) : super(key: key);

  final Completer<WebViewController> controller;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<WebViewController>(
      future: controller.future,
      builder: (context, controller) {
        return PopupMenuButton<_MenuOptions>(
          onSelected: (value) async {
            switch (value) {
              case _MenuOptions.navigationDelegate:
                controller.data!.loadUrl('https://youtube.com');
                break;
              case _MenuOptions.userAgent:
                final userAgent = await controller.data!
                    .runJavascriptReturningResult('navigator.userAgent');
                ScaffoldMessenger.of(context).showSnackBar(SnackBar(
                  content: Text(userAgent),
                ));
                break;
              // Add from here ...
              case _MenuOptions.javascriptChannel:
                await controller.data!.runJavascript('''
var req = new XMLHttpRequest();
req.open('GET', "https://api.ipify.org/?format=json");
req.onload = function() {
  if (req.status == 200) {
    let response = JSON.parse(req.responseText);
    SnackBar.postMessage("IP Address: " + response.ip);
  } else {
    SnackBar.postMessage("Error: " + req.status);
  }
}
req.send();''');
                break;
              // ... to here.
            }
          },
          itemBuilder: (context) => [
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.navigationDelegate,
              child: Text('Navigate to YouTube'),
            ),
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.userAgent,
              child: Text('Show user-agent'),
            ),
            // Add from here ...
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.javascriptChannel,
              child: Text('Lookup IP Address'),
            ),
            // ... to here.
          ],
        );
      },
    );
  }
}

当用户选择 JavaScript Channel Example(JavaScript 渠道示例)菜单选项时,系统会执行此 JavaScript。

var req = new XMLHttpRequest();
req.open('GET', "https://api.ipify.org/?format=json");
req.onload = function() {
  if (req.status == 200) {
    SnackBar.postMessage(req.responseText);
  } else {
    SnackBar.postMessage("Error: " + req.status);
  }
}
req.send();

此代码会向公共 IP 地址 API 发送 GET 请求,并返回设备的 IP 地址。对 SnackBar JavascriptChannel 调用 postMessage,系统会在 SnackBar 中显示结果。

11. 管理 Cookie

您的应用可以使用 CookieManager 类管理 WebView 中的 Cookie。在此步骤中,您将针对 Cookie 列表执行显示、清除和删除操作,还会设置新 Cookie。针对每个 Cookie 用例向 _MenuOptions 添加条目,如下所示:

lib/src/menu.dart

enum _MenuOptions {
  navigationDelegate,
  userAgent,
  javascriptChannel,
  // Add from here ...
  listCookies,
  clearCookies,
  addCookie,
  setCookie,
  removeCookie,
  // ... to here.
}

此步骤中的其余更改集中在 Menu 类中,包括将 Menu 类从无状态转换为有状态。此更改很重要,因为 Menu 需要拥有 CookieManager,而无状态微件中的可变状态是一种不好的组合。

使用编辑器或键盘将 Menu 类转换为 StatefulWidget,然后将 CookieManager 添加到生成的 State 类,如下所示:

lib/src/menu.dart

class Menu extends StatefulWidget {                           // Convert to StatefulWidget
  const Menu({required this.controller, Key? key}) : super(key: key);

  final Completer<WebViewController> controller;

  @override
  State<Menu> createState() => _MenuState();                  // Add this line
}

class _MenuState extends State<Menu> {                       // New State class
  final CookieManager cookieManager = CookieManager();       // Add this line

  @override
  Widget build(BuildContext context) {
  // ...

_MenuState 类将包含之前在 Menu 类中添加的代码以及新添加的 CookieManager。在接下来的几部分中,您将向 _MenuState 添加辅助函数,这些函数反过来会被尚未添加的菜单项调用。

获取所有 Cookie 的列表

您将使用 JavaScript 来获取所有 Cookie 的列表。为此,请在名为 _onListCookies_MenuState 类的末尾添加一个辅助方法。使用 runJavaScriptReturningResult 方法,您的辅助方法会在 JavaScript 环境中执行 document.cookie,并返回所有 Cookie 的列表。

将以下代码添加到 _MenuState 类中:

lib/src/menu.dart

Future<void> _onListCookies(WebViewController controller) async {
  final String cookies =
      await controller.runJavascriptReturningResult('document.cookie');
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(cookies.isNotEmpty ? cookies : 'There are no cookies.'),
    ),
  );
}

清除所有 Cookie

如需清除 WebView 中的所有 Cookie,请使用适用于 CookieManager 类的 clearCookies 方法。如果 CookieManager 清除了 Cookie,此方法会返回一个解析为 trueFuture<bool>;如果没有要清除的 Cookie,则会返回 false

将以下代码添加到 _MenuState 类中:

lib/src/menu.dart

Future<void> _onClearCookies() async {
  final hadCookies = await cookieManager.clearCookies();
  String message = 'There were cookies. Now, they are gone!';
  if (!hadCookies) {
    message = 'There were no cookies to clear.';
  }
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(message),
    ),
  );
}

可通过调用 JavaScript 添加 Cookie。用于向 JavaScript 文档添加 Cookie 的 API 已在 MDN 中详细记载

将以下代码添加到 _MenuState 类中:

lib/src/menu.dart

Future<void> _onAddCookie(WebViewController controller) async {
  await controller.runJavascript('''var date = new Date();
  date.setTime(date.getTime()+(30*24*60*60*1000));
  document.cookie = "FirstName=John; expires=" + date.toGMTString();''');
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(
      content: Text('Custom cookie added.'),
    ),
  );
}

您还可以使用 CookieManager 设置 Cookie,如下所示。

将以下代码添加到 _MenuState 类中:

lib/src/menu.dart

Future<void> _onSetCookie(WebViewController controller) async {
  await cookieManager.setCookie(
    const WebViewCookie(name: 'foo', value: 'bar', domain: 'flutter.dev'),
  );
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(
      content: Text('Custom cookie is set.'),
    ),
  );
}

移除 Cookie 的操作是指添加一个过期日期设为过去的时间的 Cookie。

将以下代码添加到 _MenuState 类中:

lib/src/menu.dart

Future<void> _onRemoveCookie(WebViewController controller) async {
  await controller.runJavascript(
      'document.cookie="FirstName=John; expires=Thu, 01 Jan 1970 00:00:00 UTC" ');
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(
      content: Text('Custom cookie removed.'),
    ),
  );
}

添加 CookieManager 菜单项

剩下的任务就是添加菜单选项,并将其连接到您刚添加的辅助方法。将 _MenuState 类更新为如下所示:

lib/src/menu.dart

class _MenuState extends State<Menu> {
  final CookieManager cookieManager = CookieManager();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<WebViewController>(
      future: widget.controller.future,
      builder: (context, controller) {
        return PopupMenuButton<_MenuOptions>(
          onSelected: (value) async {
            switch (value) {
              case _MenuOptions.navigationDelegate:
                controller.data!.loadUrl('https://youtube.com');
                break;
              case _MenuOptions.userAgent:
                final userAgent = await controller.data!
                    .runJavascriptReturningResult('navigator.userAgent');
                ScaffoldMessenger.of(context).showSnackBar(SnackBar(
                  content: Text(userAgent),
                ));
                break;
              case _MenuOptions.javascriptChannel:
                await controller.data!.runJavascript('''
var req = new XMLHttpRequest();
req.open('GET', "https://api.ipify.org/?format=json");
req.onload = function() {
  if (req.status == 200) {
    let response = JSON.parse(req.responseText);
    SnackBar.postMessage("IP Address: " + response.ip);
  } else {
    SnackBar.postMessage("Error: " + req.status);
  }
}
req.send();''');
                break;
              // Add from here ...
              case _MenuOptions.clearCookies:
                _onClearCookies();
                break;
              case _MenuOptions.listCookies:
                _onListCookies(controller.data!);
                break;
              case _MenuOptions.addCookie:
                _onAddCookie(controller.data!);
                break;
              case _MenuOptions.setCookie:
                _onSetCookie(controller.data!);
                break;
              case _MenuOptions.removeCookie:
                _onRemoveCookie(controller.data!);
                break;
              // ... to here.
            }
          },
          itemBuilder: (context) => [
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.navigationDelegate,
              child: Text('Navigate to YouTube'),
            ),
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.userAgent,
              child: Text('Show user-agent'),
            ),
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.javascriptChannel,
              child: Text('Lookup IP Address'),
            ),
            // Add from here ...
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.clearCookies,
              child: Text('Clear cookies'),
            ),
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.listCookies,
              child: Text('List cookies'),
            ),
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.addCookie,
              child: Text('Add cookie'),
            ),
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.setCookie,
              child: Text('Set cookie'),
            ),
            const PopupMenuItem<_MenuOptions>(
              value: _MenuOptions.removeCookie,
              child: Text('Remove cookie'),
            ),
            // ... to here.
          ],
        );
      },
    );
  }

执行 CookieManager

如需使用您刚刚添加到应用中的所有功能,请尝试执行以下步骤:

  1. 选择 List cookies(列出 Cookie)。系统应会列出 flutter.dev 设置的 Google Analytics(分析)Cookie。
  2. 选择 Clear cookies(清除 Cookie)。系统应会报告相应 Cookie 确实已被清除。
  3. 再次选择 Clear cookies(清除 Cookie)。系统应会报告没有可清除的任何 Cookie。
  4. 选择 List cookies(列出 Cookie)。系统应会报告没有 Cookie。
  5. 选择 Add cookie(添加 Cookie)。系统应会报告 Cookie 已添加。
  6. 选择 Set cookie(设置 Cookie)。系统应会报告 Cookie 已设置。
  7. 选择 List cookies(列出 Cookie),最后,选择 Remove cookie(移除 Cookie)。

12. 在 WebView 中加载 Flutter 资源、文件和 HTML 字符串

您的应用可以使用不同的方法加载 HTML 文件,并在 WebView 中显示这些文件。在此步骤中,您将加载 pubspec.yaml 文件中指定的 Flutter 资源,加载位于指定路径下的文件,并使用 HTML 字符串加载页面。

如果您要加载位于指定路径的文件,则需要将 path_provider 添加到 pubspec.yaml。这是一个 Flutter 插件,可用于查找文件系统中的常用位置。

在 pubspec.yaml 中添加以下行:

pubspec.yaml

dependencies:
 flutter:
   sdk: flutter

 # The following adds the Cupertino Icons font to your application.
 # Use with the CupertinoIcons class for iOS style icons.
 cupertino_icons: ^1.0.2
 webview_flutter: ^3.0.0
 path_provider: ^2.0.7   # Add this line

为了加载资源,需要在 pubspec.yaml 中指定该资源的路径。在 pubspec.yaml 中添加以下行:

pubspec.yaml

# The following section is specific to Flutter.
flutter:

 # The following line ensures that the Material Icons font is
 # included with your application, so that you can use the icons in
 # the material Icons class.
 uses-material-design: true
 # Add from here
 assets:
   - assets/www/index.html
   - assets/www/styles/style.css
 # ... to here.

如需向项目中添加资源,请按以下步骤操作:

  1. 在项目的根文件夹中创建一个名为 assets 的新 Directory
  2. assets 文件夹中创建一个名为 www 的新 Directory
  3. www 文件夹中创建一个名为 styles 的新 Directory
  4. www 文件夹中创建一个名为 index.html 的新 File
  5. styles 文件夹中创建一个名为 style.css 的新 File

复制以下代码并将其粘贴到 index.html 文件中:

assets/www/index.html

<!DOCTYPE html>
<!-- Copyright 2013 The Flutter Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file. -->
<html lang="en">
<head>
<title>Load file or HTML string example</title>
<link rel="stylesheet" href="styles/style.css" />
</head>
<body>

<h1>Local demo page</h1>
<p>
 This is an example page used to demonstrate how to load a local file or HTML
 string using the <a href="https://pub.dev/packages/webview_flutter">Flutter
 webview</a> plugin.
</p>

</body>
</html>

对于 style.css,请使用以下几行代码来设置 HTML 标头样式:

assets/www/styles/style.css

h1 {
   color: blue;
}

现在,资源已设置完毕且可供使用,接下来您可以实现加载和显示 Flutter 资源、文件或 HTML 字符串所需的方法。

加载 Flutter 资源

如需加载刚刚创建的资源,您只需使用 WebViewController 调用 loadFlutterAsset 方法,并将路径作为参数提供给该资源。在代码末尾添加以下方法:

lib/src/menu.dart

Future<void> _onLoadFlutterAssetExample(
   WebViewController controller, BuildContext context) async {
 await controller.loadFlutterAsset('assets/www/index.html');
}

加载本地文件

如需在设备上加载文件,您可以添加一个使用 loadFile 方法的方法,操作是使用 WebViewController(它接受包含文件路径的 String)。

您需要先创建一个包含 HTML 代码的文件。您只需在 menu.dart 文件(位于导入的正下方)的代码顶部将 HTML 代码以字符串的形式添加,即可实现此目的。

lib/src/menu.dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:webview_flutter/webview_flutter.dart';

// Add from here ...
const String kExamplePage = '''
<!DOCTYPE html>
<html lang="en">
<head>
<title>Load file or HTML string example</title>
</head>
<body>

<h1>Local demo page</h1>
<p>
 This is an example page used to demonstrate how to load a local file or HTML
 string using the <a href="https://pub.dev/packages/webview_flutter">Flutter
 webview</a> plugin.
</p>

</body>
</html>
''';
// ... to here.

如需创建 File 并将 HTML 字符串写入文件,您需要添加两种方法。_onLoadLocalFileExample 会通过以 _prepareLocalFile() 方法返回的字符串的形式提供路径来加载文件。将以下方法添加到您的代码中:

lib/src/menu.dart

Future<void> _onLoadFlutterAssetExample(
   WebViewController controller, BuildContext context) async {
 await controller.loadFlutterAsset('assets/www/index.html');
}

Future<void> _onLoadLocalFileExample(
   WebViewController controller, BuildContext context) async {
 final String pathToIndex = await _prepareLocalFile();

 await controller.loadFile(pathToIndex);
}

static Future<String> _prepareLocalFile() async {
 final String tmpDir = (await getTemporaryDirectory()).path;
 final File indexFile = File('$tmpDir/www/index.html');

 await Directory('$tmpDir/www').create(recursive: true);
 await indexFile.writeAsString(kExamplePage);

 return indexFile.path;
}
// ... to here.

加载 HTML 字符串

通过提供 HTML 字符串来显示页面相当简单直接。WebViewController 有一个名为 loadHtmlString 的方法,您可以使用该方法来将 HTML 字符串以参数的形式提供。然后,WebView 将显示提供的 HTML 页面。将以下代码添加到您的代码中:

lib/src/menu.dart

Future<void> _onLoadFlutterAssetExample(
   WebViewController controller, BuildContext context) async {
 await controller.loadFlutterAsset('assets/www/index.html');
}

Future<void> _onLoadLocalFileExample(
   WebViewController controller, BuildContext context) async {
 final String pathToIndex = await _prepareLocalFile();

 await controller.loadFile(pathToIndex);
}

static Future<String> _prepareLocalFile() async {
 final String tmpDir = (await getTemporaryDirectory()).path;
 final File indexFile = File('$tmpDir/www/index.html');

 await Directory('$tmpDir/www').create(recursive: true);
 await indexFile.writeAsString(kExamplePage);

 return indexFile.path;
}

// Add here ...
Future<void> _onLoadHtmlStringExample(
   WebViewController controller, BuildContext context) async {
 await controller.loadHtmlString(kExamplePage);
}
// ... to here.

添加菜单项

现在,资源已设置完毕并可供使用,并且所有功能的方法均已调用,您可以更新菜单了。将以下条目添加到 _MenuOptions 枚举:

lib/src/menu.dart

enum _MenuOptions {
  navigationDelegate,
  userAgent,
  javascriptChannel,
  listCookies,
  clearCookies,
  addCookie,
  setCookie,
  removeCookie,
  // Add from here ...
  loadFlutterAsset,
  loadLocalFile,
  loadHtmlString,
  // ... to here.
}

现在,枚举已更新,您可以添加菜单选项,并将其连接到您刚刚添加的辅助方法。将 _MenuState 类更新为如下所示:

lib/src/menu.dart

class _MenuState extends State<Menu> {
 final CookieManager cookieManager = CookieManager();

 @override
 Widget build(BuildContext context) {
   return FutureBuilder<WebViewController>(
     future: widget.controller.future,
     builder: (context, controller) {
       return PopupMenuButton<_MenuOptions>(
         onSelected: (value) async {
           switch (value) {
             case _MenuOptions.navigationDelegate:
               controller.data!.loadUrl('https://youtube.com');
               break;
             case _MenuOptions.userAgent:
               final userAgent = await controller.data!
                   .runJavascriptReturningResult('navigator.userAgent');
               ScaffoldMessenger.of(context).showSnackBar(SnackBar(
                 content: Text(userAgent),
               ));
               break;
             case _MenuOptions.javascriptChannel:
               await controller.data!.runJavascript('''
var req = new XMLHttpRequest();
req.open('GET', "https://api.ipify.org/?format=json");
req.onload = function() {
 if (req.status == 200) {
   let response = JSON.parse(req.responseText);
   SnackBar.postMessage("IP Address: " + response.ip);
 } else {
   SnackBar.postMessage("Error: " + req.status);
 }
}
req.send();''');
               break;
             case _MenuOptions.clearCookies:
               _onClearCookies();
               break;
             case _MenuOptions.listCookies:
               _onListCookies(controller.data!);
               break;
             case _MenuOptions.addCookie:
               _onAddCookie(controller.data!);
               break;
             case _MenuOptions.setCookie:
               _onSetCookie(controller.data!);
               break;
             case _MenuOptions.removeCookie:
               _onRemoveCookie(controller.data!);
               Break;
             // Add from here ...
             case _MenuOptions.loadFlutterAsset:
               _onLoadFlutterAssetExample(controller.data!, context);
               break;
             case _MenuOptions.loadLocalFile:
               _onLoadLocalFileExample(controller.data!, context);
               break;
             case _MenuOptions.loadHtmlString:
               _onLoadHtmlStringExample(controller.data!, context);
               Break;
             // ... to here.
           }
         },
         itemBuilder: (context) => [
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.navigationDelegate,
             child: Text('Navigate to YouTube'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.userAgent,
             child: Text('Show user-agent'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.javascriptChannel,
             child: Text('Lookup IP Address'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.clearCookies,
             child: Text('Clear cookies'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.listCookies,
             child: Text('List cookies'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.addCookie,
             child: Text('Add cookie'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.setCookie,
             child: Text('Set cookie'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.removeCookie,
             child: Text('Remove cookie'),
           ),
           // Add from here ...
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.loadFlutterAsset,
             child: Text('Load Flutter Asset'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.loadHtmlString,
             child: Text('Load HTML string'),
           ),
           const PopupMenuItem<_MenuOptions>(
             value: _MenuOptions.loadLocalFile,
             child: Text('Load local file'),
           ),
           // ... to here.
         ],
       );
     },
   );
 }

测试资源、文件和 HTML 字符串

如需测试您刚刚实现的代码是否正常运行,您可以在设备上运行该代码,然后点击某个新添加的菜单项。注意 _onLoadFlutterAssetExample 如何使用我们添加的 style.css 将 HTML 文件标头更改为蓝色。

13. 全部完成!

恭喜!您已完成此 Codelab 的相关学习。您可以在 Codelab 代码库中找到此 Codelab 的完整代码。

如需了解详情,请尝试学习其他 Flutter Codelab