[iOS] WKWebView에서 Native <-> Javascript간 Communication개선
iOS로 하이브리드 앱을 개발하면서 가장 불편했던 점이 webkit에 의한 네이티브 <-> 자바스크립트간의 통신이 매끄럽지 못하다는 점이었습니다.
webkit.messageHandlers.[Controller Name].postMessage(...arguments); 로 자바스크립트에서 네이티브로 요청을 보낼 수 있으나, 네이티브에서 응답할 때에는 직접 자바스크립트 함수를 호출하는 수 밖에 없습니다.
따라서 콜백 용도의 전역 함수를 선언하여 사용하였는데, 이렇게 하는 경우 코드의 파편화가 발생하고 코드 흐름이 끊기게 되어 코드가 지저분해지게 됩니다.
다행히 Promise API를 사용하면 타 비동기 함수처럼 네이티브 <-> 자바스크립트간 통신을 가능하게 할 수 있습니다.
먼저 MessageHandler 객체를 생성합니다. 함수는 postMessage, onSuccess, onFailure 세 함수로 구성됩니다.
const MessageHandler = (() => {
let number = 0 // 호출 순서를 기억할 번호
const holder = {} // Promise의 resolve, reject를 담을 객체
const tryParse = (parameters) => {
try {
return JSON.parse(parameters)
} catch (e) {
return parameters
}
}
// Javascript -> Native로 요청을 보낼 때 사용
const postMessage = (name, parameters = {}) => {
return new Promise((resolve, reject) => {
const sequence = ++number
parameters.metadata = {
sequence
}
try {
window.webkit.messageHandlers[name].postMessage(parameters)
} catch (e) {
reject(e)
return
}
holder[sequence] = {
resolve,
reject
}
})
}
// Native -> Javascript로 응답을 보낼 때 사용
const onSuccess = (sequence, parameters) => {
const handlers = holder[sequence]
if (!handlers) {
console.warn('already committed')
return
}
const { resolve } = handlers
resolve(tryParse(parameters))
delete holder[sequence]
}
// Native -> Javascript로 응답을 보낼 때 사용
const onFailure = (sequence, parameters) => {
const handlers = holder[sequence]
if (!handlers) {
console.warn('already committed')
return
}
const { reject } = handlers
const params = tryParse(parameters)
let message = 'request rejected'
if (params && params.message) {
message = params.message
}
reject(new Error(message))
delete holder[sequence]
}
return {
postMessage,
onSuccess,
onFailure
}
})()
// 충돌 방지를 위해 '_ios' namespace에 정의합니다
Object.definePropery(window, '_ios', {
value: { MessageHandler },
writable: false
})자바스크립트에서 postMessage 를 호출하면 네이티브에서 작업 후, onSuccess/onFailure 를 호출하여 응답하는 방식입니다.
swift단에서는 몇가지 사전 준비가 필요합니다. google/Promises를 사용하기 위해 Podfile에 라이브러리를 추가합니다
// in Podfile
...
pod 'SwiftPromises'
...자바스크립트 요청을 처리하기 위한 객체를 정의합니다
struct Metadata {
let sequence: Int
}
struct TaskError: Error {
let message: String
}
typealias TaskHandler = (_ parameters: [String: Any]) -> Promise<[String: Any]?>작업을 정의하고 WKWebView에 작업 이름을 추가합니다
import Promises
class WebViewController: UIViewController {
...
private var taskHandlers = [String: TaskHandler]()
...
override func viewLoaded() {
super.viewLoaded()
let webView = WKWebView(...)
// 처리하고자 하는 작업을 정의합니다
taskHandlers["test"] = { (parameters) in
Promise<[String: Any]?>(on: .global()) { (resolve, _) in
... some task
if success { // success
resolve(nil)
return
}
throw TaskError(message: "Illegal State Error!!") // failure
}
}
taskHandlers.keys.forEach {
webView.configuration.userContentController.add(self, $0) // 작업 이름을 추가합니다
}
}
}자바스크립트 요청에 응답하기 위한 함수를 정의합니다
extension WebViewController {
private func onSuccess(_ metadata: Metadata, _ payload: [String: Any]?) -> Void {
onComplete(metadata, "window._ios.MessageHandler", "onSuccess", payload)
}
private func onFailure(_ metadata: Metadata, _ payload: [String: Any]?) -> Void {
onComplete(metadata, "window._ios.MessageHandler", "onFailure", payload)
}
private func onComplete(_ metadata: Metadata, _ objectName: String, _ methodName: String, _ payload: [String: Any]?) -> Void {
var script = "\(objectName).\(methodName)(\(metadata.sequence));"
if let payload = payload,
let json = JSON.stringify(payload) {
script = "\(objectName).\(methodName)(\(metadata.sequence), '\(json)');"
}
webView?.evaluateJavaScript(script) { (_, error) in
Logger.debug(script)
if let error = error {
Logger.error(error.localizedDescription)
}
}
}
}스크립트 핸들러를 정의합니다
extension WebViewController : WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
let name = message.name
Logger.debug("name: \(name)")
guard let parameters = message.body as? [String: Any],
let metaJson = parameters["metadata"] as? [String: Any],
let sequence = metaJson["sequence"] as? Int else {
Logger.error("Insufficient Arguments")
return
}
let metadata = Metadata(sequence: sequence)
guard let taskHandler = taskHandlers[name] else {
let payload: [String: Any] = ["message": "handler with name '\(name)' not exists"]
onFailure(metadata, payload)
return
}
do {
let payload = try await(taskHandler(parameters))
onSuccess(metadata, payload)
} catch let error {
var message = "Unknown Error"
if let error = error as? TaskError {
message = error.message
} else {
message = error.localizedDescription
}
let payload: [String: Any] = ["message": message]
onFailure(metadata, payload)
}
}
}MessageHandler.js를 WKWebView에 인젝션합니다.
if let path = Bundle.main.path(forResource: "static/js/MessageHandler", ofType: "js") {
if let script = try? String(contentsOfFile: path, encoding: .utf8) {
webView.configuration.controller.addUserScript(
WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)
)
}
}이렇게 하면 정의된 작업을 Promise를 사용하듯 자연스럽게 네이티브와 Communication이 가능합니다.
try {
const response = await _ios.MessageHandler.postMessage('test', { ... });
console.log(response);
} catch (e) {
console.error(e.message);
}감사합니다