WK 与 JS 的那些事

5,794 阅读12分钟
原文链接: www.jianshu.com

我们的小马童鞋又发功了。最近打算将UIWebView替换成WKWebView,所以原来的Hybrid层需要动动土,小马小试牛刀。当然遇到了一些问题,看看他是怎么一步步解决的吧。

苹果在iOS 8中推出了 WKWebView,这是一个高性能的 web 框架,相较于 UIWebView 来说,有巨大提升。本文将针对 WKWebView 进行简单介绍,然后介绍下如何和 JS 进行愉快的交互。还望各位大佬不吝赐教。

本文分为两大部分

  1. WKWebView 简单介绍
  2. JS 交互

1 WKWebView

就目前移动开发趋势来说,很多 APP 都会嵌套一些 H5 的应用。H5 有一些 Native 无法比拟的优势,例如:更新快,不用发版,随时上线等等。然而在 iOS 中, UIWebView 是及其难用的。随着 iOS 8 的推出,Apple 重构了 UIWebView,于是 WKWebView 横空出世。

1.1 WKWebView VS UIWebView

根据官方文档,我们来简单对比一下 UIWebView 和 WKWebView,看看这两个到底有什么区别

WKWebView UIWebView
内存占用 大 且有内存泄漏
加载速度
与 JS 交互 难 (可与 JSCore 配合)
帧率 60FPS 掉帧

从文档来看,二者区别还是很明显的,但到底区别有多大的,我们用数据说话。打开京东,网易,新浪这三个网站,从打开时间和占用内存上来比较一下,看谁能胜出。该测试在 2015款 MBP 上打开,使用 Xcode 9 GM 版,在 iPhone 8 Plus 上运行

使用 WKWebView 和 UIWebView 打开 京东 网易 新浪 三个网站所耗费的时长
使用 WKWebView 和 UIWebView 打开 京东 网易 新浪 三个网站所耗费的时长
使用 WKWebView 和 UIWebView 打开 京东 网易 新浪 三个网站所耗费的内存
使用 WKWebView 和 UIWebView 打开 京东 网易 新浪 三个网站所耗费的内存

在内存测试中发现,UIWebView 占用内存很不稳定,在打开新浪的网站时,最高内存能飙升到 200m 后来慢慢回落到 160m 左右,但会上下波动。但 WKWebView 上就没有这个问题。通过上述对比,不难看出,WKWebVeiw 要优于 UIWebView。

1.2 如何使用 WKWebView

得益于苹果 API 的高度封装,我们使用 WKWebView 及其简单

- (WKWebView *)wkWebView {
    if (!_wkWebView) {
        
        _wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:[WKWebViewConfiguration new]]; //1. 
        NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.jd.com"]]; //2.
        [_wkWebView loadRequest:request]; //3. 
    }
    return _wkWebView;
}
  1. 初始化一个 WKWebView,我们需要传一个 WKWebViewConfiguration 对象,来对 WKWebView 进行配置。
  2. 构造一个请求。
  3. 加载这个请求。

只需要这三步,我们就可以使用一个高性能的 web 框架。是不是很赞!!! 关于 WKWebView 如何使用,这里就不做过多的详细介绍了,网上这种文章太多了,大家可以自行翻阅。接下来我们说如何与 JS 交互。

2. JS 交互

WebVeiw 与 JS 交互是一个很古老的问题,如何与 JS 交互是一个 WebVeiw 必须具备的能力,在 UIWebView 时代,我们可以通过拦截 URL 的方式来进行交互,也可以通过 WebViewJavascriptBridge 来进行交互,还可以配合 JSCore 来进行交互。但是在 WKWebView 时代,由于它是在一个单独的进程中运行,我们无法获取到 JSContext,所以我们无法使用 JSCore 这个强大的框架来进行交互,那我们怎么办呢,且听我一一道来。

2.1 Native 调用 JS

还记的上边说的 WKWebViewConfiguration 么,在这个类里边,有一个属性

@property (nonatomic, strong) WKUserContentController *userContentController;

Native 和 H5 交互基本全靠这个对象, 在 WKWebVeiw 中,我们使用我们有两种方式来调用 JS,

  1. 使用 WKUserScript
  2. 直接调用 JS 字符串

2.1.1 使用 WKUserScript

要想使用 WKUserScript,首先,我们要构造一个 WKUserScript 对象,构造方法及其简单,我们使用下边代码来创建一个 WKUserScript 对象。

// source 就是我们要调用的 JS 函数或者我们要执行的 JS 代码
// injectionTime 这个参数我们需要指定一个时间,在什么时候把我们在这段 JS 注入到 WebVeiw 中,它是一个枚举值,WKUserScriptInjectionTimeAtDocumentStart 或者 WKUserScriptInjectionTimeAtDocumentEnd
// MainFrameOnly 因为在 JS 中,一个页面可能有多个 frame,这个参数指定我们的 JS 代码是否只在 mainFrame 中生效
- initWithSource:injectionTime:forMainFrameOnly:

至此,我们已经构建了一个 WKUserScript,然后呢,我们要做的就是要把它添加进来

- addUserScript:

至此使用 WKUserScript 调用 JS 完成。

2.1.2 直接调用 JS 字符串

在 WKWebView 中,我们也可以直接执行 JS 字符串

- (void)evaluateJavaScript: completionHandler:

我们通过调用这个方法来执行 JS 字符串,然后在 completionHandler 中拿到执行这段 JS 代码后的返回值。

至此,Native 调用 JS 完成。是不是简单到害怕


2.2 JS 调用 Native

在 WK 这套框架下,JS 调用 Native 简直简单到丧心病狂。还记的上边那个 WKUserContentController,我们也是要通过它来进行,而你所需要做的,只需要三步,需要三步,三步。

  1. 向 JS 注入一个字符串
[_webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeMethod"];

我们向 JS 注入了一个方法,叫做 nativeMethod

  1. JS 调用 Native
window.webkit.messageHandlers.nativeMethod.postMessage(value);

一句话调用,我们就可以在 Native 中接收到 value

  1. 接收 JS 调用

上边我们调用 addScriptMessageHandler:name 的时候,我们要遵守 WKScriptMessageHandler 协议,然后实现这个协议。

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
 NSString * name = message.name // 就是上边注入到 JS 的哪个名字,在这里是 nativeMethod
 id param = message.body // 就是 JS 调用 Native 时,传过来的 value
 // TODO: do your stuff
}

完了,Native 调用 JS 就这么简单,是不是丧心病狂,简直简单到不能再简单了。

但是,你以为这么就完了么,上边写的这些东西在网上随便一搜都有一大片,重新再写一遍,貌似意义不是很大啊,怎么也得来点稍微不一样的东西吧。


2.3 JS 调用 Native 后的回调

举一个很常见的例子,假设我们有这么一个需求,我的 JS 要调用 Native 发一个网络请求,Native 执行完了,把请求数据回传给 JS。 很简单的一个需求,来,想想怎么执行。

2.3.1 postMessage 的坑

可能很快就想到了,postMessage 的时候,直接把这个方法传过去不就行了。一开始我也是这么做的。

    const person = {
        firstName: "John",
        lastName: "Doe",
        age: 50,
        eyeColor: "blue",
    };
    document.getElementById("li1").onclick = function (nativeValue) {
        person.callBack = function () {
            console.log("native call");
        }
        window.webkit.messageHandlers.nativeMethod.postMessage(person);
    };

首先构造一个 person,然后我们给 person 增加一个 callBack 属性,然后传进去,运行程序。打开 Safari 选择 开发->模拟器,打开调试界面,然后我们点击查看控制台。



然后你会发现,报错了,为什么呢,这一切都是因为 postMessag 这个方法。
打开 postMessage文档 ,你会发现,

message 将要发送到其他 window的数据。它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化

这个 message 需要支持 结构化克隆算法 。很遗憾,这个算法目前不支持传递 FunctionError,它只支持一下几种类型

对象类型 注意
所有的原始类型 除了symbols
Boolean 对象
String 对象
Date
RegExp lastIndex 字段不会被保留。
Blob
File
FileList
ArrayBuffer
ArrayBufferView 这基本上意味着所有的 类型化数组 ,比如 Int32Array 等等。
ImageData
Array
Object 仅包括普通对象 (比如对象字面量 )
Map
Set

说好的不受限制呢

15088520633631.jpg
15088520633631.jpg

2.3.2 function 转为 字符串

那既然它不支持传一个 Function ,那我们就得另辟蹊径了,String 总支持吧,我们把一个方法转为字符串,然后传到 Native,然后 Native 执行这个字符串。貌似可行的,我们来试一下。

JS 代码


    document.getElementById("li1").onclick = function () {

        person.callBack = function (nativeValue) {
            console.log("native call");
        }.toString();
        window.webkit.messageHandlers.nativeMethod.postMessage(person);
    };

OC 代码


- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    
    if ([message.name isEqualToString:@"nativeMethod"]) {
        NSLog(@"body:%@, ", message.body);
        NSDictionary *dict = @{@"key1": @"value1",
                               @"key2": @"value2"
                               }; // 构造回传 js 数据
        id data = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingPrettyPrinted error:nil];
        NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; // 转为 json 字符串
        [_webView evaluateJavaScript:[NSString stringWithFormat:@"(%@)(%@)", message.body[@"callBack"], jsonString] completionHandler:^(id _Nullable jsData, NSError * _Nullable error) {
            
        }];        
    }
}

果然不出我们所料,我们可以直接得到这个 Native 传递给 JS 的值。 但是,这个作用域会不会变化呢,我们在来改一下 JS 代码

document.getElementById("li1").onclick = function () {

    var arg1 = 100;
    var arg2 = 200;
    person.callBack = function (nativeValue) {
        console.log(nativeValue);
        console.log(arg1 + arg2);
    }.toString();
    window.webkit.messageHandlers.nativeMethod.postMessage(person);
};

大家猜能不能打印出来 300,我们来试一下。


完蛋,找不到 arg1。。。。

怎么回事呢?

我们把一个 function 转换成 字符串之后,传给 Native,Native 在执行的时候,他的作用域已经变了,变成了 window,这个时候,window 下是没有 arg1 和 arg2 的,所以我们找不到。

如果我们这么做的话,确实是可以实现上述的需求的,但是,这样作用域就改变了,所有的变量都要定义为全局变量,函数要改为全局函数,以遍能够在回调中获取正确的变量。

这确实是一个可行的方法,但有没有更好的方法呢?H5 本来写的好好的,匿名函数写的 6 的飞起,干嘛都要改成全局变量,全局函数,要是这么写,我都不好意思给 H5 提需求让人家改。

我就想,能不能像 UIWebView 一样使用 JSCore,但是使用 JSCore 的话,我们要获取 JSContext,而 WKWebView 是运行在一个单独的进程中,我们是不可能进行应用间的通信的(目前我没发现,如果有的话,还请多多指教)。我就想,要不去扒一扒 WebKit 的源码,看看会有什么发现。


2.3.3 改下源码 ?

然后我就找啊找,终于找到了关键的方法

virtual void didPostMessage(WebKit::WebPageProxy& page, WebKit::WebFrameProxy& frame, const WebKit::SecurityOriginData& securityOriginData, WebCore::SerializedScriptValue& serializedScriptValue)
{
   @autoreleasepool {
       RetainPtr<WKFrameInfo> frameInfo = wrapper(API::FrameInfo::create(frame, securityOriginData.securityOrigin()));
    
       ASSERT(isUIThread());
       static JSContext* context = [[JSContext alloc] init]; //1. 创建一个 JSContext
    
       JSValueRef valueRef = serializedScriptValue.deserialize([context JSGlobalContextRef], 0);
       JSValue *value = [JSValue valueWithJSValueRef:valueRef inContext:context];
       id body = value.toObject; // 把 JS 的类型转为 OC 类型
    
       auto message = adoptNS([[WKScriptMessage alloc] _initWithBody:body webView:fromWebPageProxy(page) frameInfo:frameInfo.get() name:m_name.get()]); // 构造 message
  
       [m_handler userContentController:m_controller.get() didReceiveScriptMessage:message.get()]; // 调用代理对象,传递 message
   }
}

看到这里,我想,能不能把这个 JSContext 漏出来,这样的话,说不定还能想 UIWebView 和 JSCore 一样。但是转念一想,WKWebView 从 iOS 8 就出现了,现在到 iOS 11 了,难道都没想过如何解决回调这个问题么?难道苹果那帮开发都没发现么?怎么办,这不科学啊。


2.3.4 我有一个同学

其实,我们一开始就想错了。一直在想,如何把这个方法传过来,其实纵使能把一个 function 传过来,我们也没有办法去执行,因为我们能执行的只有一个字符串,而这个字符串执行后作用域肯定是会变的。所以,归根到底,这是 H5 的工作,我们做不了,想要支持回调,让 H5 自己去研究。我敢保证,你如果这么去给 H5 说,他追出去三条街,也要把砍你。


我们要先帮 H5 解决这个问题,我们才能去推动 H5 解决这个问题。

然而,我有一个同学,一个做 H5 的同学,@励志成为网红的网黄,在我苦苦思索不能解决的时候,我给他说了我的问题。然后我们就这个问题和看法进行了深入的探讨和交流。在达成了某些不可描述的交易之后,我们终于找到了一种解决办法。

他说,可以用 BroadcastChannel 来解决这个问题。

BroadcastChannel API 允许同一原始域和用户代理下的所有窗口,iFrames等进行交互。也就是说,如果用户打开了同一个网站的的两个标签窗口,如果网站内容发生了变化,那么两个窗口会同时得到更新通知。

然后进行了一波研究之后,发现 API 不支持。有兴趣的可以研究这个 API



然后,我们继续进行交易,好在,这次交易,取得了重大成功。
有一天,他在看 Vue 的源码时,发现了这么一个类 MessageChannel ,看起来可以解决这个问题。

官方文档上这么说

Channel Messaging API的MessageChannel接口允许我们创建一个新的消息通道,并通过它的两个MessagePort属性发送数据

它有两个端口,port1 和 port2,这两个端口可以互相发消息,可以互相监听,这样的话,我们是不是可以另辟蹊径来解决这个问题呢,我们来看下代码。

JS 代码

document.getElementById("li1").onclick = function () {
    const  arg1 = 100;
    const  arg2 = 200;
    _postMessage(person, 'nativeMethod').then((val) => {
      // 6.
      console.log(val);
      console.log(arg1 + arg2);
    })
};
    
function _postMessage(val, name){
   var channel = new MessageChannel(); // 创建一个 MessageChannel
   window.nativeCallBack = function(nativeValue) {
     // 3. 
     channel.port1.postMessage(nativeValue) 
   };
   // 1.
   window.webkit.messageHandlers[name].postMessage(val); 
   return new Promise((resolve, reject) => {
     channel.port2.onmessage = function(e){ 
         // 4
         var data = e.data;
         // 5.
         resolve(data); 
         channel = null;
         window.nativeCallBack = null;
     }
   })
}

我们封装了一个 _postMessage 方法,在这个方法中我们,返回了一个 Promise 对象,其实 JS 调用 Native 是一个异步操作,JS 调用客户端,等待客户端执行完毕,执行完毕后,告诉 JS,JS 在执行接下来的操作。

OC 代码


- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    
    if ([message.name isEqualToString:@"nativeMethod"]) {
       NSLog(@"body:%@, ", message.body);
       NSDictionary *dict = @{
           @"key1": @"value1",
           @"key2": @"value2"
       }; // 构造回传 js 数据
       id data = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingPrettyPrinted error:nil];
       NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; // 转为 json 字符串        
    
       // 2
       [_webView evaluateJavaScript:[NSString stringWithFormat:@"%@(%@)", @"nativeCallBack", jsonString] completionHandler:^(id _Nullable jsData, NSError * _Nullable error) {
           
       }];
       
    }
}

在 OC 代码中,我们构造一个 JSON ,然后执行 JS nativeCallBack(jsonString) ,把构造的 JSON 传给 JS。

注意上边代码的注释,我们来一步一步看,发生了什么。

  1. 把值传给 Native。
  2. Native 接受到之后,调用 JS 的 nativeCallBack 方法。
  3. 接收到 Native 调用之后,channel 的 port1 把 Native 的值转出去。
  4. channel 的 port2 接收到 port1 发送的值之后,在 prot2 的 onmessage 方法中接收。
  5. 执行 Promise 的 then,并把 data 传过去。
  6. then 接收到调用,执行里边的代码。

那到底能不能执行呢,我们运行一下试试


哈哈哈,果然和我们预料的一样,我只想说一句,


总结

上边啰嗦了这么多,其实很简单,利用 MessageChannel 端口转发功能来解决作用域改变的问题,JS 不用传递方法给 Native,Native 直接调用一个统一的全局方法就行。交互简单方便。