如何與 WKWebView 網頁互動

在混合式的 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],應該會看到如下圖右的錯誤訊息。

wkwebview_swift_result

最後補充ㄧ下,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
        )
    }
}

發表迴響