本文会介绍 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 代码:
<button type="button" onclick="callNative.writeLine('123');">
Invoke C# Code
</button>
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 代码:
<button type="button" onclick="callNative.writeLine('123');">
Invoke C# Code
</button>
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 代码:
<button type="button" onclick="callNative1.Test('123');">Invoke C# Code</button>
上面的例子中,我们先写了一个叫JSInterface的类,里面有一些 Native 函数,然后在onCreate生命周期中调用addJavascriptInterface函数,第一个参数是需要传递给 JS 的对象,第二个参数是全局变量的名字。注入完毕后,就可以在 JS 端调用window.callNative1.Test和window.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 代码:
<button type="button" onclick="callNative.writeLine('123');">
Invoke C# Code
</button>
上面的例子中,Native 端先通过AddScriptMessageHandler将类MessageHandler的实例作为入口invokeAction注入到 JS 端,然后 JS 端再调用window.webkit.messageHandlers.invokeAction.postMessage与 Native 通信。
注意事项
数据丢失
通过进程间通信的原理和上面的例子,我们发现 Native 和 JS 通信时数据最终会变成字符串的格式。虽然可以通过 JSON5 来传递更多的信息,或者使用二进制流来传递文件,但是像函数、Date 等复杂对象依然不能被正确转换,因此不能传递复杂的数据。
通信开销
同上面的场景,通信前后需要对数据进行序列化。并且由于数据信息的缺失,拿到数据后我们可能还要对数据进行处理。如果频繁的进行跨端通信,会对性能产生很大的影响。
数据截断
跨端通信对于数据的大小是有限制的,在移动端尤为明显。如果将一个非常大的数据进行跨端传输,可能会造成内存占用大,导致被操作系统杀死。所以如果要传递大数据,可以借鉴 HTTP 通信中的报文机制,进行分段传输。