我們的小馬童鞋又發(fā)功了。最近打算將UIWebView替換成WKWebView,所以原來的Hybrid層需要?jiǎng)觿油粒●R小試牛刀。當(dāng)然遇到了一些問題,看看他是怎么一步步解決的吧。
蘋果在iOS 8中推出了 WKWebView
,這是一個(gè)高性能的 web 框架,相較于 UIWebView
來說,有巨大提升。本文將針對 WKWebView 進(jìn)行簡單介紹,然后介紹下如何和 JS 進(jìn)行愉快的交互。還望各位大佬不吝賜教。
本文分為兩大部分
- WKWebView 簡單介紹
- JS 交互
1 WKWebView
就目前移動開發(fā)趨勢來說,很多 APP 都會嵌套一些 H5 的應(yīng)用。H5 有一些 Native 無法比擬的優(yōu)勢,例如:更新快,不用發(fā)版,隨時(shí)上線等等。然而在 iOS 中, UIWebView 是及其難用的。隨著 iOS 8 的推出,Apple 重構(gòu)了 UIWebView,于是 WKWebView 橫空出世。
1.1 WKWebView VS UIWebView
根據(jù)官方文檔,我們來簡單對比一下 UIWebView 和 WKWebView,看看這兩個(gè)到底有什么區(qū)別
WKWebView | UIWebView | |
---|---|---|
內(nèi)存占用 | 小 | 大 且有內(nèi)存泄漏 |
加載速度 | 快 | 慢 |
與 JS 交互 | 易 | 難 (可與 JSCore 配合) |
幀率 | 60FPS | 掉幀 |
從文檔來看,二者區(qū)別還是很明顯的,但到底區(qū)別有多大的,我們用數(shù)據(jù)說話。打開京東,網(wǎng)易,新浪這三個(gè)網(wǎng)站,從打開時(shí)間和占用內(nèi)存上來比較一下,看誰能勝出。該測試在 2015款 MBP 上打開,使用 Xcode 9 GM 版,在 iPhone 8 Plus 上運(yùn)行
在內(nèi)存測試中發(fā)現(xiàn),UIWebView 占用內(nèi)存很不穩(wěn)定,在打開新浪的網(wǎng)站時(shí),最高內(nèi)存能飆升到 200m 后來慢慢回落到 160m 左右,但會上下波動。但 WKWebView 上就沒有這個(gè)問題。通過上述對比,不難看出,WKWebVeiw 要優(yōu)于 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;
}
- 初始化一個(gè) WKWebView,我們需要傳一個(gè)
WKWebViewConfiguration
對象,來對 WKWebView 進(jìn)行配置。 - 構(gòu)造一個(gè)請求。
- 加載這個(gè)請求。
只需要這三步,我們就可以使用一個(gè)高性能的 web 框架。是不是很贊!!!
關(guān)于 WKWebView 如何使用,這里就不做過多的詳細(xì)介紹了,網(wǎng)上這種文章太多了,大家可以自行翻閱。接下來我們說如何與 JS 交互。
2. JS 交互
WebVeiw 與 JS 交互是一個(gè)很古老的問題,如何與 JS 交互是一個(gè) WebVeiw 必須具備的能力,在 UIWebView 時(shí)代,我們可以通過攔截 URL 的方式來進(jìn)行交互,也可以通過 WebViewJavascriptBridge 來進(jìn)行交互,還可以配合 JSCore 來進(jìn)行交互。但是在 WKWebView 時(shí)代,由于它是在一個(gè)單獨(dú)的進(jìn)程中運(yùn)行,我們無法獲取到 JSContext,所以我們無法使用 JSCore 這個(gè)強(qiáng)大的框架來進(jìn)行交互,那我們怎么辦呢,且聽我一一道來。
2.1 Native 調(diào)用 JS
還記的上邊說的 WKWebViewConfiguration
么,在這個(gè)類里邊,有一個(gè)屬性
@property (nonatomic, strong) WKUserContentController *userContentController;
Native 和 H5 交互基本全靠這個(gè)對象, 在 WKWebVeiw 中,我們使用我們有兩種方式來調(diào)用 JS,
- 使用
WKUserScript
- 直接調(diào)用 JS 字符串
2.1.1 使用 WKUserScript
要想使用 WKUserScript,首先,我們要構(gòu)造一個(gè) WKUserScript 對象,構(gòu)造方法及其簡單,我們使用下邊代碼來創(chuàng)建一個(gè) WKUserScript 對象。
// source 就是我們要調(diào)用的 JS 函數(shù)或者我們要執(zhí)行的 JS 代碼
// injectionTime 這個(gè)參數(shù)我們需要指定一個(gè)時(shí)間,在什么時(shí)候把我們在這段 JS 注入到 WebVeiw 中,它是一個(gè)枚舉值,WKUserScriptInjectionTimeAtDocumentStart 或者 WKUserScriptInjectionTimeAtDocumentEnd
// MainFrameOnly 因?yàn)樵?JS 中,一個(gè)頁面可能有多個(gè) frame,這個(gè)參數(shù)指定我們的 JS 代碼是否只在 mainFrame 中生效
- initWithSource:injectionTime:forMainFrameOnly:
至此,我們已經(jīng)構(gòu)建了一個(gè) WKUserScript,然后呢,我們要做的就是要把它添加進(jìn)來
- addUserScript:
至此使用 WKUserScript 調(diào)用 JS 完成。
2.1.2 直接調(diào)用 JS 字符串
在 WKWebView 中,我們也可以直接執(zhí)行 JS 字符串
- (void)evaluateJavaScript: completionHandler:
我們通過調(diào)用這個(gè)方法來執(zhí)行 JS 字符串,然后在 completionHandler
中拿到執(zhí)行這段 JS 代碼后的返回值。
至此,Native 調(diào)用 JS 完成。是不是簡單到害怕
2.2 JS 調(diào)用 Native
在 WK 這套框架下,JS 調(diào)用 Native 簡直簡單到喪心病狂。還記的上邊那個(gè) WKUserContentController
,我們也是要通過它來進(jìn)行,而你所需要做的,只需要三步,需要三步,三步。
- 向 JS 注入一個(gè)字符串
[_webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeMethod"];
我們向 JS 注入了一個(gè)方法,叫做 nativeMethod
- JS 調(diào)用 Native
window.webkit.messageHandlers.nativeMethod.postMessage(value);
一句話調(diào)用,我們就可以在 Native 中接收到 value
- 接收 JS 調(diào)用
上邊我們調(diào)用 addScriptMessageHandler:name
的時(shí)候,我們要遵守 WKScriptMessageHandler
協(xié)議,然后實(shí)現(xiàn)這個(gè)協(xié)議。
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
NSString * name = message.name // 就是上邊注入到 JS 的哪個(gè)名字,在這里是 nativeMethod
id param = message.body // 就是 JS 調(diào)用 Native 時(shí),傳過來的 value
// TODO: do your stuff
}
完了,Native 調(diào)用 JS 就這么簡單,是不是喪心病狂,簡直簡單到不能再簡單了。
但是,你以為這么就完了么,上邊寫的這些東西在網(wǎng)上隨便一搜都有一大片,重新再寫一遍,貌似意義不是很大啊,怎么也得來點(diǎn)稍微不一樣的東西吧。
2.3 JS 調(diào)用 Native 后的回調(diào)
舉一個(gè)很常見的例子,假設(shè)我們有這么一個(gè)需求,我的 JS 要調(diào)用 Native 發(fā)一個(gè)網(wǎng)絡(luò)請求,Native 執(zhí)行完了,把請求數(shù)據(jù)回傳給 JS。
很簡單的一個(gè)需求,來,想想怎么執(zhí)行。
2.3.1 postMessage 的坑
可能很快就想到了,postMessage 的時(shí)候,直接把這個(gè)方法傳過去不就行了。一開始我也是這么做的。
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);
};
首先構(gòu)造一個(gè) person,然后我們給 person 增加一個(gè) callBack 屬性,然后傳進(jìn)去,運(yùn)行程序。打開 Safari 選擇 開發(fā)->模擬器,打開調(diào)試界面,然后我們點(diǎn)擊查看控制臺。
然后你會發(fā)現(xiàn),報(bào)錯(cuò)了,為什么呢,這一切都是因?yàn)?postMessag 這個(gè)方法。
打開 postMessage文檔 ,你會發(fā)現(xiàn),
message
將要發(fā)送到其他 window的數(shù)據(jù)。它將會被結(jié)構(gòu)化克隆算法序列化。這意味著你可以不受什么限制的將數(shù)據(jù)對象安全的傳送給目標(biāo)窗口而無需自己序列化
這個(gè) message 需要支持 結(jié)構(gòu)化克隆算法 。很遺憾,這個(gè)算法目前不支持傳遞 Function
和 Error
,它只支持一下幾種類型
對象類型 | 注意 |
---|---|
所有的原始類型 | 除了symbols |
Boolean | 對象 |
String | 對象 |
Date | |
RegExp | lastIndex 字段不會被保留。 |
Blob | |
File | |
FileList | |
ArrayBuffer | |
ArrayBufferView | 這基本上意味著所有的 類型化數(shù)組 ,比如 Int32Array 等等。 |
ImageData | |
Array | |
Object | 僅包括普通對象 (比如對象字面量 ) |
Map | |
Set |
說好的不受限制呢
2.3.2 function 轉(zhuǎn)為 字符串
那既然它不支持傳一個(gè) Function
,那我們就得另辟蹊徑了,String
總支持吧,我們把一個(gè)方法轉(zhuǎn)為字符串,然后傳到 Native,然后 Native 執(zhí)行這個(gè)字符串。貌似可行的,我們來試一下。
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"
}; // 構(gòu)造回傳 js 數(shù)據(jù)
id data = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingPrettyPrinted error:nil];
NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; // 轉(zhuǎn)為 json 字符串
[_webView evaluateJavaScript:[NSString stringWithFormat:@"(%@)(%@)", message.body[@"callBack"], jsonString] completionHandler:^(id _Nullable jsData, NSError * _Nullable error) {
}];
}
}
果然不出我們所料,我們可以直接得到這個(gè) Native 傳遞給 JS 的值。
但是,這個(gè)作用域會不會變化呢,我們在來改一下 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。。。。
怎么回事呢?
我們把一個(gè) function 轉(zhuǎn)換成 字符串之后,傳給 Native,Native 在執(zhí)行的時(shí)候,他的作用域已經(jīng)變了,變成了 window,這個(gè)時(shí)候,window 下是沒有 arg1 和 arg2 的,所以我們找不到。
如果我們這么做的話,確實(shí)是可以實(shí)現(xiàn)上述的需求的,但是,這樣作用域就改變了,所有的變量都要定義為全局變量,函數(shù)要改為全局函數(shù),以遍能夠在回調(diào)中獲取正確的變量。
這確實(shí)是一個(gè)可行的方法,但有沒有更好的方法呢?H5 本來寫的好好的,匿名函數(shù)寫的 6 的飛起,干嘛都要改成全局變量,全局函數(shù),要是這么寫,我都不好意思給 H5 提需求讓人家改。
我就想,能不能像 UIWebView 一樣使用 JSCore,但是使用 JSCore 的話,我們要獲取 JSContext,而 WKWebView 是運(yùn)行在一個(gè)單獨(dú)的進(jìn)程中,我們是不可能進(jìn)行應(yīng)用間的通信的(目前我沒發(fā)現(xiàn),如果有的話,還請多多指教)。我就想,要不去扒一扒 WebKit 的源碼,看看會有什么發(fā)現(xiàn)。
2.3.3 改下源碼 ?
然后我就找啊找,終于找到了關(guān)鍵的方法
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. 創(chuàng)建一個(gè) JSContext
JSValueRef valueRef = serializedScriptValue.deserialize([context JSGlobalContextRef], 0);
JSValue *value = [JSValue valueWithJSValueRef:valueRef inContext:context];
id body = value.toObject; // 把 JS 的類型轉(zhuǎn)為 OC 類型
auto message = adoptNS([[WKScriptMessage alloc] _initWithBody:body webView:fromWebPageProxy(page) frameInfo:frameInfo.get() name:m_name.get()]); // 構(gòu)造 message
[m_handler userContentController:m_controller.get() didReceiveScriptMessage:message.get()]; // 調(diào)用代理對象,傳遞 message
}
}
看到這里,我想,能不能把這個(gè) JSContext 漏出來,這樣的話,說不定還能想 UIWebView 和 JSCore 一樣。但是轉(zhuǎn)念一想,WKWebView 從 iOS 8 就出現(xiàn)了,現(xiàn)在到 iOS 11 了,難道都沒想過如何解決回調(diào)這個(gè)問題么?難道蘋果那幫開發(fā)都沒發(fā)現(xiàn)么?怎么辦,這不科學(xué)啊。
2.3.4 我有一個(gè)同學(xué)
其實(shí),我們一開始就想錯(cuò)了。一直在想,如何把這個(gè)方法傳過來,其實(shí)縱使能把一個(gè) function 傳過來,我們也沒有辦法去執(zhí)行,因?yàn)槲覀兡軋?zhí)行的只有一個(gè)字符串,而這個(gè)字符串執(zhí)行后作用域肯定是會變的。所以,歸根到底,這是 H5 的工作,我們做不了,想要支持回調(diào),讓 H5 自己去研究。我敢保證,你如果這么去給 H5 說,他追出去三條街,也要把砍你。
我們要先幫 H5 解決這個(gè)問題,我們才能去推動 H5 解決這個(gè)問題。
然而,我有一個(gè)同學(xué),一個(gè)做 H5 的同學(xué),@勵(lì)志成為網(wǎng)紅的網(wǎng)黃,在我苦苦思索不能解決的時(shí)候,我給他說了我的問題。然后我們就這個(gè)問題和看法進(jìn)行了深入的探討和交流。在達(dá)成了某些不可描述的交易之后,我們終于找到了一種解決辦法。
他說,可以用 BroadcastChannel 來解決這個(gè)問題。
BroadcastChannel API 允許同一原始域和用戶代理下的所有窗口,iFrames等進(jìn)行交互。也就是說,如果用戶打開了同一個(gè)網(wǎng)站的的兩個(gè)標(biāo)簽窗口,如果網(wǎng)站內(nèi)容發(fā)生了變化,那么兩個(gè)窗口會同時(shí)得到更新通知。
然后進(jìn)行了一波研究之后,發(fā)現(xiàn) API 不支持。有興趣的可以研究這個(gè) API
然后,我們繼續(xù)進(jìn)行交易,好在,這次交易,取得了重大成功。
有一天,他在看 Vue
的源碼時(shí),發(fā)現(xiàn)了這么一個(gè)類 MessageChannel ,看起來可以解決這個(gè)問題。
官方文檔上這么說
Channel Messaging API的MessageChannel接口允許我們創(chuàng)建一個(gè)新的消息通道,并通過它的兩個(gè)MessagePort屬性發(fā)送數(shù)據(jù)
它有兩個(gè)端口,port1 和 port2,這兩個(gè)端口可以互相發(fā)消息,可以互相監(jiān)聽,這樣的話,我們是不是可以另辟蹊徑來解決這個(gè)問題呢,我們來看下代碼。
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(); // 創(chuàng)建一個(gè) 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;
}
})
}
我們封裝了一個(gè) _postMessage 方法,在這個(gè)方法中我們,返回了一個(gè) Promise 對象,其實(shí) JS 調(diào)用 Native 是一個(gè)異步操作,JS 調(diào)用客戶端,等待客戶端執(zhí)行完畢,執(zhí)行完畢后,告訴 JS,JS 在執(zhí)行接下來的操作。
OC 代碼
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"nativeMethod"]) {
NSLog(@"body:%@, ", message.body);
NSDictionary *dict = @{
@"key1": @"value1",
@"key2": @"value2"
}; // 構(gòu)造回傳 js 數(shù)據(jù)
id data = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingPrettyPrinted error:nil];
NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; // 轉(zhuǎn)為 json 字符串
// 2
[_webView evaluateJavaScript:[NSString stringWithFormat:@"%@(%@)", @"nativeCallBack", jsonString] completionHandler:^(id _Nullable jsData, NSError * _Nullable error) {
}];
}
}
在 OC 代碼中,我們構(gòu)造一個(gè) JSON ,然后執(zhí)行 JS nativeCallBack(jsonString)
,把構(gòu)造的 JSON 傳給 JS。
注意上邊代碼的注釋,我們來一步一步看,發(fā)生了什么。
- 把值傳給 Native。
- Native 接受到之后,調(diào)用 JS 的 nativeCallBack 方法。
- 接收到 Native 調(diào)用之后,channel 的 port1 把 Native 的值轉(zhuǎn)出去。
- channel 的 port2 接收到 port1 發(fā)送的值之后,在 prot2 的 onmessage 方法中接收。
- 執(zhí)行 Promise 的 then,并把 data 傳過去。
- then 接收到調(diào)用,執(zhí)行里邊的代碼。
那到底能不能執(zhí)行呢,我們運(yùn)行一下試試
哈哈哈,果然和我們預(yù)料的一樣,我只想說一句,
總結(jié)
上邊啰嗦了這么多,其實(shí)很簡單,利用 MessageChannel 端口轉(zhuǎn)發(fā)功能來解決作用域改變的問題,JS 不用傳遞方法給 Native,Native 直接調(diào)用一個(gè)統(tǒng)一的全局方法就行。交互簡單方便。