UIWebView程式碼注入時機與姿勢

折騰範兒_味精發表於2017-09-09

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

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

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

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

但這樣會有一些問題,純為WAP開發的頁面,直接扔到APP的WebView裡表現並不好

WAP的團隊開發出來的介面一般長這樣

15049394419512
15049394419512

如果這樣的頁面不做任何處理直接在APP中低成本接入會變成這樣

15049394419512
15049394419512

問題就在於APP是有自己的NavigationBar的,而WAP的頁面一般都為瀏覽器而生,瀏覽器沒有自己的導航條於是WAP的團隊很自然都會在WAP頁面裡開發出一個導航條,如果這個頁面不做任何針對APP的處理,直接放入APP的WebView中,就會出現這樣醜陋的雙導航條,一個是native App自己的,一個是WAP網頁自己畫的

這是一個非常常見的場景

想要實現也非常簡單

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
[self.webView stringByEvaluatingJavaScriptFromString:injectjs];複製程式碼
  • 安卓
webView.loadUrl("javascript:" + injectjs);複製程式碼

我們注入這麼一行demo JS程式碼試試看

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

- (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個事件

- (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;}

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子標籤也可以

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函式

// 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
15049523772825

從這個官方文件中你可以發現比UIWebViewDelegate多很多的各種Webkit核心的事件

15049524279135
15049524279135

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

webView:didCreateJavaScriptContext:forFrame:

沒錯就是他,意思是說,其實Webkit核心早就把這類事件都丟擲來了,並且在macOS的SDK中把這些事件都暴露給了開發者,但是在iOS的SDK中,UIWebView的標頭檔案設計卻把這些事件都吞掉了,沒暴露出來,不讓開發者使用

按著蘋果的尿性,原始碼裡一般都會這麼寫

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
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呢,要想看進去還真是一個十足的挑戰

相關文章