John Suhr
3k
2021-02-11 16:38:42 작성 2021-02-11 17:20:09 수정됨
0
187

[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);
}


감사합니다

1
  • 댓글 0

  • 로그인을 하시면 댓글을 등록할 수 있습니다.