|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "[iOS] WKWebView을 이용한 iOS 앱과 웹페이지 간의 통신 (3) - Plugin을 이용하여 기능 확장하기" |
| 4 | +tags: [iOS, WKWebView, Javascript, if, switch, statement, Plugin] |
| 5 | +--- |
| 6 | +{% include JB/setup %} |
| 7 | + |
| 8 | +이전 글에서 웹페이지에서 전달한 Action을 처리하는 조건문의 구현이 계속 늘어나, 모든 기능을 포함하도록 된다는 것을 알 수 있었습니다. |
| 9 | + |
| 10 | +이번 글에서는 `WKWebView`와 다른 도메인과의 강한 결합 관계를 피하기 위해 Plugin을 이용하여 기능을 확장하는 방법을 알아보려고 합니다. |
| 11 | + |
| 12 | +## Plugin |
| 13 | + |
| 14 | +Plugin이란, 특정 기능을 수행하는 코드를 따로 분리하는 것을 의미합니다. Plugin을 이용하면 기능을 확장하거나, 기능을 수정할 때 기존 코드를 수정하지 않고도 기능을 추가할 수 있습니다. |
| 15 | + |
| 16 | +웹페이지에서 전달하는 Action을 처리하는 조건문의 코드를 Plugin으로 분리하여, 기존 코드를 수정하지 않도고 새로운 Plugin을 추가함으로써 기능을 추가할 수 있습니다. |
| 17 | + |
| 18 | +### Plugin 구현 |
| 19 | + |
| 20 | +Plugin을 구현하기 전에, 웹페이지에서 iOS 앱으로 전달하는 전달하는 JSON 구조는 다음과 같습니다. |
| 21 | + |
| 22 | +```json |
| 23 | +{ |
| 24 | + "action": "action", |
| 25 | + "uuid": "uuid", |
| 26 | + "body": "body" |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +body는 String, Int, Bool, Array, Dictionary 등의 타입을 가지는 값으로 구성됩니다. Action에 따라 body의 구조가 달라지므로 Plugin에서 body를 파싱하는 방법을 구현해야 합니다. |
| 31 | + |
| 32 | +Plugin은 Action을 Key로 사용하고, message를 넘겨받을 수 있도록 하는 `callAsAction` 메소드를 가지는 `JSInterfacePluggable` 프로토콜을 구현합니다. |
| 33 | + |
| 34 | +```swift |
| 35 | +// FileName : JSInterfacePluggable.swift |
| 36 | + |
| 37 | +import WebKit |
| 38 | + |
| 39 | +protocol JSInterfacePluggable { |
| 40 | + var action: String { get } |
| 41 | + func callAsAction(_ message: [String: Any], with: WKWebView) |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +다음으로, Plugin을 관리하는 Supervisor를 만들고, 웹뷰로부터 특정 Action을 수행 요청을 받으면 Plugin의 callAsAction을 호출하도록 하는 기능을 구현합니다. |
| 46 | + |
| 47 | +```swift |
| 48 | +// FileName : JSInterfaceSupervisor.swift |
| 49 | + |
| 50 | +import Foundation |
| 51 | +import WebKit |
| 52 | + |
| 53 | +/// Supervisor class responsible for loading and managing JS plugins. |
| 54 | +class JSInterfaceSupervisor { |
| 55 | + var loadedPlugins = [String: JSInterfacePluggable]() |
| 56 | + |
| 57 | + init() {} |
| 58 | +} |
| 59 | + |
| 60 | +extension JSInterfaceSupervisor { |
| 61 | + /// Loads a single plugin into the supervisor. |
| 62 | + func loadPlugin(_ plugin: JSInterfacePluggable) { |
| 63 | + let action = plugin.action |
| 64 | + |
| 65 | + guard loadedPlugins[action] == nil else { |
| 66 | + assertionFailure("\(action) action already exists. Please check the plugin.") |
| 67 | + return |
| 68 | + } |
| 69 | + |
| 70 | + loadedPlugins[action] = plugin |
| 71 | + } |
| 72 | + |
| 73 | + /// Loads multiple plugins into the supervisor. |
| 74 | + func loadPlugin(contentsOf newElements: [JSInterfacePluggable]) { |
| 75 | + newElements.forEach { loadPlugin($0) } |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +extension JSInterfaceSupervisor { |
| 80 | + /// Resolves an action and calls the corresponding plugin with a message and web view. |
| 81 | + func resolve(_ action: String, message: [String: Any], with webView: WKWebView) { |
| 82 | + guard |
| 83 | + let plugin = loadedPlugins[action], |
| 84 | + plugin.action == action |
| 85 | + else { |
| 86 | + assertionFailure("Failed to resolve \(action): Action is not loaded. Please ensure the plugin is correctly loaded.") |
| 87 | + return |
| 88 | + } |
| 89 | + |
| 90 | + plugin.callAsAction(message, with: webView) |
| 91 | + } |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +`JSInterfaceSupervisor`는 `JSInterfacePluggable` 프로토콜을 준수하는 Plugin을 관리하는 역할을 수행합니다. `JSInterfacePluggable` 프로토콜을 준수하는 Plugin을 `loadPlugin` 메소드를 이용하여 로드하고, `WKWebView`로부터 Action을 수행 요청을 받으면, `resolve` 메소드를 호출하여 Plugin을 호출합니다. |
| 96 | + |
| 97 | +이제 웹페이지에서 전달받은 Action을 처리하는 조건문의 코드를 Plugin으로 분리합니다. |
| 98 | + |
| 99 | +```swift |
| 100 | +// Before |
| 101 | + |
| 102 | +func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { |
| 103 | + // 메시지의 이름과 body 추출 |
| 104 | + guard |
| 105 | + message.name == "actionHandler", |
| 106 | + let messageBody = message.body as? [String: Any], |
| 107 | + let action = messageBody["action"] as? String |
| 108 | + else { return } |
| 109 | + |
| 110 | + // Action에 따라 처리하는 switch 문 |
| 111 | + switch action { |
| 112 | + case "loading": loading(body: messageBody) |
| 113 | + case "openCard": openCard(body: messageBody) |
| 114 | + case "payment": payment(body: messageBody) |
| 115 | + case "log": log(body: messageBody) |
| 116 | + default: break |
| 117 | + } |
| 118 | + ... |
| 119 | +} |
| 120 | + |
| 121 | +// After |
| 122 | + |
| 123 | +private let supervisor = JSInterfaceSupervisor() |
| 124 | + |
| 125 | +func set(plugins: [JSInterfacePluggable]) { |
| 126 | + supervisor.loadPlugin(contentsOf: plugins) |
| 127 | +} |
| 128 | + |
| 129 | +... |
| 130 | + |
| 131 | +func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { |
| 132 | + // 메시지의 이름과 body 추출 |
| 133 | + guard |
| 134 | + message.name == "actionHandler", |
| 135 | + let messageBody = message.body as? [String: Any], |
| 136 | + let action = messageBody["action"] as? String |
| 137 | + else { return } |
| 138 | + |
| 139 | + // Supervisor에게 Action을 수행 요청 |
| 140 | + supervisor.resolve(action, message: messageBody, with: webView) |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +다음으로, 각 Action에 해당하는 Plugin을 만들어봅시다. |
| 145 | + |
| 146 | +```swift |
| 147 | +// MARK: - LoadingJSPlugin |
| 148 | +class LoadingJSPlugin: JSInterfacePluggable { |
| 149 | + struct Info { |
| 150 | + let uuid: String |
| 151 | + let isShow: Bool |
| 152 | + } |
| 153 | + |
| 154 | + let action = "loading" |
| 155 | + |
| 156 | + func callAsAction(_ message: [String: Any], with webView: WKWebView) { |
| 157 | + guard |
| 158 | + let result = Parser(message) |
| 159 | + else { return } |
| 160 | + |
| 161 | + closure?(result.info, webView) |
| 162 | + } |
| 163 | + |
| 164 | + func set(_ closure: @escaping (Info, WKWebView) -> Void) { |
| 165 | + self.closure = closure |
| 166 | + } |
| 167 | + |
| 168 | + private var closure: ((Info, WKWebView) -> Void)? |
| 169 | +} |
| 170 | + |
| 171 | +private extension LoadingJSPlugin { |
| 172 | + struct Parser { |
| 173 | + let info: Info |
| 174 | + |
| 175 | + init?(_ dictonary: [String: Any]) { |
| 176 | + guard |
| 177 | + let uuid = dictonary["uuid"] as? String, |
| 178 | + let body = dictonary["body"] as? [String: Any], |
| 179 | + let isShow = body["isShow"] as? Bool |
| 180 | + else { return nil } |
| 181 | + |
| 182 | + info = .init(uuid: uuid, isShow: isShow) |
| 183 | + } |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +// MARK: - PaymentJSPlugin |
| 188 | +class PaymentJSPlugin: JSInterfacePluggable { |
| 189 | + struct Info { |
| 190 | + let uuid: String |
| 191 | + let paymentAmount: Int |
| 192 | + let paymentTransactionId: String |
| 193 | + let paymentId: String |
| 194 | + let paymentGoodsName: String |
| 195 | + } |
| 196 | + |
| 197 | + let action = "payment" |
| 198 | + |
| 199 | + func callAsAction(_ message: [String: Any], with webView: WKWebView) { |
| 200 | + guard |
| 201 | + let result = Parser(message) |
| 202 | + else { return } |
| 203 | + |
| 204 | + closure?(result.info, webView) |
| 205 | + } |
| 206 | + |
| 207 | + func set(_ closure: @escaping (Info, WKWebView) -> Void) { |
| 208 | + self.closure = closure |
| 209 | + } |
| 210 | + |
| 211 | + private var closure: ((Info, WKWebView) -> Void)? |
| 212 | +} |
| 213 | + |
| 214 | +private extension PaymentJSPlugin { |
| 215 | + struct Parser { |
| 216 | + let info: Info |
| 217 | + |
| 218 | + init?(_ dictonary: [String: Any]) { |
| 219 | + guard |
| 220 | + let uuid = dictonary["uuid"] as? String, |
| 221 | + let body = dictonary["body"] as? [String: Any], |
| 222 | + let paymentAmount = body["paymentAmount"] as? Int, |
| 223 | + let paymentTransactionId = body["paymentTransactionId"] as? String, |
| 224 | + let paymentId = body["paymentId"] as? String, |
| 225 | + let paymentGoodsName = body["paymentGoodsName"] as? String |
| 226 | + else { return nil } |
| 227 | + |
| 228 | + info = .init( |
| 229 | + uuid: uuid, |
| 230 | + paymentAmount: paymentAmount, |
| 231 | + paymentTransactionId: paymentTransactionId, |
| 232 | + paymentId: paymentId, |
| 233 | + paymentGoodsName: paymentGoodsName |
| 234 | + ) |
| 235 | + } |
| 236 | + } |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | +위와 같이 `JSInterfacePluggable` 프로토콜을 준수하는 Plugin을 만들고, Plugin를 생성하고, Closure를 주입한 뒤, `JSInterfaceSupervisor`에 Plugin을 등록하면 됩니다. |
| 241 | + |
| 242 | +```swift |
| 243 | +let loadingPlugin = LoadingJSPlugin() |
| 244 | +let paymentPlugin = PaymentJSPlugin() |
| 245 | +loadingPlugin.set { info, webView in |
| 246 | + // Loading Action일 때 수행할 코드 |
| 247 | +} |
| 248 | +paymentPlugin.set { info, webView in |
| 249 | + // Payment Action일 때 수행할 코드 |
| 250 | +} |
| 251 | + |
| 252 | +webViewManager.set(plugins: [loadingPlugin, paymentPlugin]) |
| 253 | +``` |
| 254 | + |
| 255 | +이제 웹페이지에서 Action을 수행하는 코드를 Plugin으로 분리했습니다. 추가되는 Action에 맞춰 Plugin을 만들고, 필요한 Plugin을 등록하는 방식으로 진행하면 됩니다. |
| 256 | + |
| 257 | +이와 같은 방식은 웹페이지에서 전달하는 Action 뿐만 아니라, AppScheme, 채팅 등의 다른 방식에서도 Plugin 방식을 적용하여 코드를 더욱 쉽게 유지하고 관리할 수 있습니다. |
| 258 | + |
| 259 | +## 정리 |
| 260 | + |
| 261 | +이번 포스팅에서는 웹페이지에서 Action을 수행하는 코드를 Plugin으로 분리하는 방법에 대해서 알아보았습니다. |
| 262 | + |
| 263 | +웹페이지에서 전달받은 Action을 수행하는 코드를 Plugin으로 분리하는 방식은 코드를 더욱 쉽게 유지하고 관리할 수 있게 해주며, Action을 처리하는 코드를 더욱 간결하게 작성할 수 있습니다. |
| 264 | + |
| 265 | +[예제 코드](https://github.com/minsOne/Experiment-Repo/tree/master/20240511) |
| 266 | + |
| 267 | +## 참고자료 |
| 268 | + |
| 269 | +* Post |
| 270 | + * [분리 인터페이스 패턴, 플러그인 패턴](https://harrislee.tistory.com/62) |
| 271 | + * [Using the plugin pattern in a modularized codebase](https://proandroiddev.com/using-the-plugin-pattern-in-a-modularized-codebase-af8d4905404f) |
| 272 | + * [Introduction to Plugin Architecture in C#](https://www.youtube.com/watch?v=g4idDjBICO8) |
| 273 | + * [Plug-in Architecture](https://medium.com/omarelgabrys-blog/plug-in-architecture-dec207291800) |
| 274 | + * [Extending modules using a plugin architecture](https://medium.com/@tyronemichael/extending-your-modules-using-a-plugin-architecture-c1972735d728) |
| 275 | + * [Plugin-Based Architecture and Scaling iOS Development at Capital One](https://medium.com/capital-one-tech/plugin-based-architecture-and-scaling-ios-development-at-capital-one-fb67561c7df6) |
| 276 | + * [Swift By Sundell - Making Swift code extensible through plugins](https://www.swiftbysundell.com/articles/making-swift-code-extensible-through-plugins/) |
| 277 | +* GitHub |
| 278 | + * [ionic-team/capacitor](https://github.com/ionic-team/capacitor/blob/main/ios/Capacitor/Capacitor/CAPBridgeProtocol.swift) |
| 279 | + * [capacitor-community/generic-oauth2](https://github.com/capacitor-community/generic-oauth2/blob/276f01d4883748a776e86d80ad9b0b547309561f/ios/Plugin/GenericOAuth2Plugin.swift#L82) |
| 280 | + * [TYRONEMICHAEL/plugin-architecture-swift](https://github.com/TYRONEMICHAEL/plugin-architecture-swift) |
| 281 | + * [Electrode-iOS/ELMaestro](https://github.com/Electrode-iOS/ELMaestro/blob/master/ELMaestro/Pluggable.swift) |
| 282 | + |
0 commit comments