Flutter 使用Webview进行混合开发

4,700 阅读3分钟

前言

开发过程中我们会遇到App嵌套网页的使用场景,我们在App中跳转到H5网页有时候网页中会有跳转到App的需求,也就是App与网页的相互通信。下面我们来慢慢的实现这个功能。

准备工作

安装Flutter插件webview_flutter

pubspec.yaml添加依赖

开发使用的Flutter版本是2.2.3,dart版本是2.13.x,安装webview_flutter: ^2.0.10最低dart版本>=2.12.x,建议使用新的版本,之前有遇到安卓手机不能弹起输入键盘、内存泄漏、文本不能复制等一系列问题,2.0.10这个版本没有遇见类似问题。

dependencies:
...
 webview_flutter: ^2.0.10
..

使用webview_flutter插件

import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_nxj_c/helpers/colors.dart';
import 'package:flutter_nxj_c/helpers/config.dart';
import 'package:flutter_nxj_c/stores/hybrid_h5.dart';
import 'package:webview_flutter/webview_flutter.dart';

class HybridH5 extends StatefulWidget {
  static const String routeName = '/hybrid_h5';
  const HybridH5();
  @override
  _HybridH5State createState() => _HybridH5State();
}

class _HybridH5State extends State<HybridH5> {
  final Completer<WebViewController> _controller = Completer<WebViewController>();
  final _hybridH5Store = HybridH5Store();
  late WebViewController _webViewController;
  String title = '加载中...';

  @override
  void initState() {
    super.initState();
    if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
    Future(() async {
      // 请求参数参数
      var params = ModalRoute.of(context)!.settings.arguments;
      debugPrint('WebView参数-----$params');
      // Modal.loading(duration: Duration.zero);
      await _initController();
    });
  }

  Future<void> _initController() async {
    await _controller.future.then((controller) {
      _webViewController = controller;
      _webViewController.loadUrl('https://juejin.cn/user/1046390798028072');
    });
  }

  @override
  Widget build(BuildContext context) => WillPopScope(
        onWillPop: () async {
          var readyController = await _controller.future;
          if (await readyController.canGoBack()) {
            await readyController.goBack();
            return Future.value(false);
          }
          return Future.value(true);
        },
        child: CupertinoPageScaffold(
          backgroundColor: ColorTheme.of(context).colorF3F3F6,
          navigationBar: CupertinoNavigationBar(
            backgroundColor: ColorTheme.of(context).colorFFFFFF,
            padding: EdgeInsetsDirectional.zero,
            border: Border.all(color: ColorTheme.of(context).borderColor),
            transitionBetweenRoutes: Platform.isIOS,
            middle: Text('$title'),
            leading: GestureDetector(
              behavior: HitTestBehavior.opaque,
              onTap: () async {
                var readyController = await _controller.future;
                if (await readyController.canGoBack()) {
                  await readyController.goBack();
                  return;
                }
                Navigator.pop(context, '数据传参');
              },
              child: Container(
                width: 42.0,
                padding: const EdgeInsets.only(left: 10.0, right: 20.0),
                child: Image.asset(
                  'assets/icons/ic_arrow_left_gray.png',
                  color: ColorTheme.of(context).color202326,
                ),
              ),
            ),
          ),
          child: WebView(
            javascriptMode: JavascriptMode.unrestricted,
            onWebViewCreated: (webViewController) {
              _controller.complete(webViewController);
            },
            onProgress: (progress) {
              print('WebView is loading (progress : $progress%)');
            },
            javascriptChannels: <JavascriptChannel>{
              _toasterJavascriptChannel(context),
            },
            navigationDelegate: (request) => _hybridH5Store.listenNavigationDelegate(request),
            onPageStarted: (url) {
              print('Page started loading: $url');
            },
            onPageFinished: (url) async {
              debugPrint('Page finished loading: $url');
              await _webViewController.evaluateJavascript('document.title').then((result) {
                debugPrint('标题--: $result');
                if (result.replaceAll('"', '').isNotEmpty) {
                  setState(() => title = result.replaceAll('"', ''));
                }
              });
            },
            gestureNavigationEnabled: true,
          ),
        ),
      );

  JavascriptChannel _toasterJavascriptChannel(BuildContext context) {
    return JavascriptChannel(
        name: 'Toaster',
        onMessageReceived: (message) {
          // ignore: deprecated_member_use
          Scaffold.of(context).showSnackBar(
            SnackBar(content: Text(message.message)),
          );
        });
  }
}

初始化加载地址,设置headers

使用_webViewController.loadUrl方法加载网址,这个地方我们可以headers,有token需求的同学们就可以直接将App内的token放到浏览器中,我们也可以访问cookie

Future<void> _initController() async {
  await _controller.future.then((controller) {
    _webViewController = controller;
    _webViewController.loadUrl('https://juejin.cn/user/1046390798028072',headers:{});
  });
}

Webview组件构建

webview生成成功以后调用_controller.complete(webViewController)将我们需要访问的网址放入,浏览器就会访问指定的网页

WebView(
  javascriptMode: JavascriptMode.unrestricted,
  onWebViewCreated: (webViewController) {
    _controller.complete(webViewController);
  },
  onProgress: (progress) {
    print('WebView is loading (progress : $progress%)');
  },
  javascriptChannels: <JavascriptChannel>{
    _toasterJavascriptChannel(context),
  },
  navigationDelegate: (request) => _hybridH5Store.listenNavigationDelegate(request),
  onPageStarted: (url) {
    print('Page started loading: $url');
  },
  onPageFinished: (url) async {
    debugPrint('Page finished loading: $url');
    await _webViewController.evaluateJavascript('document.title').then((result) {
      debugPrint('标题--: $result');
      if (result.replaceAll('"', '').isNotEmpty) {
        setState(() => title = result.replaceAll('"', ''));
      }
    });
  },
  gestureNavigationEnabled: true,
)

获取浏览器访问的页面title

在浏览器访问网页的过程中我们会修改标题,达到不同页面显示不同标题的功能。

调用_webViewController.evaluateJavascript('document.title')方法获取网页标题。

注意:这是一个异步方法。

onPageFinished: (url) async {
  debugPrint('Page finished loading: $url');
  await _webViewController.evaluateJavascript('document.title').then((result) {
    debugPrint('标题--: $result');
    if (result.replaceAll('"', '').isNotEmpty) {
      setState(() => title = result.replaceAll('"', ''));
    }
  });
}

Screenshot_1628435985.png

Screenshot_1628435963.png

浏览器网页与App的相互通信

App如何接收到网页的方法

WebView增加javascriptChannelsjavascriptChannelsjavaScript的管道可以包含很多个自己定义的方法。

javascriptChannels: <JavascriptChannel>{
// 弹出App内的提示框
  _toasterJavascriptChannel(context),
// 保存图片到相册
  _fileDownLoaderChannel(),
// 添加自定义的方法处理网页
  ...
},

_toasterJavascriptChannel

JavascriptChannel _toasterJavascriptChannel(BuildContext context) {
  return JavascriptChannel(
      name: 'Toaster',
      onMessageReceived: (message) {
        // ignore: deprecated_member_use
        Scaffold.of(context).showSnackBar(
          SnackBar(content: Text(message.message)),
        );
      });
}

_fileDownLoaderChannel

JavascriptChannel _fileDownLoaderChannel() {
  return JavascriptChannel(
      name: 'fileDownLoader',
      onMessageReceived: (message) async {
        // 跳转到指定页面
        try {
          final data = json.decode(message.message) as Map<String, dynamic>;
          var response = await api.Interceptor.dio
              .get<Options>("${data['url']}", options: Options(responseType: ResponseType.bytes));
          await ImageGallerySaver.saveImage(Uint8List.fromList(response.data as List<int>));
          Toast.info(msg: '保存图片成功', showTime: 5000);
        } catch (err) {
          Toast.info(msg: '保存图片失败', showTime: 5000);
        }
      });
}

网页通知App弹出提示框、保存图片到相册

// 保存图片到App
window.fileDownLoader.postMessage(JSON.stringify({ url: detailData.codeUrl }));
// 使用App的提示
window.Toaster.postMessage(JSON.stringify({ message: '提示信息' }));

JavascriptChannelname就是window方法需要通信的方法名,onMessageReceived方法的参数就是postMessage发送的参数。

网页不能打开三方应用问题

网页中会有打开第三方应用、拨打电话的功能,在我们不对WebView增加方法的时候点击就会无效。话不多说现在开始解决这个问题。

WebView中增加navigationDelegate这个方法可以监听到导航地址的变化。

navigationDelegate: (NavigationRequest request) {
  debugPrint('request.url ${request.url}');
  // 检查支付宝
  if (request.url.contains('alipays://')) {
    _openUrl(request.url);
    return NavigationDecision.prevent;
  }
  // 路由拦截-单页应用路由检测有问题
  if (request.url.contains('https://3gimg.qq.com/') ||
      request.url.contains('https://apis.map.qq.com')) {
    debugPrint('跳转地图-路由被拦截');
    return NavigationDecision.prevent;
  }
  if (request.url.contains('tel:')) {
    // 拨打电话
    _openUrl(request.url);
    return NavigationDecision.prevent;
  }
  debugPrint('allowing navigation to ${request.url}');
  return NavigationDecision.navigate;
}

_openUrl方法

使用url_launcher插件的launch方法可以打开App或者浏览器,打开App使用的是schema

Future<void> _openUrl(String linkUrl) async {
  await launch(linkUrl);
}

结语

目前就是我使用Webview遇到的问题,大家遇到有其它问题欢迎留言讨论。