11 May 2024

이전 글에서 웹페이지에서 전달한 Action을 처리하는 조건문의 구현이 계속 늘어나, 모든 기능을 포함하도록 된다는 것을 알 수 있었습니다.

이번 글에서는 WKWebView와 다른 도메인과의 강한 결합 관계를 피하기 위해 Plugin을 이용하여 기능을 확장하는 방법을 알아보려고 합니다.

Plugin

Plugin이란, 특정 기능을 수행하는 코드를 따로 분리하는 것을 의미합니다. Plugin을 이용하면 기능을 확장하거나, 기능을 수정할 때 기존 코드를 수정하지 않고도 기능을 추가할 수 있습니다.

웹페이지에서 전달하는 Action을 처리하는 조건문의 코드를 Plugin으로 분리하여, 기존 코드를 수정하지 않도고 새로운 Plugin을 추가함으로써 기능을 추가할 수 있습니다.

Plugin 구현

Plugin을 구현하기 전에, 웹페이지에서 iOS 앱으로 전달하는 전달하는 JSON 구조는 다음과 같습니다.

{
  "action": "action",
  "uuid": "uuid",
  "body": "body"
}

body는 String, Int, Bool, Array, Dictionary 등의 타입을 가지는 값으로 구성됩니다. Action에 따라 body의 구조가 달라지므로 Plugin에서 body를 파싱하는 방법을 구현해야 합니다.

Plugin은 Action을 Key로 사용하고, message를 넘겨받을 수 있도록 하는 callAsAction 메소드를 가지는 JSInterfacePluggable 프로토콜을 구현합니다.

// FileName : JSInterfacePluggable.swift

import WebKit

protocol JSInterfacePluggable {
  var action: String { get }
  func callAsAction(_ message: [String: Any], with: WKWebView)
} 

다음으로, Plugin을 관리하는 Supervisor를 만들고, 웹뷰로부터 특정 Action을 수행 요청을 받으면 Plugin의 callAsAction을 호출하도록 하는 기능을 구현합니다.

// FileName : JSInterfaceSupervisor.swift

import Foundation
import WebKit

/// Supervisor class responsible for loading and managing JS plugins.
class JSInterfaceSupervisor {
  var loadedPlugins = [String: JSInterfacePluggable]()

  init() {}
}

extension JSInterfaceSupervisor {
  /// Loads a single plugin into the supervisor.
  func loadPlugin(_ plugin: JSInterfacePluggable) {
    let action = plugin.action

    guard loadedPlugins[action] == nil else {
      assertionFailure("\(action) action already exists. Please check the plugin.")
      return
    }

    loadedPlugins[action] = plugin
  }

  /// Loads multiple plugins into the supervisor.
  func loadPlugin(contentsOf newElements: [JSInterfacePluggable]) {
    newElements.forEach { loadPlugin($0) }
  }
}

extension JSInterfaceSupervisor {
  /// Resolves an action and calls the corresponding plugin with a message and web view.
  func resolve(_ action: String, message: [String: Any], with webView: WKWebView) {
    guard
      let plugin = loadedPlugins[action],
      plugin.action == action
    else {
      assertionFailure("Failed to resolve \(action): Action is not loaded. Please ensure the plugin is correctly loaded.")
      return
    }

    plugin.callAsAction(message, with: webView)
  }
}

JSInterfaceSupervisorJSInterfacePluggable 프로토콜을 준수하는 Plugin을 관리하는 역할을 수행합니다. JSInterfacePluggable 프로토콜을 준수하는 Plugin을 loadPlugin 메소드를 이용하여 로드하고, WKWebView로부터 Action을 수행 요청을 받으면, resolve 메소드를 호출하여 Plugin을 호출합니다.

이제 웹페이지에서 전달받은 Action을 처리하는 조건문의 코드를 Plugin으로 분리합니다.

// Before

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
  // 메시지의 이름과 body 추출
  guard
    message.name == "actionHandler",
    let messageBody = message.body as? [String: Any],
    let action = messageBody["action"] as? String
  else { return }
  
  // Action에 따라 처리하는 switch 문
  switch action {
  case "loading": loading(body: messageBody)
  case "openCard": openCard(body: messageBody)
  case "payment": payment(body: messageBody)
  case "log": log(body: messageBody)
  default: break
  }
  ...
}

// After

private let supervisor = JSInterfaceSupervisor()

func set(plugins: [JSInterfacePluggable]) {
  supervisor.loadPlugin(contentsOf: plugins)
}

...

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
  // 메시지의 이름과 body 추출
  guard
    message.name == "actionHandler",
    let messageBody = message.body as? [String: Any],
    let action = messageBody["action"] as? String
  else { return }

  // Supervisor에게 Action을 수행 요청
  supervisor.resolve(action, message: messageBody, with: webView)
}

다음으로, 각 Action에 해당하는 Plugin을 만들어봅시다.

// MARK: - LoadingJSPlugin
class LoadingJSPlugin: JSInterfacePluggable {
  struct Info {
    let uuid: String
    let isShow: Bool
  }

  let action = "loading"

  func callAsAction(_ message: [String: Any], with webView: WKWebView) {
    guard
      let result = Parser(message)
    else { return }

    closure?(result.info, webView)
  }

  func set(_ closure: @escaping (Info, WKWebView) -> Void) {
    self.closure = closure
  }

  private var closure: ((Info, WKWebView) -> Void)?
}

private extension LoadingJSPlugin {
  struct Parser {
    let info: Info

    init?(_ dictonary: [String: Any]) {
      guard
        let uuid = dictonary["uuid"] as? String,
        let body = dictonary["body"] as? [String: Any],
        let isShow = body["isShow"] as? Bool
      else { return nil }

      info = .init(uuid: uuid, isShow: isShow)
    }
  }
}

// MARK: - PaymentJSPlugin
class PaymentJSPlugin: JSInterfacePluggable {
  struct Info {
    let uuid: String
    let paymentAmount: Int
    let paymentTransactionId: String
    let paymentId: String
    let paymentGoodsName: String
  }

  let action = "payment"

  func callAsAction(_ message: [String: Any], with webView: WKWebView) {
    guard
      let result = Parser(message)
    else { return }

    closure?(result.info, webView)
  }

  func set(_ closure: @escaping (Info, WKWebView) -> Void) {
    self.closure = closure
  }

  private var closure: ((Info, WKWebView) -> Void)?
}

private extension PaymentJSPlugin {
  struct Parser {
    let info: Info

    init?(_ dictonary: [String: Any]) {
      guard
        let uuid = dictonary["uuid"] as? String,
        let body = dictonary["body"] as? [String: Any],
        let paymentAmount = body["paymentAmount"] as? Int,
        let paymentTransactionId = body["paymentTransactionId"] as? String,
        let paymentId = body["paymentId"] as? String,
        let paymentGoodsName = body["paymentGoodsName"] as? String
      else { return nil }

      info = .init(
        uuid: uuid,
        paymentAmount: paymentAmount,
        paymentTransactionId: paymentTransactionId,
        paymentId: paymentId,
        paymentGoodsName: paymentGoodsName
      )
    }
  }
}

위와 같이 JSInterfacePluggable 프로토콜을 준수하는 Plugin을 만들고, Plugin를 생성하고, Closure를 주입한 뒤, JSInterfaceSupervisor에 Plugin을 등록하면 됩니다.

let loadingPlugin = LoadingJSPlugin()
let paymentPlugin = PaymentJSPlugin()
loadingPlugin.set { info, webView in 
  // Loading Action일 때 수행할 코드
}
paymentPlugin.set { info, webView in 
  // Payment Action일 때 수행할 코드
}

webViewManager.set(plugins: [loadingPlugin, paymentPlugin])

이제 웹페이지에서 Action을 수행하는 코드를 Plugin으로 분리했습니다. 추가되는 Action에 맞춰 Plugin을 만들고, 필요한 Plugin을 등록하는 방식으로 진행하면 됩니다.

이와 같은 방식은 웹페이지에서 전달하는 Action 뿐만 아니라, AppScheme, 채팅 등의 다른 방식에서도 Plugin 방식을 적용하여 코드를 더욱 쉽게 유지하고 관리할 수 있습니다.

정리

이번 포스팅에서는 웹페이지에서 Action을 수행하는 코드를 Plugin으로 분리하는 방법에 대해서 알아보았습니다.

웹페이지에서 전달받은 Action을 수행하는 코드를 Plugin으로 분리하는 방식은 코드를 더욱 쉽게 유지하고 관리할 수 있게 해주며, Action을 처리하는 코드를 더욱 간결하게 작성할 수 있습니다.

참고자료