写点什么

Native 与 JS 的双向通信

发布于: 2020 年 12 月 25 日

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

前置知识:进程间通信



进程间通信(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.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 代码:



<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 通信中的报文机制,进行分段传输。



发布于: 2020 年 12 月 25 日阅读数: 276
用户头像

还未添加个人签名 2020.09.09 加入

小米云前端开发工程师

评论 (2 条评论)

发布
用户头像
addJavascriptInterface 4.2+就不能用了,而且有安全隐患
2020 年 12 月 31 日 15:03
回复
Android 4.2+
2020 年 12 月 31 日 15:03
回复
没有更多了
Native 与 JS 的双向通信