[Flutter翻译]Flutter WebView JavaScript通信 - InAppWebView 5。

6,865 阅读8分钟

原文地址:medium.com/flutter-com…

原文作者:medium.com/@pichillilo…

发布时间:2021年4月20日-8分钟阅读

image.png

Flutter InAppWebView JavaScript双向通信。

在这个深入的教程中,我将解释如何使用我的flutter_inappwebview插件(在写这篇文章的时候,最新的版本是5.3.2)从Dart(Flutter WebView)到JavaScript进行通信,反之亦然。

有3种主要方式来实现这种2-way通信。

  • JavaScript处理程序。
  • Web消息通道。
  • Web消息监听器。

JavaScript处理程序

JavaScript处理程序的概念类似于原生的Android WebView JavaScript接口iOS WKWebView JavaScript消息处理程序的,但它提供了一种跨平台的方式。

Android和iOS一起走向跨平台的方式。

对于每一个JavaScript Handler,你可以定义它的名字和一个回调,当JavaScript端调用它的时候就会被调用。同时,回调可以以Promise类型向JavaScript端返回数据。

要添加一个JavaScript处理程序,可以使用InAppWebViewController.addJavaScriptHandler方法。如果你需要在网页加载时就管理JavaScript处理程序,这个方法应该在创建InAppWebView时调用,就像这样。

onWebViewCreated: (controller) {
  // register a JavaScript handler with name "myHandlerName"
  controller.addJavaScriptHandler(handlerName: 'myHandlerName', callback: (args) {
    // print arguments coming from the JavaScript side!
    print(args);
    // return data to the JavaScript side!
    return {
      'bar': 'bar_value', 'baz': 'baz_value'
    };
  });
},

回调的返回类型将使用dart:convert库的jsonEncode函数自动进行JSON编码。所以,你可以把所有可编码的JSON类型发回给JavaScript。

而在JavaScript端,要执行回调处理程序并向Flutter发送数据,你需要使用window.flutter_inappwebview.callHandler(handlerName, ...args)方法,其中handlerName是一个字符串,代表你要调用的处理程序名称,args是你可以向Flutter端发送的可选参数。


需要注意的是,如果你想要一个不同的名字,而不是window.flutter_inappwebview,你可以简单地把它包在另一个JavaScript函数或对象里面。例如:window.myCustomObj = { callHandler: window.flutter_inappwebview.callHandler }; 然后,你可以用同样的方式使用window.myCustomObj.callHandler。

另外,你也可以用这种方式来包装整个特定的处理程序。

const myHandlerName = function(...args) {
  return window.flutter_inappwebview.callHandler('myHandlerName', ...args);
};
// and then use it
myHandlerName();

为了在JavaScript文件或<script> HTML标签内正确调用window.flutter_inappwebview.callHandler,你需要等待并监听flutterInAppWebViewPlatformReady JavaScript事件。一旦平台准备好处理callHandler方法,这个事件就会被派发。你也可以使用一个全局标志变量,该变量在flutterInAppWebViewPlatformReady事件被派遣时设置,并在调用window.flutter_inappwebview.callHandler方法之前使用它。

下面是一个JavaScript示例代码。

// execute inside the "flutterInAppWebViewPlatformReady" event listener
window.addEventListener("flutterInAppWebViewPlatformReady", function(event) {
  const args = [1, true, ['bar', 5], {foo: 'baz'}];
  window.flutter_inappwebview.callHandler('myHandlerName', ...args);
});
// or using a global flag variable
var isFlutterInAppWebViewReady = false;
window.addEventListener("flutterInAppWebViewPlatformReady", function(event) {
  isFlutterInAppWebViewReady = true;
});
// then, somewhere in your code
if (isFlutterInAppWebViewReady) {
  const args = [1, true, ['bar', 5], {foo: 'baz'}];
  window.flutter_inappwebview.callHandler('myHandlerName', ...args);
}

相反,如果你是从Dart(Flutter)端评估JavaScript代码,你可以直接在onLoadStop事件上(或之后)调用window.flutter_inappwebview.callHandler,而不需要监听flutterInAppWebViewPlatformReady JavaScript事件,因为,那时它已经被发射了。例如

onLoadStop: (controller, url) async {
  await controller.evaluateJavascript(source: """
    const args = [1, true, ['bar', 5], {foo: 'baz'}];
    window.flutter_inappwebview.callHandler('myHandlerName', ...args);
  """);
},

这里是一个通信的例子。

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

Future main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (Platform.isAndroid) {
    await AndroidInAppWebViewController.setWebContentsDebuggingEnabled(true);
  }
  
  runApp(new MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => new _MyAppState();
}

class _MyAppState extends State<MyApp> {

  InAppWebViewGroupOptions options = InAppWebViewGroupOptions(
      android: AndroidInAppWebViewOptions(
        useHybridComposition: true,
      ),);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          appBar: AppBar(title: Text("JavaScript Handlers")),
          body: SafeArea(
              child: Column(children: <Widget>[
                Expanded(
                  child: InAppWebView(
                    initialData: InAppWebViewInitialData(
                        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">
    </head>
    <body>
        <h1>JavaScript Handlers</h1>
        <script>
            window.addEventListener("flutterInAppWebViewPlatformReady", function(event) {
                window.flutter_inappwebview.callHandler('handlerFoo')
                  .then(function(result) {
                    // print to the console the data coming
                    // from the Flutter side.
                    console.log(JSON.stringify(result));
                    
                    window.flutter_inappwebview
                      .callHandler('handlerFooWithArgs', 1, true, ['bar', 5], {foo: 'baz'}, result);
                });
            });
        </script>
    </body>
</html>
                      """
                    ),
                    initialOptions: options,
                    onWebViewCreated: (controller) {
                      controller.addJavaScriptHandler(handlerName: 'handlerFoo', callback: (args) {
                        // return data to the JavaScript side!
                        return {
                          'bar': 'bar_value', 'baz': 'baz_value'
                        };
                      });

                      controller.addJavaScriptHandler(handlerName: 'handlerFooWithArgs', callback: (args) {
                        print(args);
                        // it will print: [1, true, [bar, 5], {foo: baz}, {bar: bar_value, baz: baz_value}]
                      });
                    },
                    onConsoleMessage: (controller, consoleMessage) {
                      print(consoleMessage);
                      // it will print: {message: {"bar":"bar_value","baz":"baz_value"}, messageLevel: 1}
                    },
                  ),
                ),
              ]))),
    );
  }
}

与JavaScript通信的另一种方式是使用InAppWebViewController.evaluateJavascript方法来评估一些JavaScript代码。

你可以设置一个消息事件监听器(与postMessage一起使用)或一个自定义的事件监听器(详见CustomEvent),比如。

// message event listener
window.addEventListener("message", (event) => {
  console.log(event.data);
}, false);
// or custom event listener
window.addEventListener("myCustomEvent", (event) => {
  console.log(event.detail);
}, false);

然后,你就可以随时随地分派自定义的JavaScript事件。

// using postMessage method
window.postMessage({foo: 1, bar: false});
// or dispatching a custom event
const event = new CustomEvent("myCustomEvent", {
    detail: {foo: 1, bar: false}
});
window.dispatchEvent(event);

所以,你可以在运行时使用InAppWebViewController.evaluateJavascript方法来设置这些事件监听器,或者在Web应用本身内部,使用同样的方法来调度这些事件,例如。

onLoadStop: (controller, url) async {
  await controller.evaluateJavascript(source: """
    window.addEventListener("myCustomEvent", (event) => {
      console.log(JSON.stringify(event.detail));
    }, false);
  """);
  await Future.delayed(Duration(seconds: 5));
  controller.evaluateJavascript(source: """
    const event = new CustomEvent("myCustomEvent", {
      detail: {foo: 1, bar: false}
    });
    window.dispatchEvent(event);
  """);
},
onConsoleMessage: (controller, consoleMessage) {
  print(consoleMessage);
  // it will print: {message: {"foo":1,"bar":false}, messageLevel: 1}
},

网络消息通道

另一种方法是使用Web消息通道,它是HTML5消息通道的代表。更多细节请参见通道消息传递API

它允许你创建一个新的消息通道,并通过其两个WebMessagePort属性来发送数据。

  • port1,这是第一个WebMessagePort。
  • port2,这是第二个WebMessagePort。

这都是关于连接的。是的。

要创建一个Web消息通道,你需要使用InAppWebViewController.createWebMessageChannel方法(官方的本地Android WebView API可以在这里找到)。这个方法应该在页面加载时被调用,例如,当onLoadStop事件被触发时,否则,WebMessageChannel将无法工作。

Android的注意事项。只有当AndroidWebViewFeature.isFeatureSupported对AndroidWebViewFeature.CREATE_WEB_MESSAGE_CHANNEL返回true时,才可以调用这个方法。

下面是它的一个例子。

onLoadStop: (controller, url) async {
    if (!Platform.isAndroid || await AndroidWebViewFeature.isFeatureSupported(AndroidWebViewFeature.CREATE_WEB_MESSAGE_CHANNEL)) {
        var webMessageChannel = await controller.createWebMessageChannel();
        var port1 = webMessageChannel!.port1;
        var port2 = webMessageChannel.port2;
    }
},

创建通道的Dart方使用port1,而端口另一端的JavaScript方使用port2--你向port2发送消息,并使用InAppWebViewController.postWebMessage方法将端口与要发送的消息以及要转移所有权的对象(在这里是指端口本身)一起转移到另一个浏览环境。

当这些可转移的端口对象被转移时,它们在之前的上下文中被 "阉割 "了,即它们之前属于的那个上下文。例如,当一个端口被发送到JavaScript时,就不能再用于在Dart端发送或接收消息。此外,与 HTML5 Spec 不同的是,如果发生过以下情况,则不能转移端口。

  • 设置了一个消息回调。
  • 一个消息被发布在上面。

我知道你在想什么。"胡说八道......来吧,让我看看代码!"。别担心,我们就快到了!

一个被转移的端口不能被应用程序关闭,因为所有权也被转移了。

为了从Dart方面监听端口上的消息,你需要使用WebMessagePort.setWebMessageCallback方法设置WebMessageCallback。然后你可以通过使用WebMessagePort.postMessage方法向原始文件发送消息来进行回应。

当你想停止在通道中发送消息时,你可以调用WebMessagePort.close来关闭端口。关闭的端口不能被转移,也不能被重新打开以发送消息。

为了能够监听来自JavaScript方面的消息,你需要首先 "捕获 "来自Dart方面的端口。 这里终于为你提供了预期的通信实例。

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

Future main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (Platform.isAndroid) {
    await AndroidInAppWebViewController.setWebContentsDebuggingEnabled(true);
  }

  runApp(new MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => new _MyAppState();
}

class _MyAppState extends State<MyApp> {

  InAppWebViewGroupOptions options = InAppWebViewGroupOptions(
    android: AndroidInAppWebViewOptions(
      useHybridComposition: true,
    ),);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          appBar: AppBar(title: Text("Web Message Channels")),
          body: SafeArea(
              child: Column(children: <Widget>[
                Expanded(
                  child: InAppWebView(
                    initialData: InAppWebViewInitialData(
                        data: """
<!DOCTYPE html>
<html lang="en">
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebMessageChannel Test</title>
</head>
<body>
    <!-- when you click this button, it will send a message to the Dart side -->
    <button id="button" onclick="port.postMessage(input.value);" />Send</button>
    <br />
    <input id="input" type="text" value="JavaScript To Native" />
    
    <script>
      // variable that will represents the port used to communicate with the Dart side
      var port;
      // listen for messages
      window.addEventListener('message', function(event) {
          if (event.data == 'capturePort') {
              // capture port2 coming from the Dart side
              if (event.ports[0] != null) {
                  // the port is ready for communication,
                  // so you can use port.postMessage(message); wherever you want
                  port = event.ports[0];
                  // To listen to messages coming from the Dart side, set the onmessage event listener
                  port.onmessage = function (event) {
                      // event.data contains the message data coming from the Dart side 
                      console.log(event.data);
                  };
              }
          }
      }, false);
    </script>
</body>
</html>
"""),
                    initialOptions: options,
                    onConsoleMessage: (controller, consoleMessage) {
                      print("Message coming from the Dart side: ${consoleMessage.message}");
                    },
                    onLoadStop: (controller, url) async {
                      if (!Platform.isAndroid || await AndroidWebViewFeature.isFeatureSupported(AndroidWebViewFeature.CREATE_WEB_MESSAGE_CHANNEL)) {
                        // wait until the page is loaded, and then create the Web Message Channel
                        var webMessageChannel = await controller.createWebMessageChannel();
                        var port1 = webMessageChannel!.port1;
                        var port2 = webMessageChannel.port2;
  
                        // set the web message callback for the port1
                        await port1.setWebMessageCallback((message) async {
                          print("Message coming from the JavaScript side: $message");
                          // when it receives a message from the JavaScript side, respond back with another message.
                          await port1.postMessage(WebMessage(data: message! + " and back"));
                        });
  
                        // transfer port2 to the webpage to initialize the communication
                        await controller.postWebMessage(message: WebMessage(data: "capturePort", ports: [port2]), targetOrigin: Uri.parse("*"));
                      }
                    },
                  ),
                ),
              ])
          )
      ),
    );
  }
}

网络消息监听器

网络消息监听器类似于JavaScript处理程序和网络消息通道。它允许在WebMessageListener将监听的每一帧中注入一个JavaScript对象。

一个Web消息监听器的例子。

要添加一个Web Message Listener,你需要使用InAppWebViewController.addWebMessageListener方法。这个方法应该在使用它的网页被加载之前被调用,例如,当onWebViewCreated事件被触发时。

Android的注意事项。与Web消息通道类似,只有当AndroidWebViewFeature.isFeatureSupported返回true时,AndroidWebViewFeature.WEB_MESSAGE_LISTENER才应该调用这个方法。

child: InAppWebView(
  onWebViewCreated: (controller) async {
   // add first all of your web message listeners
   if (!Platform.isAndroid || await AndroidWebViewFeature.isFeatureSupported(AndroidWebViewFeature.WEB_MESSAGE_LISTENER)) {
     await controller.addWebMessageListener(WebMessageListener(
       jsObjectName: "myObject",
       allowedOriginRules: Set.from(["https://*.example.com"]),
       onPostMessage: (message, sourceOrigin, isMainFrame, replyProxy) {
         replyProxy.postMessage("Got it!");
       },
     ));
   }
   
   // then load your URL
   await controller.loadUrl(urlRequest: URLRequest(url: Uri.parse("https://www.example.com")));
  },
),

注入的JavaScript对象将在全局范围内被命名为WebMessageListener.jsObjectName。这将在此调用后的每次导航中,在原点符合WebMessageListener.allowedOriginRules的任何框架中注入JavaScript对象,当页面开始加载时,JavaScript对象将立即可用。

每个WebMessageListener.allowedOriginRules条目必须遵循格式SCHEME "://" [ HOSTNAME_PATTERN [ ":" PORT ]],每个部分在本表中解释。

为了安全起见,强烈建议使用HTTPS方案,因为当框架的原点与允许的原点中的任何一个匹配时,JavaScript对象将被注入。允许HTTP来源会使注入的对象暴露给任何潜在的基于网络的攻击者。 如果提供一个通配符 "*",它将注入JavaScript对象到所有的框架。只有当应用程序希望任何第三方网页能够使用注入的对象时,才应该使用通配符。当使用通配符时,应用程序必须将收到的信息视为不可信的,并仔细验证任何数据。

你可以多次调用这个方法来注入多个JavaScript对象!

每个注入的JavaScript对象将有以下方法/属性。

  • postMessage(message[, MessagePorts]) 在Android上,postMessage(message) 在iOS上,message是一个字符串。
  • onmessage。要接收从Flutter App端发布的消息,请为这个属性指定一个函数。这个函数应该接受一个单一的 "事件 "参数(onmessage = function(event) { ... }). "event "有一个 "data "属性,它是来自Flutter App端的消息字符串。
  • addEventListener(type, listener)。为了与DOM EventTarget的addEventListener兼容,它接受type和listener参数,其中type只能是 "消息 "类型,listener只能是一个JavaScript函数,具有onmessage属性函数的相同功能。
  • removeEventListener(type, listener)。为了与DOM EventTarget的removeEventListener兼容,它接受type和listener参数。它被用来删除使用addEventListener方法添加的事件监听器。

通信必须首先在JavaScript端初始化,发布消息,所以Flutter App将有一个JavaScriptReplyProxy对象来响应。

JavaScript端。

// Web page (in JavaScript)
myObject.onmessage = function(event) {
  // prints "Got it!" when we receive the app's response.
  console.log(event.data);
}
myObject.postMessage("I'm ready!");

Flutter方面。

// Flutter App
child: InAppWebView(
  onWebViewCreated: (controller) async {
   if (!Platform.isAndroid || await AndroidWebViewFeature.isFeatureSupported(AndroidWebViewFeature.WEB_MESSAGE_LISTENER)) {
     await controller.addWebMessageListener(WebMessageListener(
       jsObjectName: "myObject",
       onPostMessage: (message, sourceOrigin, isMainFrame, replyProxy) {
         // do something about message, sourceOrigin and isMainFrame.
         replyProxy.postMessage("Got it!");
       },
     ));
   }
   await controller.loadUrl(urlRequest: URLRequest(url: Uri.parse("https://www.example.com")));
  },
),

总结

现在,在本教程结束后,你可以开始尝试不同的双向通信方法,并选择最适合你和适合你需求的方法。 今天的内容就到此为止!

一如既往,感谢所有支持这个项目的人 💙


通过www.DeepL.com/Translator(免费版)翻译