JavaScript和Objective-C互動的那些事

發表於2016-08-28

注:此文只現在只推薦需要適配iOS7的同學讀,如果已經扔掉iOS7,強烈建議換用WKWebView。

互動的細節可以參考我寫的上一篇文章JavaScript和Objective-C互動的那些事。已經寫過互動了,為什麼相隔幾個月來還要在出一片續集呢?這是因為過去幾個月的使用的過程中出現了幾個深坑,在這裡特別強調一下。深坑主要包括記憶體管理和什麼時候注入互動物件才是合理的。

記憶體管理

記憶體洩露問題

在我的第一篇文章裡面注入的互動物件為控制器self,這樣JSContext環境引用控制器self,在退出控制器的時候,因為控制器selfJSContext引用而不釋放,而JSContext只有等控制器釋放了才能隨之釋放,所以就引起了迴圈引用,造成記憶體洩露。

解決辦法

關於這個問題說三種解決辦法。

  • 可以使用我參考文章中提到的,注入一箇中間的物件去互動,而不是直接使用控制器selfiOS與JS互動實戰篇(ObjC版),這樣可能需要在物件中在加一層代理,或者Block來進行和控制器之間的通訊。
  • 注入物件改為注入類[self class],這樣倒是可以防止記憶體洩露,但是所寫的代理方法就要改為類方法,全部使用類方法在實際開發中會帶來一些不便,也不會太好。
  • 使用Block進行互動替掉JSExport協議

合適時機注入互動物件

UIWebView什麼時機建立JSContext環境

什麼時候UIWebView會建立JSContext環境,分兩種方式,第一在渲染網頁時遇到去執行JavaScript程式碼。第二就是使用方法[webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]去獲取JSContext環境時,這時無論是否遇到,而且和遇到

我的錯誤做法

剛開始的時候,我是在- (void)webViewDidFinishLoad:(UIWebView *)webView中去注入互動物件,但是這時候網頁還沒載入完,JavaScript那邊已經呼叫互動方法,這樣就會調不到原生應用的方法而出現問題。後來我就改成在- (void)viewDidLoad中去注入互動物件,這樣倒是解決了上面的問題,但是同時又引起了一個新的問題就是在一個網頁內部點選連結跳轉到另一個網頁的時候,第二個頁面需要互動,這時JSContext環境已經變化,但是- (void)viewDidLoad僅僅載入一次,跳轉的時候,沒有再次注入互動物件,這樣就會導致第二個頁面沒法進行互動。當然你可以在- (void)viewDidLoad- (void)webViewDidFinishLoad:(UIWebView *)webView都注入一次,但是一定會有更優雅的辦法去解決此問題。

解決辦法

那麼互動物件到底該什麼時候注入呢?其實網上已有很好的解決辦法,就是在每次建立JSContext環境的時候,我們都去注入此互動物件這樣就解決了上面的問題。具體解決辦法參考了此開源庫UIWebView-TS_JavaScriptContext。關於這個開源庫,我說一點在- (void)webView:(id)unused didCreateJavaScriptContext:(JSContext*)ctx forFrame:(id)frame此方法中使用到代理方法parentFrame可能會被認為是私有API而遭拒,在Issues中有人提到。此開源庫的實現思路可以參考readme寫的很不錯,除了解決這個問題,也可以學習到一些思考問題的思路。有些不太願意讀英文的同學,我這裡也有中文版僅供參考。

UIWebView-TS_JavaScriptContext的readme譯文

我曾經做過很多的混合iOS應用,但是我不屑於承認。這些應用的一個主要痛點是通過web/native邊界(執行在UIWebView中的JavaScript與執行在App中的ObjectiveC之間)進行互動。

我們都知道官方只給出一種方法從ObjectiveC調到網頁裡,是通過stringByEvaluatingJavaScriptFromString方法。還有一種呼叫JavaScript的典型辦法是人為設定window.location去觸發UIWebView的代理方法shouldStartLoadWithRequest:。另一種常常使用到的技術是實現自定義的NSURLProtocol並攔截通過XMLHttpRequest發出的請求。

在iOS7中蘋果給出了一個公開的框架JavaScriptCore(WebKit的一部分),這個框架提供了簡單機制在ObjectiveC和JavaScript的環境中互相呼叫物件和方法。眾所周知,UIWebView建立在WebKit之上最終也是使用了JavaScriptCore,不幸的是蘋果沒有暴露一些途徑給我們去訪問這套框架。

可以使用KVC簡單粗暴的獲取這個深植於UIWebView內部官方文件卻未定義的屬性JSContext這篇部落格介紹了這個技術。當然,這個方法的主要缺點是他依賴UIWebView的內部構造。

我介紹一個可供選擇的方法去獲取UIWebViewJSContext。當然我的方法也不是官方的,可能被拒。我應該不會嘗試提交一個這樣的應用到AppStore。但它看來至少不那麼容易被拒,我認為它並沒有特別地依賴於UIWebView的內部結構不同於UIWebView自己用WebKit和JavaScriptCore。(這有個小警告,一會解釋)

基本原理是這樣的:WebKit用WebFrameLoadDelegate回撥與客戶端進行通訊就好像UIWebView傳達頁面載入事件通過他自己的UIWebViewDelegate。WebFrameLoadDelegate其中一個方法是webView:didCreateJavaScriptContext:forFrame:就像所有事件源,WebKit的程式碼去檢測他的代理是否實現了回撥方法,如果實現了就呼叫此方法。下面是WebKit的部分原始碼(WebFrameLoaderClient.mm)

證實在iOS,UIWebView內,不論任何物件實現WebKit的WebFrameLoadDelegate方法,並不是真的實現webView:didCreateJavaScriptContext:forFrame:所以WebKit從不會呼叫此方法。如果此方法存在於代理物件中,它將會被自動呼叫。

既然如此,在OC中有很多的辦法給現有的類和物件動態的增添一個方法。最簡單的辦法就是通過擴充套件。我給已有的類NSObject新增一個擴充套件去實現webView:didCreateJavaScriptContext:forFrame:方法。

的確,新增這個方法讓WebKit開始呼叫它,因為任何物件(包括UIWebView中的一些sink object)都繼承自NSObject,現在都實現了webView:didCreateJavaScriptContext:forFrame:這個方法。如果未來UIWebView內部的sink object實現了這個代理方法,那麼這個途徑就是失效因為我們自己實現的分類永遠不會被呼叫。

當我們的方法被WebKit呼叫的時候會傳給我們一個WebKit中的WebView(不是UIWebView),一個JavaScriptCore的JSContext物件和WebKit的WebFrame。因為沒有一個公開的WebKit框架的標頭檔案提供給我們,所以WebView和WebFrame對我們來說非常透明。但是JSContext正是我們尋找的,通過JavaScriptCore框架對我們來說完全是適用的。(在實際中,我最終在WebFrame中呼叫方法,作為一個最佳狀態)

問題現在就變成怎樣根據JSContext反找到對應的UIWebView。首先我嘗試使用WebView物件我們控制和沿著繼承的view去找到他擁有的UIWebView.但是後來證明這個物件是一些UIView的代理,並不是一個真正的UIView。並且因為他對我們來說是透明的,我也沒有打算使用它。

我的解決方案是迭代所有在app中所建立的UIWebViews(參考程式碼,我是怎麼樣做的)並且使用stringByEvaluatingJavaScriptFromString:去儲存一個token”cookie”在JavaScriptContext中,然後我在JSContext中查詢已經存在的這個token,如果他存在這個UIWebView就是我所要找的。

一旦我們有了JSContext我們就可以做一些很有趣的事情。我的測試App展示了我們怎樣對映ObjectiveC的blocks和物件到全域性名稱空間並且通過JavaScript訪問和呼叫它們。

總結

在使用一項技能的時候,一定要挖透,理解其原理,這樣在遇到問題的時候才能更從容的應對。這也算是給我的一點點啟示和教訓吧,更希望大家能引以為戒。關於readme的譯文,作者水平有限,歡迎指正。

參考

相關文章