UIWebView代碼注入時機與姿式

 

一個奇怪的業務場景,引起的胡亂思考javascript

問題其實不難解決,只是順着這個問題,發散出了一些有意思的東西css

本文旨在討論UIWebView,WKWebView有本身的機制,不用這麼費勁html

咱們的業務最大的最重要的流量仍是在PC與WAP,也就是說主要業務仍是以Web的形式進行開發的,WAP上不少活動/頁面/功能,他們不是由APP的H5團隊主導開發的,也不在APP總體的規劃功能內,但常常會以所謂低成本的形式接入APP嘗試,快速的也在APP裏進行傳播。(後續驗證可行和有效後,也會歸入APP的功能規劃裏,以最流暢的體驗進行呈現)前端

但這樣會有一些問題,純爲WAP開發的頁面,直接扔到APP的WebView裏表現並很差java

WAP的團隊開發出來的界面通常長這樣
15049394419512ios

若是這樣的頁面不作任何處理直接在APP中低成本接入會變成這樣git

15049394419512

問題就在於APP是有本身的NavigationBar的,而WAP的頁面通常都爲瀏覽器而生,瀏覽器沒有本身的導航條因而WAP的團隊很天然都會在WAP頁面裏開發出一個導航條,若是這個頁面不作任何針對APP的處理,直接放入APP的WebView中,就會出現這樣醜陋的雙導航條,一個是native App本身的,一個是WAP網頁本身畫的github

這是一個很是常見的場景web

想要實現也很是簡單後端

WAP識別APP的UA,進行定製化的開發就行了

爲何說他奇怪?

團隊不一樣,業務場景不一樣,也面臨不同的問題,對於咱們來講,這個問題不在於如何實現,而在於如何作到讓WAP開發最省事。由於背景交代過了,WAP的前端團隊和APP徹底不是一撥人,若是能有什麼辦法讓WAP前端團隊在開發工做中儘可能的無感知,儘可能的少操做,不須要WAP團隊在開發的時候人工的判斷UA,選擇性渲染,因而蛋疼的問題來了

  • 直接讓WAP開發人員定製開發
    • 後端渲染的時候判斷UA
    • 前端模板隱藏UI

現有老的開發模式就是這樣,每次都是人工適配,純體力活,有時候項目緊急WAP團隊就會忘了,上線的時候一發現,咦?在App裏好醜啊,雖然改動很小,但一塊後端斷定UA,一塊前端模板選擇渲染,代碼分散在幾處,改起來很麻煩

單純是Bar的話不是問題,寫進WAP基類就行,問題是相似的場景看業務功能,有時候不止是Bar,會有定製化的東西,在APP裏表現,不能和WAP同樣

  • WAP的編譯框架支持
    • 這確實是可行的,而且是很好的解決方案之一
    • 廠裏的前端使用的是FIS的編譯打包框架,支持必定的插件擴展,能夠在前端代碼編譯環節,就自動加入UA判斷,對特定的UI,進行有規律的渲染控制

這個太底層了,對天天幾千萬UV的WAP來講,進行這麼大的改動,風險高,收益低(畢竟這個界面適配APP只是摟草打兔子捎帶手)有點難推進,後續確實能夠嘗試一下

  • WAP的JS插件支持
    • 基礎模板引入JS腳本
    • 用JS腳本在client裏判斷UA
    • 提取特定Dom
    • 隱藏Dom

最大的問題在於,JS在client裏執行的時機,JS執行的時候,這個Dom已經被渲染出來了,當你判斷UA,要移除的時候,畫面那個bar會閃一下,總體效果是,整個頁面帶着bar加載出來了,可是會忽然閃一下bar消失

  • App在WebView裏注入CSS
    • 讓WAP只須要對須要隱藏的Dom作個標記好比XXWAPBAR(WAP只用寫幾個字母)
    • 在WAP瀏覽器裏,無感知,徹底不須要定製化開發
    • 在App WebView加載網頁的時候,注入額外的CSS,將含有XXWAPBAR標記的Dom隱藏

看起來靠譜,看起來是一種WAP開發人員幾乎不用管不用操心,也不會影響WAP,只在APP裏有獨有效果的設計,試試看

WebView注入

對於Hybrid App來講,向WebView裏面注入JS(CSS也是經過JS代碼的方式注入),是太常見的一件事情了,注入就是最多見的native to js的通訊方式

  • iOS
1
[self.webView stringByEvaluatingJavaScriptFromString:injectjs];
  • 安卓
1
webView.loadUrl("javascript:" + injectjs);

咱們注入這麼一行demo JS代碼試試看

1
2
3
4
var style = document.createElement('style');
//XXWAPBAR 是咱們的WAP頂部Bar的class標記
style.innerHTML = '.XXWAPBAR { display: none;}';
document.head.appendChild(style)

習慣性的在iOS的webViewDidFinishLoad,安卓的OnPageFinished的時機去注入這個JS,Run一下看看效果,納尼?仍是閃爍!看來是注入晚了,網頁已經渲染完了,這時候注入css,會像前面提到的client端隱藏dom同樣,畫面會閃爍一下,那咱們早一點,webViewDidStartLoadonPageStarted的時機注入?Run一下看看效果,納尼?完全沒反應?

WebView的JSContext

JSContext是Webkit裏面JavaScriptCore框架裏面的js上下文,其實就至關於一個WebView裏面的js運行時,也能夠理解爲JS運行環境,先拿iOS作個試驗

iOS的同窗想必都知道能夠用KVC的方式取出UIWebView的JSContext,那麼作一個試驗,分別在StartLoadFinishLoad的delegate裏打印一下JSContext

1
2
3
4
5
6
7
8
9
10
- (void)webViewDidStartLoad:(JSBridgeWebView *)webView {
JSContext* context =[self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
NSLog(@"%@",context);
}

- (void)webViewDidFinishLoad:(JSBridgeWebView *)webView
{
JSContext* context =[self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
NSLog(@"%@",context);
}

運行事後你就會發現,同一個webview的JSContext,在時機不一樣,他根本就不是一個JS上下文對象,地址都不同。相同的JS,運行在不一樣的JS環境裏,天然效果是徹底不同的。

每次WebView加載一個新Url的時候,都會丟掉舊的JS上下文,從新啓用一個新的JS環境新的JS上下文,所以你在webViewDidStartLoad的時候即使使用stringByEvaluatingJavaScriptFromString去注入js,也是把js代碼在舊的上下文中執行,當新的js上下文徹底不受任何影響,沒任何效果。

在資源加載的時候注入js

安卓的道理也是同樣的,所以咱們選擇OnPageFinished已經晚了,此時頁面已經渲染完了,再注入畫面會閃,選擇OnPageStarted實際上是早了,注入到錯誤的js上下文裏,等頁面開始加載,就啓用了新的js上下文,所以白注入了。

咱們得換一個事件,選一個恰到好處的事件回調,安卓的WebViewClient的onLoadResource事件,這個能夠知足咱們的需求,這個時間點新的js上下文已經生效,整個網頁處於加載資源的階段,還沒開始進行排版與渲染,此時加入恰好知足需求

運行一下,效果很是好,畫面打開的時候,頁面中就已經看不到那個Bar了

蛋疼的問題來了:

iOS的UIWebView沒有這個事件,UIWebView只有可憐的這4個事件

1
2
3
4
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
- (void)webViewDidStartLoad:(UIWebView *)webView;
- (void)webViewDidFinishLoad:(UIWebView *)webView;
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error;

UIWebView的其餘出路之一,NSURLProtocol

iOS平臺提供的NSURLProtocol是一個能夠Hook全部網絡請求的工具,不管是由WebView發起的,仍是直接由App發起的。

NSURLProtocolHybrid App相結合能夠碰撞出很是多的火花好比

  • 利用NSURLProtocol實現Web圖片Native緩存
    • UIWebView的緩存有統一上線,而且很差細粒度控制
    • Hook圖片請求,不走UIWebView的網絡請求,直接經過SDWebImage,進行fetch&cache圖片
  • 利用NSURLProtocol實現Hybrid Web頁面靜態資源本地包
    • css/js/image等靜態資源打包隨app下發
    • WebView發起請求的時候Hook,從app本地包中返回靜態資源
    • 加快網頁加載速度
    • 靜態資源本地包經過app的方式進行批量更新
  • 利用NSURLProtocol實現Web圖片native濾鏡處理能力
    • UIWebView發起圖片資源加載
    • Hook後由App下載圖片
    • App下載圖片後進行native濾鏡處理
    • App將濾鏡事後的圖片返回Web

怎麼使用NSURLProtocol我就很少說了,隨便搜搜你能搜出一大筐。

咱們這個場景也能夠利用這樣方式來實現,簡單的說,App就是經過Hook的方式,直接修改了WAP頁面的源代碼。但有2個選擇,能夠選擇修改css代碼,也能夠選擇直接修改html頁面

  • HookCss

每一個頁面都要加載不少CSS,通常咱們的WAP項目裏都有一些基礎模板通用css,假設是common.css

1.NSURLProtocol選擇性hook咱們本身域名下的common.css文件
2.經過iOS的字符串處理,給這個文件尾部增長css信息
3.和JS注入的代碼裏的css同樣.XXWAPBAR { display: none;}

1
NSString *newcontent = [NSString stringWithFormat:@"%@\n\n.XXWAPBAR { display: none;}\n",content];

這樣Run一下,效果很是好,畫面打開的時候,頁面中就已經看不到那個Bar了

  • HookHtml

若是不修改CSS,修改HTML也行,但這樣就不限定文件了,任意本身域名下的HTML

1.NSURLProtocol選擇性hook咱們本身域名下的任意HTML文件
2.經過iOS的字符串處理,給這個head標籤增長信息
3.給head標籤增長script子標籤
4.其實直接給head標籤直接增長style子標籤也能夠

1
2
3
4
5
NSString *newcontent = [content stringByReplacingOccurrencesOfString:@"<head>" withString:@"<head>\n<script type=\"text/javascript\">\n
var style = document.createElement('style');
style.innerHTML = '.wkWapX { display: none;}';
document.head.appendChild(style);
\n</script>\n"];

這樣Run一下,效果同樣,畫面打開的時候,頁面中就已經看不到那個Bar了

UIWebview的其餘出路之二,WebFrameLoadDelegate

這是一個黑科技

這個科技和KVC取JSContext同樣,都屬於UnDocumented API

WebView與JSContext在UIWebView上的困境

自從iOS 7推出JavaScriptCore,蘋果本意是開放這個框架,讓開發者根據本身的需求,本身獨立運行和開發腳本引擎,但不少人都想在UIWebView上使用JavaScriptCore裏很是方便的API快速的進行js與oc的互通,使用裏面的JSContext,拋棄以往iframe走shouldStartLoadWithRequest的delegate方式。

UIWebView是基於Webkit的,內部自然存在着一個javascriptcore,之前只是iOS沒對外開放,iOS7纔對外開放

但很惋惜,對於UIWebView看起來蘋果然是對它沒多少愛了,並無把JSContext暴露出來,拿到不到webview的JSContext,整個JSC的API也玩不起來,因而聰明的開發者利用KVC的方式仍是把它拿了出來

documentView.webView.mainFrame.javaScriptContext

說到底這仍是一個Undocumented Api,沒有記錄在合法蘋果開發者文檔與頭文件的一個Api,存在必定的風險,但即使如此,使用這個方式依然存在一個問題,也就是我上文強調過的WebView與JSContext的問題

每次WebView加載一個新Url的時候,都會丟掉舊的JS上下文,從新啓用一個新的JS環境新的JS上下文,所以你在webViewDidStartLoad的時候即使使用stringByEvaluatingJavaScriptFromString去注入js,也是把js代碼在舊的上下文中執行,新的js上下文徹底不受任何影響,沒任何效果。

你們在搜索javaScriptCore使用指南的時候,總能看到相似這樣的代碼,在OC中給JSContext直接注入對象or函數

1
2
3
4
5
6
7
// Use JSExport Protocol 將oc對象注入給js
context[@"ViewController"] = self

// 將oc的block,注入給JS當作函數
context[@"hello"] = ^(void) {
NSLog(@"hello world");
};

若是咱們基於這種模式來構建Hybrid Bridge,那麼將帶來很大的便利,最直觀的優點就是,這種bridge是同步直接return返回的

而之前iframe經過shouldStartLoadWithRequest的delegate方式想要返回,必須得異步,而且用js語句注入來執行回調,才能返回數據給js。

這種基於JSContext的同步Hybrid Bridge構建的時機若是是webViewDidFinishLoad就會存在一些問題,在loadfinish的時候,表明網頁中的js代碼已經執行完了,若是此時纔將bridge構建完畢,那麼loadfinish以前執行的js代碼是不可以使用jsbridge

若是咱們能捕獲到新JSContext剛建立的時機,那麼咱們就能搞事情

  • 好比建立這種同步jsBridge,是的任意js執行的時候都能有效jsBridge!
  • 好比解決咱們今天聊得場景問題,在新JS環境剛建立,網頁還沒開始排版和渲染的時候,注入CSS!

WebFrameLoadDelegate尋求突破

搜索和尋找中發現了這樣一個東西

TS_JavaScriptContext

簡單的說,這個開源庫也找到了一種UnDocumented API來準確捕捉到了新JSContext剛建立的時機,經過WebFrameLoadDelegate

WebFrameLoadDelegate這個詞隨便在網上一搜,你就能搜到API和OC/Swift代碼,但很惋惜,這個代碼僅限macOS

Apple關於WebFrameLoadDelegate的官方文檔URL

15049523772825

從這個官方文檔中你能夠發現比UIWebViewDelegate多不少的各類Webkit內核的事件

15049524279135

看到其中最重要的一個delegate沒?

webView:didCreateJavaScriptContext:forFrame:

沒錯就是他,意思是說,其實Webkit內核早就把這類事件都拋出來了,而且在macOS的SDK中把這些事件都暴露給了開發者,可是在iOS的SDK中,UIWebView的頭文件設計卻把這些事件都吞掉了,沒暴露出來,不讓開發者使用

按着蘋果的尿性,源碼裏通常都會這麼寫

1
2
3
if (_xxDelegate && [_xxDelegate respondsToSelector:@selector(webView:didCreateJavaScriptContext:forFrame:)]) {
[_xxDelegate webView:webView didCreateJavaScriptContext:ctx forFrame:frame];
}

若是蘋果把這個delegate給藏了起來,沒有寫進UIWebViewDelegate的Protocol裏,但咱們本身把這個函數實現了,按着蘋果的尿性,就應當能夠觸發

因而TS_JavaScriptContext這個項目就按着這個思路去嘗試而且真的成功了,他給NSObject添加了一個category,使得NSObject擁有了webView:didCreateJavaScriptContext:forFrame:的implement,所以respondsToSelector的斷定就會生效,從而咱們就拿到了JS環境的建立事件

15049532969678

既然已經拿到了正確的時機,後面注入JS就行了,效果槓槓的,

一些探討和猜想

到了這一步,單純的找到時機,已經能解決個人問題了,不過WebFrameLoadDelegate裏面的其餘事件讓我產生了很大的好奇心

Apple關於WebFrameLoadDelegate的官方文檔URL

從這裏能夠看到不少不少的事件,都是UIWebView裏沒有的,能夠說macOS下的WebKit框架對外暴露的Api,更加能窺視Webkit本來的運做機制以及事件週期

想要窺視更多Webkit也能夠看這個

ios UIWebview runtime header 用於私有api調用查看

其實Webkit整個都是開源的,網上也有不少教你本身下Webkit源碼,編譯Webkit的,看些個是最直接的,但畢竟太龐大了,頭疼看不進去,哈哈哈哈哈

我在以前的文章動態界面:DSL&佈局引擎中畫過這樣一個圖

而今天發現,在這圖裏面還須要補充不少環節,也就是html/css/js在被加載以前都發生了啥

淺談WebKit之WebCore篇

能夠看看這篇文章來學習一下,而後梳理一個大概的理解

  • 當webview跳轉了一個url
  • 會先交給Frameloader
  • 而後就會new Document啊
  • Load Resource啊(html/css/js)
  • 就會commit Document
  • 而後parse HTML
  • 生成Dom樹啦
  • 再排版 layout
  • 最後渲染 render

看了蘋果的WebFrameLoadDelegate文檔和那篇私有api調用查看,你會發現有不少forFrame的Api&Delegate,可見FrameLoader仍是很重要的一個環節

並且,經過TS_JavaScriptContext這個項目,我還發現一個有趣的現象,就是若是頁面中不包含任何的JS(不管是HTML中的JS代碼,仍是額外JS文件)那麼就徹底不會有webView:didCreateJavaScriptContext:forFrame:的事件被拋出來,能夠想象既然沒有JS代碼,要毛的JS引擎。

後記

其實一開始咱們聊的要注入CSS隱藏WAPUI的業務場景,已經不重要了。這麼總體review一下你會發現,客戶端解決方案裏只有安卓比較舒服,iOS UIWebView都不太盡如人意。並且換了WKWebView可能這些問題都不存在(恩,項目還沒用,沒深挖)

  • 前端解決
    • 定製開發(機械工做,繁瑣,沒意義)
    • 前端編譯框架(成本大,風險高,跨團隊)
  • 客戶端解決
    • 安卓onLoadResource時機注入(比較完美)
    • iOS NSURLProtocol改HTML源碼(感受並不很好)
    • iOS 非公開Api調用(可能有審覈風險)

一個奇怪的業務場景,引起的胡亂思考

可是這個奇怪的場景,和胡亂發散的思考,確實讓我多的瞭解了不少關於WebView內核的機制,這內核機制太龐大了,如今仍是靠發散思考和搜索查找進行學習,有時間和精力真的想好好看看,親自編譯一下Webkit的源碼,光是純純的源碼文本就20M呢,要想看進去還真是一個十足的挑戰

相關文章
相關標籤/搜索