每個用過 UIWebView
的iOS開發(fā)者對其諸多的限制和有限的功能也深有感觸。悻然,自iOS8推出 WebKit 框架后將改變這一窘境。在本文我將會深入WebKit來體驗一下它給我們帶來的好處,同時也看看在iOS9中新加入的 SFSafariViewController 有些什么新的驚喜。
通用的瀏覽行為
所謂的通用瀏覽行為主要可以歸納為以下的幾種:
網(wǎng)頁載入進度
前進
后退
刷新
如果每個用到 WebView 的 app都要做一個專用的Controller也挺麻煩的,我以前就直接采用其它第三方寫好的包來完成。
但現(xiàn)在,如果用 WKWebView 將變得很方便,以代碼說話吧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
class ViewController: UIViewController { var webView: WKWebView! @IBOutlet weak var progressView: UIProgressView! required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! // 實例化 WKWebView self.webView = WKWebView(frame: CGRectZero) } override func viewDidLoad() { super.viewDidLoad() // 編程式加入 WKWebView view.addSubview(webView) view.insertSubview(webView, aboveSubview: progressView) webView.translatesAutoresizingMaskIntoConstraints = false let widthConstraint = NSLayoutConstraint(item:webView, attribute: .Width, relatedBy: .Equal, toItem: view, attribute: .Width, multiplier: 1 , constant: 0) view.addConstraint(widthConstraint) let heightConstraint = NSLayoutConstraint(item:webView,attribute: .Height, relatedBy: .Equal,toItem: view, attribute: .Height, multiplier:1, constant: -46) view.addConstraint(heightConstraint) // 檢測webView對象屬性的變化 webView.addObserver(self, forKeyPath: "loading" , options: .New, context: nil) webView.addObserver(self, forKeyPath: "title" , options: .New, context: nil) //加載網(wǎng)頁 let request = NSURLRequest(URL: NSURL(string: "http://ray.dotnetage.com" )!) webView.loadRequest(request) } override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) { if (keyPath == "loading" ) { // 檢測按鈕的可用性 forwardButton.enabled = webView.canGoBack backButton.enabled = webView.canGoBack stopButton.image = webView.loading ? UIImage(name: "Cross" ) : UIImage(named: "Syncing" ) } else if keyPath == "title" { title = webView.title } else if keyPath == "estimatedProgress" { progressView.hidden = webView.estimatedProgress == 1 progressView.setProgress(Float(webView.estimatedProgress), animated: true ) } } } |
這些代碼我覺得沒什么好說的,除了WKWebView不能通過 IB 來可視化構(gòu)建外,以上的代碼最多是將 Autolayout 部分的代碼優(yōu)化一下就是了。寫一寫,做個 Example 就懂了。
與 Javascript 通信
通過WebKit就不需要通過 javascript 橋的方式來與DOM通信了。其實這也不是什么新技術(shù),早再 windows98 在VB或者在Delphi中也可以通過COM接口用完全相類似的手法與DOM通信了。
廢話不多說,講講 WebKit 的基本原理吧。以下是 WebKit Host 的Web進程 與 App 主進程的通信關(guān)系示意圖:
這里包含兩個過程
執(zhí)行 javascript 腳本
我們可以將 javascript 腳本包含于 App 的 Bundle 內(nèi),作為應(yīng)用程序資源。在運行期將其通過 WebKit 注入至目標(biāo)網(wǎng)頁內(nèi)執(zhí)行。
首先我們要準(zhǔn)備一個目標(biāo)網(wǎng)頁,這里就以我自己的博客來做一個示例(http://ray.dotnetage.com )。在 App 中用WebKit打開是這樣的
現(xiàn)在,我就將我博客上首頁的大標(biāo)題的文字改掉,具體的代碼很簡單:
$(".page-header h1").text("iOS注入測試");
然后,在 iOS項目內(nèi)增加一個叫 inject.js
的腳本文件,將上述代碼復(fù)制其內(nèi)。
在 App 內(nèi)包含的 javascript 腳本最好先在瀏覽器的控制臺內(nèi)執(zhí)行一次,以確保腳本自身是可以被正確執(zhí)行的。如果腳本中含有潛在錯誤,在App內(nèi)是無法檢測得到的。
然后,在控制器的構(gòu)造函數(shù)內(nèi)創(chuàng)建一個 WKWebViewConfiguration
實例,并作為參數(shù)傳入 WKWebView
的構(gòu)造函數(shù),具體代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// ViewController.swift import WebKit class ViewController : UIViewController { var webView: WKWebView! required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! let configuation = WKWebViewConfiguration() configuation.userContentController.addUserScript(getUserScript( "inject" )) self.webView = WKWebView(frame: CGRectZero,configuration: configuation) } // 從資源中讀取 javascript 腳本 func getUserScript(fromName: String)-> WKUserScript { let filePath = NSBundle.mainBundle().pathForResource(fromName, ofType: "js" ) let js = try ! String(contentsOfFile: filePath!, encoding: NSUTF8StringEncoding) return WKUserScript(source: js, injectionTime: .AtDocumentEnd, forMainFrameOnly: true ) } ... } |
此代碼段中需要注意的另一點是在自定義方法 getUserScript()
所返回的 WKUserScript
對象。我們可以通過 injectionTime
決定將腳本注入至HTML的開始部分還是在文檔的尾部。
再次執(zhí)行代碼,效果如下:
也就是說我們可以在 app 內(nèi)通過 WebKit 注入javascript后就可以任意地操控頁面內(nèi)的所有 DOM 對象!
javascript 的回調(diào)
除了從 app 一端將代碼注入到瀏覽器,執(zhí)行一個動作。某些情況下我們還需要從網(wǎng)頁上做某一些處理后,例如將網(wǎng)頁內(nèi)的某些元素讀出并轉(zhuǎn)為一個 json 對象集合,回傳給 App 處理。又或者我們的 app 在加載一個網(wǎng)頁之后想一次性地讀出頁面內(nèi)的所有圖像,當(dāng)用戶點擊這些圖像的時候我們用 app 的本地方式來全屏預(yù)覽,諸如此類。在這些語境下:
我們都得從網(wǎng)頁內(nèi)返回對象
也就是說,在網(wǎng)頁的進程內(nèi)要向 app 進程通信,那么我們就需要在腳本中使用:
webkit.messageHandlers.{MessageName}.postMessage([params]);
這個方法在標(biāo)準(zhǔn)的HTML5瀏覽器是不能直接執(zhí)行的,例如 Chrome和 Safair。只有通過 WebKit Host 的頁面才會出現(xiàn)這個 webkit
對象。 這并不難理解,只是 WebKit 在加載頁面后向 windows 注入了 webkit
這個實例,使得 javascript 可以通過它來向 app 發(fā)送信息。
如果我們要向 app 發(fā)送一個信息,例如:在頁面上的一個按鈕被點擊后,執(zhí)行 app 內(nèi)打開相冊的代碼,那么就得先在 javascript 上寫好這樣的代碼:
1
2
3
|
$( "#mybutton" ).click( function (){ webkit.messageHandlers.openPhotoLibrary.postMessage(); }); |
請留意 openPhotoLibrary
這個對象在Swift是沒有,當(dāng)這個方法被回傳到 Swift 的時候這只是一個消息的名字,而在Swift中要接收這種來至于瀏覽器發(fā)送的信息我們的控制器就需要實現(xiàn) WKScriptMessageHandler
這個接口,它只有一個方法,我們多花些篇幅直接將這個接口的代碼打開:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
/*! A class conforming to the WKScriptMessageHandler protocol provides a method for receiving messages from JavaScript running in a webpage. */ public protocol WKScriptMessageHandler : NSObjectProtocol { /*! @abstract Invoked when a script message is received from a webpage. @param userContentController The user content controller invoking the delegate method. @param message The script message received. */ @available(iOS 8.0, *) public func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) } |
那么,我們就直接實現(xiàn)這個接口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class ViewController: UIViewController, WKScriptMessageHandler { required init(coder aDecoder: NSCoder) { // ... 之前的代碼同上 configuation.userContentController.addScriptMessageHandler(self, name: "openPhotoLibrary" ) self.webView = WKWebView(frame: CGRectZero,configuration: configuation) } ... func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) { if message.name == "openPhotoLibrary" { // 這里就可以加入打開相冊的代碼了 } } } |
從代碼就可以看出原理的一二:
在構(gòu)造 WKWebView
之前要用 addScriptMessageHandler
方法向配置對象注冊一個消息名,這里的例程是 "openPhotoLibrary"。
實現(xiàn) WKScriptMessageHandler
接口,從 userContentController()
方法的 message.name
參數(shù)中判斷消息的源頭,執(zhí)行對應(yīng)的代碼。
另外,如果我們需要從javascript腳本中向 app 傳入對象,可以直接在 postMessage()
方法內(nèi)將對象作為參數(shù)輸入,但通常這個參數(shù)的類型應(yīng)該是一個數(shù)組或者是普通的JSON對象,這樣在 app 才能用字典對象將其從新讀出。
例如,我從當(dāng)前網(wǎng)頁中將所有的菜單的地址和名稱讀出,并生成了一個 menus
的 javascript 數(shù)組對象:
1
2
3
4
5
6
7
8
|
var menus = $( ".navbar a" ).map(function(n,i){ return { title: $(n).text, link: $(n).attr( "href" ) }; }); webkit.messageHandlers.didFetchMenus.postMessage(menus); |
這里就略過接口實現(xiàn),直接看 userContentController
方法實現(xiàn):
1
2
3
4
5
6
7
8
9
10
11
12
|
var menus: [Menus]? func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) { if message.name == "didFetchMenus" { if let resultArray = message.body as? [Dictionary<String,String>] { menus = resultArray.map{ Menu(dict: $0) } // 這里就取出并將JSON轉(zhuǎn)換為 Swift 的Menu對象了 print(menus) } } } |
iOS9 中的 Safair 瀏覽器
在 iOS9 中加入了 SafariServices
這個新的模塊,其作用就是提供了一個全功能的內(nèi)嵌式 Safair,通過
SFSafariViewController
就能像普通的 控制器那樣使用。
以下是一個簡單的例子
1
2
3
4
5
6
7
8
9
10
|
import UIKit import SafariServices class ViewController: UIViewController { @IBAction func openBrowser(sender: AnyObject) { let safari = SFSafariViewController(URL:NSURL(string: "http://www.apple.com" )!) self.showViewController(safari, sender: self) } } |
SFSafariViewController
和 WebKit 的最大區(qū)別是 SFSafariViewController
沒有什么可控制方法,只是一個可以完全嵌入到 app 中的一個控制器,避免了像以前那樣如果打開一個外部鏈接要跳出當(dāng)前的app,而且 SFSafariViewController
嵌入的 Safari 和 Safari 內(nèi)的所有功能是一樣的,同樣支持 3D Touch 和切頁的等特色功能。且當(dāng)我們的 app 采用外部網(wǎng)絡(luò)帳號進行集成登錄時,Safari 能更直接獲取到當(dāng)前 app的應(yīng)用上下文,而無須再跳出重新在外部登入后再返回至App。這無疑是大大地增強了 app 在與 Safari 集成的時的使用體驗。
在 Apple 的開發(fā)者網(wǎng)站上對 WebKit 與 SafariServices 的選擇上給出了這樣的意見:
如果需要與網(wǎng)頁交互則選擇 WebKit
如果需要與Safari具有同樣的使用體驗且不需要與網(wǎng)頁交互推薦使用 SafariServices
這確實是一項很不錯的更新。