由于業(yè)務需要肖油,最近開發(fā)并總結了關于JavaScript
和原生app的交互的一些實現(xiàn)方式。
通常情況下,我們加載一個網(wǎng)頁胶背,然后使用WKWebView
展示它。WKWebView
自開發(fā)以來就提供了豐富的js和原生的交互能力喘先,比如為了能夠獲取網(wǎng)頁中的一些內(nèi)容钳吟,我們可以使用原生方法執(zhí)行JavaScript
代碼來實現(xiàn)信息抓取,然后再將結果回傳到原生環(huán)境窘拯。
至于js如何主動調(diào)用原生方法红且,并攜帶上原生app需要的結果,這里總結了以下幾種方式:
- 通過截獲js的跳轉請求涤姊,進入原生回調(diào)
- 通過執(zhí)行js函數(shù)得到返回結果暇番,回到原生方法
- 通過js腳本注入
- 官方支持:直接注入帶有消息發(fā)送機制的js腳本
- 間接注入帶有腳本URL的標簽,方便js代碼的動態(tài)修改
<br />
方法一:截獲js的跳轉請求
比如點擊網(wǎng)頁中一個鏈接砂轻,該鏈接會導致新頁面的加載奔誓。而WKWebView響應加載之前斤吐,會首先截獲這個行為搔涝,然后調(diào)用下面這個WKNavigationDelegate
代理方法,讓開發(fā)者來決定是允許還是取消該行為和措。
-[WKWebView webView:decidePolicyForNavigationAction:decisionHandler:]
比如這段js代碼:window.location.
庄呈,表示當前的webview將要跳轉到新的網(wǎng)址,如果實現(xiàn)了上面的代理派阱,該行為就會首先被WKWebView攔截下來诬留,只有經(jīng)過了開發(fā)者允許后,才會執(zhí)行新的跳轉請求贫母。
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
// 比如只允許加載https:
NSString *href = navigationAction.request.URL.absoluteString;
if ([href hasPrefix:@"https://"]) {
decisionHandler(WKNavigationActionPolicyAllow);
} else if ([href hasPrefix:@"http://"]) {
decisionHandler(WKNavigationActionPolicyCancel);
}
// ...
}
而這種機制也提供了一個js調(diào)用原生app的機會文兑。比如將js代碼改成:
window.location.href = 'myapp://get_info_' + json.toString()
并且傳遞一個json在鏈接中,那么objc就有機會截取到整個鏈接的信息腺劣,然后解析并取出其中的參數(shù)绿贞,順便取消WKWebView對該鏈接的加載行為,這樣就達到了從js傳遞給原生app信息的目的橘原。
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
NSString *href = navigationAction.request.URL.absoluteString;
if ([href hasPrefix:@"myapp://get_info_"]) {
// parse and get json, then ...
decisionHandler(WKNavigationActionPolicyCancel);
return;
}
decisionHandler(WKNavigationActionPolicyAllow);
}
注意如果從js傳遞的信息中帶有中文字符籍铁,那么這些字符首先會被轉義成為URL編碼后的格式涡上,才能進行跳轉請求。例如myapp://get_info_蘋果
就成了myapp://get_info_%e8%8b%b9%e6%9e%9c
這樣的信息拒名。而原生應用為了得到并且還原這些字符串吩愧,可以使用NSString的UTF8的解碼方式進行中文的還原。
<br />
方法二:通過執(zhí)行js函數(shù)得到返回結果
WKWebView本身自帶了執(zhí)行js的方法:
-[WKWebView evaluateJavaScript:completionHandler:]
該方法不會阻塞用戶在主線程的網(wǎng)頁交互增显,且保證回調(diào)block一定在主線程中雁佳。如果js代碼有返回值的話,那么返回值就可以在block里面獲取到同云。這就要求js的代碼以函數(shù)的形式甘穿,在該作用域中返回一個結果。
比如執(zhí)行以下代碼:
NSString *js = @"function greeting() { return 'hello'; }; greeting();";
[webView evaluateJavaScript:js completionHandler:^(id _Nullable callback, NSError * _Nullable error) {
NSLog(@"%@", callback); // 輸入hello
}];
可以通過這種方式梢杭,用js抓取網(wǎng)頁的信息温兼,將結果通過函數(shù)返回的形式,告訴原生app武契。
<br />
方法三:官方提供的js腳本注入和原生回調(diào)
這里提到的向WKWebView
進行js腳本注入和回調(diào)募判,其實是蘋果提供的自家支持,允許webView在展示一個網(wǎng)頁前咒唆,先運行指定的js腳本届垫,完成特殊的需求。然后在js的腳本中全释,可以通過發(fā)送消息(Post Message)的方法給原生app傳遞結果装处,而原生app只需要注冊一個消息處理機制(Message Handler)即能接收到來自js發(fā)送的消息。舉個例子浸船,我需要加載google.com
的時候妄迁,通過js代碼來獲取google的標題信息,把結果傳遞給app李命。
首先寫好js代碼:獲取網(wǎng)頁標題登淘,接著命名一個didFindTitle
消息,這里的didFindTitle
是自定義的封字,可以根據(jù)不同需求來定義成不同名字黔州,最后發(fā)送消息給app。
var title = document.getElementsByTagName("title")[0].textContent;
window.webkit.messageHandlers.didFindTitle.postMessage(title)
建議把js代碼單獨保存為文件形式阔籽,這里保存為get_title.js
文件流妻,加入到Xcode項目中,避免與原生代碼過度混淆笆制;然后需要創(chuàng)建一個WKUserScript
腳本對象绅这,作為配置信息添加到WKWebView
中去。
// 讀取js腳本
NSString *filePath = [NSBundle.mainBundle pathForResource:@"get_title" ofType:@"js"];
NSString *javaScript = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:nil];
// 生成WKUserScript對象
WKUserScript *script = [[WKUserScript alloc] initWithSource:javaScript
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
// 生成WKWebViewConfiguration配置信息
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
[configuration.userContentController addUserScript:script]; // 添加腳本
[configuration.userContentController addScriptMessageHandler:self name:@"didGetTitle"]; // 注冊消息處理器
// 生成帶有自定義配制的WKWebView
self.webView = [[WKWebView alloc] initWithFrame:frame configuration:configuration];
接下來需要實現(xiàn)WKScriptMessageHandler
協(xié)議的一個方法:
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
// 匹配消息名稱项贺,這里需要匹配的是didFindTitle
if ([message.name isEqualToString:@"didFindTitle"]) {
NSString *title = message.body; // 獲取發(fā)送的消息內(nèi)容
// ...
}
}
運行后君躺,在網(wǎng)頁顯示之前峭判,就能獲得網(wǎng)頁的標題了。
腳本注入時機
關于腳本注入的時機棕叫,只有WKUserScriptInjectionTimeAtDocumentStart
和WKUserScriptInjectionTimeAtDocumentEnd
可以選擇林螃,以下是文檔解釋:
- Start: Inject the script after the document element has been created, but before any other content has been loaded. 意思是網(wǎng)頁中的元素標簽創(chuàng)建剛出來的時候,但是還沒有內(nèi)容俺泣。該時機適合通過注入腳本來添加元素標簽等操作疗认。(注意:此時<head>和<body>等標簽都還沒有出現(xiàn))
- End: Inject the script after the document has finished loading, but before any subresources may have finished loading. 意思是網(wǎng)頁中的元素標簽已經(jīng)加載好了內(nèi)容,但是網(wǎng)頁還沒有渲染出來伏钠。該時機適合通過注入腳本來獲取元素標簽內(nèi)容等操作横漏。(如果注入的js代碼跟修改元素標簽有關的話,這就是合適的時機)
在demo中熟掂,我試圖在兩個時機分別在DOM樹中各插入一個元素缎浇,結果如下:
使用消息處理的注意事項
- 注意copy關鍵字
從文檔中可以看到,創(chuàng)建WKWebView
的時候赴肚,會傳入一個WKWebViewConfiguration
對象作為網(wǎng)頁的配置信息(沒有傳的話就是默認的配置)素跺,該配置對象在加入到webView以后會被其拷貝,意味著之后修改配置信息的話將會被webView無視誉券。
即便如此指厌,WKWebViewConfiguration
自己的屬性對象并沒有實現(xiàn)NSCopying
,比如WKUserContentController
踊跟,這就意味著它們的屬性是可以修改踩验,并且改完還是奏效的:
// 獲取WKWebView的配置,這里得到一個copy的對象
WKWebViewConfiguration *configuration = self.webView.configuration;
// 從copy來的配置中商玫,得到userContentController箕憾,這里的引用和copy前的配置引用的是同一個對象
WKUserContentController *userContentController = configuration.userContentController;
// 添加腳本等
[userContentController addUserScript:script];
[userContentController addScriptMessageHandler:self name:@"didFindTitle"];
這個例子說明WKUserContentController
對象可以在WKWebView
創(chuàng)建好以后再次添加新配置,不過官方還是建議使用WKWebView
的時候决帖,最好是先配置好了再使用厕九,而不是使用的時候修改配置蓖捶。
- 注意addScriptMessageHandler:導致的引用循環(huán)
addScriptMessageHandler:
方法會將作為參數(shù)的消息處理目標對象建立強引用關系地回,就像使用NSTimer
一樣,很容易陷入引用循環(huán)俊鱼。
[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"xxx"]
當然解決方案也很容易刻像,就像NSTimer
使用過后調(diào)用[timer invalidate]
一樣,調(diào)用相應的移除方法并闲,即可打破循環(huán)引用细睡。
[self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"xxx"];
<br />
動態(tài)修改js腳本
上面提到的js腳本,如果放在app的bundle中隨著發(fā)布版本來更新的話帝火,自然是可行的溜徙。但是哪個產(chǎn)品經(jīng)理不希望js腳本能即改即用(其實互聯(lián)網(wǎng)產(chǎn)品經(jīng)理希望任何東西都能支持動態(tài)配置~)湃缎,為了實現(xiàn)這個需求,可以有兩個方案:
- 每次檢查js的腳本更新蠢壹,如有更新嗓违,將更新后的js腳本下載到app,確保使用最新的腳本
- 在網(wǎng)頁中注入包含腳本URL的標簽图贸,讓網(wǎng)頁自己去加載最新的js腳本
第一種方案的實現(xiàn)蹂季,就是下載文件到設備的文件目錄,使用新文件替代舊文件疏日。
下面主要介紹第二種方案偿洁,通常網(wǎng)頁元素中會包含這樣的標簽:
<script type="text/javascript" src="xxx.js"></script>
意思是網(wǎng)頁會通過這里提供的URL路徑來加載腳本文件。這樣的話沟优,就可以不用直接注入實際的js代碼涕滋,而是注入一個包含腳本URL的script標簽,讓網(wǎng)頁通過URL的方式來獲取腳本挠阁,于是腳本的代碼就可以隨時動態(tài)修改了何吝,改完部署到服務器上,也無需改動URL地址鹃唯。當然了爱榕,如果載入自家前端做的網(wǎng)頁,那么這個標簽完全可以寫在網(wǎng)頁中坡慌,但如果是加載任何一個網(wǎng)頁的話黔酥,就只能通過注入的方式來進行統(tǒng)一處理。
舉例:比如前端寫好一個js文件洪橘,用于抓取網(wǎng)頁的標題跪者,如下:
function grabTitle() {
return document.getElementsByTagName("title")[0].textContent;
}
把該文件命名為grab_title.js
,然后上傳至服務器上熄求,假設訪問路徑定為https://your_host/grab_title.js
渣玲。至于為什么推薦https
而不是http
,因為瀏覽器會阻止向https的網(wǎng)頁中運行來自不安全的js弟晚,從而導致即使注入成功也不會執(zhí)行js代碼的窘境忘衍。
接下來實現(xiàn)iOS端,這里給WKUserScript
添加一個插入script標簽和屬性的擴展方法卿城,即創(chuàng)建和向網(wǎng)頁中插入script標簽枚钓。
typedef NS_ENUM(NSInteger, HTMLScriptTagPosition) {
HTMLScriptTagPositionHead, // 將節(jié)點插入在<head>中
HTMLScriptTagPositionBody, // 將節(jié)點插入在<body>中
};
@interface WKUserScript (ScriptURL)
// 通過傳入一個或者多個腳本URL,來生成一個WKUserScript對象
- (instancetype)initWithScriptURLs:(NSArray <NSString *> *)scriptURLs toPosition:(HTMLScriptTagPosition)position forMainFrameOnly:(BOOL)forMainFrameOnly;
@end
@implementation WKUserScript (ScriptURL)
- (instancetype)initWithScriptURLs:(NSArray <NSString *> *)scriptURLs toPosition:(HTMLScriptTagPosition)position forMainFrameOnly:(BOOL)forMainFrameOnly {
NSMutableString *javaScript = [NSMutableString string];
NSString *targetElement = nil;
switch (position) {
case HTMLScriptTagPositionHead: targetElement = @"head"; break;
case HTMLScriptTagPositionBody: targetElement = @"body"; break;
}
for (NSUInteger i = 0; i < scriptURLs.count; i++) {
NSString *scriptURL = scriptURLs[i];
// 創(chuàng)建<script>標簽
[javaScript appendFormat:@"var scriptElement%@ = document.createElement('script');", @(i)];
[javaScript appendFormat:@"scriptElement%@.setAttribute(\"type\", \"text/javascript\");", @(i)];
[javaScript appendFormat:@"scriptElement%@.setAttribute(\"src\", \"%@\");", @(i), scriptURL];
// 將<script>插入到<head>或者<body>中去
[javaScript appendFormat:@"var targetElement = document.getElementsByTagName('%@')[0];", targetElement];
[javaScript appendFormat:@"targetElement.appendChild(scriptElement%@);", @(i)];
}
return [self initWithSource:javaScript.copy
injectionTime:WKUserScriptInjectionTimeAtDocumentEnd
forMainFrameOnly:forMainFrameOnly];
}
@end
使用擴展方法創(chuàng)建WKUserScript對象瑟押,將其注入到網(wǎng)頁中即可:
NSString *scriptURL = @"https://your_host/grab_title.js";
NSString *requestURL = [scriptURL stringByAppendingFormat:@"?%@", @(arc4random() % 999)];
WKUserScript *script = [[WKUserScript alloc] initWithScriptURLs:@[requestURL] toPosition:HTMLScriptTagPositionBody forMainFrameOnly:YES];
如果注入成功搀捷,那么在網(wǎng)頁的<body>最后會出現(xiàn)一個新的節(jié)點<script type="text/javascript" src="https://your_host/grab_title.js?123"></script>
,這里特別在URL后面添加了一個隨機數(shù)多望,本意是為了防止下一次請求相同URL的時候嫩舟,瀏覽器會默認使用上次的緩存結果氢烘。當然你可以通過代碼將webview設為“無痕跡瀏覽”,或者手動清理緩存文件來實現(xiàn)每次都能下載并使用最新的grab_title.js
家厌,但是我感覺做法都不如直接用隨機數(shù)方案解決起來的方便威始。
當網(wǎng)頁加載完成后,會正常下載了定義好的grabTitle()
函數(shù)像街,這時候就可以通過webview執(zhí)行js方法
[webView evaluateJavaScript:@"grabTitle();" completionHandler:^(NSString *title, NSError * _Nullable error) {
NSLog(@"title = %@", title);
}];
以后前端更新了js文件黎棠,只要保持調(diào)用函數(shù)的名稱和返回值格式一致,那么iOS端就不用做升級維護,并且無需擔心因為緩存問題而無法使用最新的js腳本。當然這樣的腳本還是越輕量越好香椎。
注入腳本URL的注意事項
- 腳本中的js向原生app的回調(diào)方案可以是多樣化的,只需要事先約定好一個策略:可以是發(fā)送消息和消息處理随静;也可以是函數(shù)定義;還可以讓原生app截獲js跳轉請求等等吗讶。定好以后就統(tǒng)一規(guī)范燎猛,在未來的腳本更新中依然保持一致的回調(diào)方式
- script標簽位置通常在<head>或者<body>中,否則會有bug照皆。放在<head>中會優(yōu)先下載腳本重绷,同時阻塞后面的HTML元素解析,影響網(wǎng)頁加載速度膜毁;而放在<body>中則會等到頁面元素解析完成以后昭卓,才會加載腳本;這里有文檔列出了script其他的屬性可供參考
- 及時用瀏覽器的網(wǎng)頁檢查器來調(diào)試瘟滨,查看注入后的網(wǎng)頁元素是否符合預期候醒,注入后的js是否正常工作。方法是:
- 使用iPhone真機或模擬器運行app杂瘸,加載網(wǎng)頁倒淫,完成注入工作
- 保持該網(wǎng)頁在屏幕中的展示,不要退出败玉;如果是真機敌土,確保設備與Mac連接
- 在Mac上運行Safari,在菜單中選擇“開發(fā)” -> "xxx's iPhone"(真機) / "Simulator"(模擬器)-> 選擇該頁面(鼠標移動到該選項的時候绒怨,頁面會顯示高亮狀態(tài))
- 在網(wǎng)頁檢查器中纯赎,選擇“元素”可以查看網(wǎng)頁標簽和內(nèi)容,在“控制臺”可以調(diào)試js代碼
- 如果注入標簽成功南蹂,但是網(wǎng)頁卻沒有加載腳本代碼,看看是不是瀏覽器會阻止在
https
的網(wǎng)頁中加載不安全的http
的URL念恍。我本人就是在前端同事的幫助下六剥,使用Safari網(wǎng)絡檢查到了以下的警告:[blocked] The page at https://en.m.wikipedia.org/wiki/Main_Page was not allowed to run insecure content from http://ougg9cexh.bkt.clouddn.com/grab_title.js.
晚顷,所以建議使用https
的腳本URL
<br />
通過腳本注入來修改網(wǎng)頁
舉個例子,我加載一篇網(wǎng)頁文章疗疟,文章默認是Helvetica
字體该默,排版比較規(guī)范,但是我希望實際顯示成menlo
字體策彤,看起來更符合程序員的喜好栓袖,那么可以通過js腳本注入的方法,在網(wǎng)頁載入前店诗,改變CSS的樣式裹刮。腳本的格式如下:
var styleElement = document.createElement('style');
document.documentElement.appendChild(styleElement);
styleElement.textContent = 'body { font-family : menlo !important; };';
意思是通過js添加一個style標簽,來設置body的字體為menlo庞瘸,并且設置了高優(yōu)先級捧弃。如果執(zhí)行的話,下面這段結構就會加入到網(wǎng)頁元素中去擦囊。
<style>
body {
font-family : menlo !important;
};
</style>
有了腳本以后违霞,創(chuàng)建WKUserScript
等來實現(xiàn)注入,這里就不重復了瞬场,但是需要注意:既然是修改元素內(nèi)容买鸽,那么注入時機就得等到所有元素標簽加載完成后,即WKUserScriptInjectionTimeAtDocumentEnd
運行后贯被,不出別的問題的話癞谒,網(wǎng)頁上的字體就被修改了。
總結
以上分享了一些js向原生app交互的方式刃榨,總結起來可以這么說:
- js腳本可以保存在app中弹砚,還可以通過遠程下載,或URL鏈接的形式獲取
- js腳本可以通過
evaluate
方法執(zhí)行枢希,也可以首先被注入在頁面中桌吃,等需要的時候在執(zhí)行 - js腳本帶著參數(shù)調(diào)用原生app時,可以等著跳轉請求時被app截獲苞轿,可以通過函數(shù)返回值直接給到結果茅诱,也可以通過發(fā)送消息機制;根據(jù)不同的需求制定出不同方案就好
- js腳本在服務器端更新后搬卒,如果app使用URL的方式讀取腳本瑟俭,需要避免讀取本地的緩存結果
最后貼個demo地址。