在混合式的 App 架構中,Swift 程式碼往往需要與 WKWebView 中的網頁互相交換資料。目前有兩種方式,一種是 iOS 8 開始就提供的技術,也就是常見的使用 evaluateJavaScript() 加上 WKScriptMessageHandler Protocol,另外一種是新的作法,使用 callAsyncJavaScript() 加上 WKScriptMessageHandlerWithReply Protocol,這種作法支援 JS 的 Promise 型態,讓網頁與 Swift 間的互動性更好。這篇文章要介紹的是新作法。
文章中的範例要做的事情是,按下網頁上的按鈕後將一個字串傳到 ViewController 中,然後將此字串轉成大寫後再傳回到網頁上。先把網頁內容設計好,如下。這裡的 JS 程式碼也可以透過 Swift 產生,但為了讓 Swift 的程式碼看起來不要太亂,所以將 JS 程式碼先寫好放在網頁裡。
<script>
const msg = 'Hello, World!'
window.onload = function() {
document.getElementById('button').onclick = function() {
// 下一行的 "myTopic" 名字任意取,但在 WKWebView 中要註冊
window.webkit.messageHandlers.myTopic.postMessage(msg)
.then(function(result) {
document.getElementById('status').innerHTML = result
})
.catch(function(reason) {
document.getElementById('status').innerHTML = reason
})
}
}
// 這個函數會從 Swift 中呼叫
function setLabelText(text) {
document.getElementById('label').innerHTML = text
}
</script>
<body>
<button id="button">click me</button>
<div id="label"></div>
<div id="status"></div>
</body>
接下來建立 Storyboard 專案,SwiftUI 專案當然也可以,但因為網頁要傳給 Swift 的資料會透過WKScriptMessageHandlerWithReply 中的某個 delegate 函數傳進來,因此只要知道如何在 SwiftUI 中使用 delegate 技術就可以開 SwiftUI 專案。但這裡簡單點,開 Storyboard 專案,然後把上面的網頁拖進專案中。
全部程式碼都寫在 ViewController.swift,不需要動到 Storyboard,因此排版簡單處理,如下。注意自訂的 initWebView() 函數中要註冊「myTopic」這個訊息處理名字,名稱任意取只要不重複或空字串即可,然後要讓 ViewController 符合 WKScriptMessageHandlerWithReply 協定。另外,在 contentWorld 這個參數中可填入 .defaultClient 與 .page 或是自訂一個字串,這是用來決定 WKWebView 所執行的 JS 程式碼的影響範圍是僅僅在 Swift 中(設定 .defaultClient),還是會擴及到原本的網頁上(設定 .page)。這是一個安全機制,避免 Swift 產生的 JS 程式碼破壞了原本網頁上的 JS 程式碼。這裡要設定 .page,這樣網頁上已經寫好的 myTopic.postMessage(msg) 這行程式碼才有作用。
import UIKit
import WebKit
class ViewController: UIViewController, WKScriptMessageHandlerWithReply {
private var web: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
initWebView()
let url = Bundle.main.url(forResource: "demo.html", withExtension: nil)
let request = URLRequest(url: url!)
web.load(request)
}
private func initWebView() {
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
// 別忘了這裡要註冊一個名字,例如 "myTopic",否則網頁不知道要怎麼呼叫
config.userContentController.addScriptMessageHandler(
self,
contentWorld: .page,
name: "myTopic"
)
let rect = CGRect(x: 10, y: 10, width: 300, height: 200)
web = WKWebView(frame: rect, configuration: config)
view.addSubview(web)
}
}
網頁傳資料到 ViewController
現在網頁上的資料會透過 window.webkit.messageHandlers.myTopic.postMessage(msg) 傳到 ViewController 的 userContentController(_:didReceive) 函數中,此函數定義在 WKScriptMessageHandlerWithReply 協定內。實作這個函數,其中參數 message 包含了JS 呼叫時的 訊息處理名稱(在 message.name 中)以及實際傳遞的資料內容(在 message.body 中),基本架構如下。
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) {
if message.name == "myTopic" {
}
return (nil, nil)
}
最後將收到的字串轉成大寫後送回網頁上就完成了。將上面這段程式碼填入適當的內容,這裡有兩個地方要注意,首先是呼叫的 setLabText() 函數已經存在於網頁中,因此 callAsyncJavaScript() 中的 contentWorld 必須設定為 .page。第二個要注意的地方是,此 delegate 函數結束時必須傳回一個 tuple 型態資料,這個型態會對映到 JS 的 Promise 型態,也就是 tuple 的第一筆資料會傳入到 Promise 的 resolve 函數(也就是成功),tuple 的第二筆資料會傳入到 Promise 的 reject 函數(也就是失敗)。
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) {
if message.name == "myTopic", let string = message.body as? String {
Task {
try await web.callAsyncJavaScript(
"setLabelText(text)",
arguments: ["text": string.uppercased()],
contentWorld: .page
)
}
return ("success", nil)
}
return (nil, "oops! something wrong")
}
執行看看,應該可以在網頁上看到下圖左的畫面。如果將網頁中要傳給 Swift 的資料改為陣列,例如 [1, 2, 3],應該會看到如下圖右的錯誤訊息。
最後補充ㄧ下,Swift 與網頁互動所需要的 JS 程式碼可以完全寫在 callAsyncJavaScript() 函數內,也就是可以直接在 Swift 中來操作網頁上的 DOM 元件,因此一個極端的例子是,網頁中完全沒有任何 JS 程式碼,所有的 JS 程式碼都寫在 Swift 裡,例如下面這段程式碼,這時 contentWorld 建議使用 .defaultClient 即可,因為所有的 JS 程式碼都不在網頁上執行,所以設定 .page 時反而執行範圍變太大了。
try await web.callAsyncJavaScript(
"""
let label = document.getElementById('label')
label.innerHTML = text
label.style.color = 'green'
""",
arguments: ["text": string.uppercased()],
contentWorld: .defaultClient
)
然後在 Storyboard 上添加一個 Button 元件,按下後要網頁送出一個訊息給 Swift,如下。最後記得也要將自訂的 initWebView() 函數中的 contentWorld 改為 .defaultClient。
@IBAction func onClick(_ sender: Any) {
Task {
try await web.callAsyncJavaScript(
"""
window.webkit.messageHandlers.myTopic.postMessage('hi')
.then(function(result) {
document.getElementById('status').innerHTML = result
})
.catch(function(reason) {
document.getElementById('status').innerHTML = reason
})
""",
contentWorld: .defaultClient
)
}
}