Native 与 JS 的双向通信

本文会介绍 Native 应用中 Native 层与 JS 层是如何通信的,以及从通信原理中找到一些需要注意的地方。

注意:Webview 控件在不同平台、不同时期有不同的叫法,为了方便,本文统称为 Webview。

前置知识:进程间通信

进程间通信(IPC,Inter-Process Communication)指的是两个不同的进程相互传递信息。在一个 Native 程序中,嵌入一个 Webview 控件以后,这个 Webview 控件相当于一个小型的浏览器,它会开启 UI 渲染线程、JS 虚拟机线程、网络线程等。所以 Native 与 JS 通信,其实是 Native 线程与 JS 虚拟机线程的通信。

不管是进程间通信还是线程间通信,理论上可复用的数据很高,如 Node.js 进程之间甚至可以共享一个 Server 或者 Socket。然而, JS 与 Native 的数据结构不同,所以 Native 的数据结构并不能复用。Native 与 JS 的通信会使用 HTML5 结构化克隆算法来序列化传递的数据,也就是说传递的数据最终会被转换成字符串,所以不能被 JSON.stringify 或其他序列化方法转换的的数据结构就会丢失。

Native 调用 JS

首先来说一说 Native 如何调用 JS。其实,所有的 Webview 控件都会自带一个方法用来执行 JS,只是它们的格式有所区别,主要有以下两种格式:

// 函数名和参数列表分开
this.webView.InvokeScript("alert", "123");

// 直接执行一段JS代码
this.webView.EvaluateJavaScriptAsync("alert('123')");

Native 调用 JS 是一件非常简单的事情,但是一般只有做自动化测试的时候才会这么做,因为 JS 能做的事情 Native 也能做,而且做得更好。

JS 调用 Native

JS 调用 Native 的方法在不同的平台都不一样,下面我们来分别讲解。

Internet Explorer

在 HTML 标准中,微软贡献了一个名为window.external的全局变量。这个变量用来提供添加浏览器的搜索引擎、添加收藏夹、设置主页等外部功能,自然也可以作为 Native 与 JS 通信的桥梁。

在一个 IE 应用中,Webview 控件有一个ObjectForScripting属性,这个属性可以被 JS 端的window.external访问到。比如有如下 Native 代码:

public partial class MainWindow: Window {
  public MainWindow() {
    InitializeComponent();
    this.webBrowser.ObjectForScripting = new WebviewClass();
  }
}
public class WebviewClass {
  public void Test(String message) {
    MessageBox.Show(message, "client code");
  }
}

ObjectForScripting属性被指定成WebviewClass这个类的一个实例,而ObjectForScripting又等于window.external,那么这个实例中的Test函数就可以通过window.external.Test访问到。

Microsoft Edge UWP

在 UWP 版本 Edge 浏览器中,微软依然是通过window.external这个全局变量来访问 Native 代码,然而它和 IE 不同的是,它不是直接调用 Native 函数,而是通过window.external.notify函数给 Native 层传递一串字符串,Native 层有一个叫ScriptNotify的事件专门用来接收这个字符串。收到字符串以后,再从中提取一些特征信息(调用的函数名、参数等),并且执行响应的逻辑。

由于频繁手动调用 notify 麻烦且易错,所以一般会在 JS 层指定一个全局变量或全局函数来封装 Native 调用。一个典型的例子如下:

Native 代码:

// 注册一个全局变量callNative
const string JavaScriptFunction = @"
window.callNative = {
  writeLine(msg) {
    window.external.notify(msg);
  }
}";

this.Control.InvokeScript("eval", new[] { JavaScriptFunction });

// 绑定ScriptNotify事件
void OnWebViewScriptNotify(object sender, NotifyEventArgs e)
{
  Console.WriteLine(e.Value);
}
this.Control.ScriptNotify += OnWebViewScriptNotify;

HTML 代码:

Native 端先让 JS 层在 window 对象上挂载一个叫callNative的全局变量,由于 Edge 调用 JS 是采用函数名和函数参数分开的写法,所以这里需要用eval函数来执行 JS 代码。同时,Native 端也需要挂载ScriptNotify事件,这里是直接调用Console.WriteLine输出到控制台。最后,JS 端调用callNative.writeLine函数,这个函数会调用window.external.notify函数,将msg传递给ScriptNotify事件,进而触发Console.WriteLine函数。

Microsoft Edge Webview2 (Chromium)

最近微软发布了 Webview2 控件,它是基于 Chromium 的浏览器。Webview2 和传统 Webview 在 Native 与 JS 双向通信上大同小异,主要区别是 Webview2 用window.chrome.webview.postMessage替代了window.external.notify,用WebMessageReceived替代了ScriptNotify,调用 JS 代码也可以直接执行 JS 而不需要用eval函数包裹。

将上面的案例稍作修改就可以用于 Webview2:

Native 代码:

// 注册一个全局变量callNative,这段代码也可以写在JS端
const string JavaScriptFunction = @"
window.callNative = {
  writeLine(msg) {
    window.chrome.webview.postMessage(msg);
  }
}";
await this.webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(JavaScriptFunction);


// 绑定ScriptNotify事件
void onMessage(object sender, CoreWebView2WebMessageReceivedEventArgs args)
{
  String msg = args.TryGetWebMessageAsString();
  Console.WriteLine(msg);
}
this.webView.CoreWebView2.WebMessageReceived += onMessage;

HTML 代码:

Android

Android 端的调用方式比较类似于 Internet Explorer,也是将 Native 的函数封装到一个对象里,然后将这个对象写入一个特殊的属性,作为 Native 与 JS 直接的桥梁。比 IE 灵活的一点是,Android 可以通过addJavascriptInterface函数注入多个对象,而不是只能通过window.external访问。

一个典型的例子:

Native 代码:

private final class JSInterface{
  @SuppressLint("JavascriptInterface")
  @JavascriptInterface
  public void Test(String userInfo){
    Toast.makeText(MainActivity.this, userInfo, Toast.LENGTH_LONG).show();
  }
}

@SuppressLint("JavascriptInterface")
@Override
protected void onCreate() {
  wv.addJavascriptInterface(new JSInterface(), "callNative1");
  wv.addJavascriptInterface(new JSInterface(), "callNative2");
}

HTML 代码:

上面的例子中,我们先写了一个叫JSInterface的类,里面有一些 Native 函数,然后在onCreate生命周期中调用addJavascriptInterface函数,第一个参数是需要传递给 JS 的对象,第二个参数是全局变量的名字。注入完毕后,就可以在 JS 端调用window.callNative1.Testwindow.callNative2.Test函数了。

iOS

iOS 端采用了类似 Internet Explorer 的全局变量注入和类似 Webview2 的postMessage通信注入两种结合的方式。

iOS 端需要调用AddScriptMessageHandler函数来给 JS 端传递一个对象,第一个参数的要传递的对象,第二个参数是入口名称。和 IE 不同,iOS 端传入的对象中并不直接包含业务代码,而是一个消息接收对象,该对象必须包含一个叫DidReceiveScriptMessage的方法用来接收 JS 传来的消息:

// 定义消息接收类MessageHandler
public class MessageHandler : WkWebViewRenderer, IWKScriptMessageHandler
{
  public void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message)
  {
    System.Console.WriteLine(message.Body.ToString());
  }
}
public class HybridWebViewRenderer : WkWebViewRenderer, IWKScriptMessageHandler
{
  public HybridWebViewRenderer(WKWebViewConfiguration config) : base(config)
  {
    var userController = config.UserContentController;
    // 将MessageHandler的实例注入到JS中
    userController.AddScriptMessageHandler(new MessageHandler(), "入口名称");
  }
}

注入成功后,就可以在 JS 端通过window.webkit.messageHandlers[入口名称].postMessage给 Native 发送消息了。

一个典型的例子:

Native 代码:

// 定义消息接收类MessageHandler
public class MessageHandler : WkWebViewRenderer, IWKScriptMessageHandler
{
  public void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message)
  {
    System.Console.WriteLine(message.Body.ToString());
  }
}

public class HybridWebViewRenderer : WkWebViewRenderer, IWKScriptMessageHandler
{
  public HybridWebViewRenderer(WKWebViewConfiguration config) : base(config)
  {
    var userController = config.UserContentController;
    // 将MessageHandler的实例注入到JS中
    userController.AddScriptMessageHandler(new MessageHandler(), "invokeAction");

    // 注册一个全局变量callNative,这段代码也可以写在JS端
    const string JavaScriptFunction = @"
window.callNative = {
  writeLine(msg) {
    window.webkit.messageHandlers.invokeAction.postMessage(msg);
  }
}";
    var script = new WKUserScript(new NSString(JavaScriptFunction), WKUserScriptInjectionTime.AtDocumentEnd, false);
    userController.AddUserScript(script);
  }
}

HTML 代码:

上面的例子中,Native 端先通过AddScriptMessageHandler将类MessageHandler的实例作为入口invokeAction注入到 JS 端,然后 JS 端再调用window.webkit.messageHandlers.invokeAction.postMessage与 Native 通信。

注意事项

数据丢失

通过进程间通信的原理和上面的例子,我们发现 Native 和 JS 通信时数据最终会变成字符串的格式。虽然可以通过 JSON5 来传递更多的信息,或者使用二进制流来传递文件,但是像函数、Date 等复杂对象依然不能被正确转换,因此不能传递复杂的数据。

通信开销

同上面的场景,通信前后需要对数据进行序列化。并且由于数据信息的缺失,拿到数据后我们可能还要对数据进行处理。如果频繁的进行跨端通信,会对性能产生很大的影响。

数据截断

跨端通信对于数据的大小是有限制的,在移动端尤为明显。如果将一个非常大的数据进行跨端传输,可能会造成内存占用大,导致被操作系统杀死。所以如果要传递大数据,可以借鉴 HTTP 通信中的报文机制,进行分段传输。

你可能感兴趣的:(Native 与 JS 的双向通信)