Об основных причинах, сподвигнувший начать изучение этой темы можно узнать из первой части, здесь же пойдёт рассказ о том как и к чему мы по итогу пришли.

И так, что же мы имели:

  • приложение прошло модерацию в AppStore, но у нас всё ещё не было уверенности, что мы сможем пройти следующие ревью;

  • код взаимодействия с приложениями в вебвью дублировался в нескольких приложениях;

  • бридж между нативным приложением и игрой был написан достаточно давно и очень уж хотелось его освежить.

Таким образом пришли к тому, что рефакторингу — быть.

Как было

Для начала попытаюсь описать в каком состоянии всё было.

Для того чтоб получать ивенты от веб-приложения использовались WKScriptMessageHandler, под каждый ивент создавался свой класс, со своим делегатом:

import WebKit

protocol GameOnReadyHandlerDelegate: AnyObject {
    func onReady()
}

class GameOnReadyHandler: NSObject, WKScriptMessageHandler {
    
    weak var delegate: GameOnReadyHandlerDelegate?
    
    var name: String {
        return "onReady"
    }
    
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        guard message.name == name else {
            return
        }
        
        delegate?.onReady()
    }
    
}

А почти вся логика, по лучшим традициям MVC, была в одном ViewController'e.

let handler = GameOnReadyHandler()
handler.delegate = self
webView.configuration.userContentController.add(handler, name: handler.name)
extension GameViewController: GameOnReadyHandlerDelegate {
    func onReady() { /* ... */ }
}

Для связи в другую сторону же просто выполняли js-код через webView.evaluateJavaScript

private func dispatchEvent(name: String, json: Any) {
    let json: [String: Any] = ["name": name, "body": json]
    
    do {
        if let jsonString = String(data: try JSONSerialization.data(withJSONObject: json, options: []), encoding: .utf8) {
            mainView.webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent(\"WebAppEvent\", { \"detail\": \(jsonString) }));")
        } else {
            throw NSError()
        }
    } catch {
        log.error("Can't evaluateJavaScript: \(error)")
    }
}

Самое главное — всё работало, но трогать всё же пришлось.

Как делали

Первым делом было решено, что все классы хэнделры ивентов веб-приложения можно закрыть единым протоколом.

public protocol WebBridgeHandler {
    var name: String  { get }
    func bridgeMessageReceived(data: Data) throws
}

Для того, чтобы связать это всё с WKScriptMessageHandler был написан класс WebKitHandler — в нём базовая обработка ивента, парсинг и вызов метода WebBridgeHandler.bridgeMessageReceived с уже обработанными данными.

Hidden text
class WebKitHandler: NSObject, WKScriptMessageHandler {
    
    private let handler: WebBridgeHandler

    weak var bridge: WebViewBridge?
    
    init(bridge: WebViewBridge, handler: WebBridgeHandler) {
        self.bridge = bridge
        self.handler = handler
    }
    
    open func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        guard message.name == handler.name else { return }

        do {
            if let string = message.body as? String {
                if let data = string.data(using: .utf8) {
                    try handler.bridgeMessageReceived(data: data)
                }
                return
            }
            
            let data = try JSONSerialization.data(withJSONObject: message.body, options: [])
            try handler.bridgeMessageReceived(data: data)
        } catch {
            logError(error)
        }
    }
}

В нашем случае ивенты как правило приходили в виде строки, но так же была добавлена поддержка JSON-сериализуемых объектов.

А для добавления хэндлера расширили WKWebView, это позволило нам быть уверенными, что под нужным именем зарегистрирован нужный хэндлер:

public extension WKWebView {
  func addHandler(_ handler: WebBridgeHandler) {
    let wkHandler = WebKitHandler(bridge: self, handler: handler)
    configuration.userContentController.add(wkHandler, name: handler.name)
  }
}

Помимо этого для упрощения жизни было создано несколько протоколов и классов для сокращения количества бойлерплейта:

Hidden text

Со временем, все объекты ивентов у нас должны будут быть переведены на Codable, а данный хэнделер требует от нас реализовать исключительно один метод, принимающий уже свифтовый объект.

public protocol ObjectDecoder {
    func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable
}
extension JSONDecoder: ObjectDecoder { }

public protocol WebBridgeObjectHandler: WebBridgeHandler {
    associatedtype Object: Decodable
    
    var decoder: ObjectDecoder { get }
    func bridgeMessageReceived(object: Object) throws
}

public extension WebBridgeObjectHandler {
    var decoder: ObjectDecoder { JSONDecoder() }
  
    func bridgeMessageReceived(data: Data) throws {
        let object = try decoder.decode(Object.self, from: data)
        try bridgeMessageReceived(object: object)
    }
}

Hidden text

В процессе переезда на новый бридж мы зачастую были вынуждены использовать не только строго типизированные объекты, потому был добавлен такой хендлер. Со временем от него планируется отказаться, используя исключительно Сodable.

public protocol WebBridgeJsonDictHandler: WebBridgeHandler {
    func bridgeMessageReceived(json: [String: Any]) throws
    func bridgeMessageReceived(json: [Any]) throws
    func bridgeMessageReceived(json: Any) throws
}

public extension WebBridgeJsonDictHandler {
    func bridgeMessageReceived(json: [String: Any]) throws {}
    func bridgeMessageReceived(json: [Any]) throws {}
    func bridgeMessageReceived(json: Any) throws {}
}

public extension WebBridgeJsonDictHandler {
    func bridgeMessageReceived(data: Data) throws {
        let body = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
        if let body = body as? [String: Any] {
            try bridgeMessageReceived(json: body)
        } else if let body = body as? [Any] {
            try bridgeMessageReceived(json: body)
        } else {
            try bridgeMessageReceived(json: body)
        }
    }
}

Hidden text

Порой вся логика работы хендлера сводится к тому, чтобы вызвать какой-то код в контроллере/презентере. Как писал выше, делалось у нас это через создание классов, единственная задача которого — вызвать метод делегата. Чтобы такого не было, мы сделали несколько стандартных классов, где обработку можно уместить в замыкании

open class WebBridgeDataCallbackHandler: WebBridgeHandler {
    
    public let name: String
    private let handler: ((Data) -> Void)
    
    public init(name: String, handler: @escaping ((Data) -> Void)) {
        self.name = name
        self.handler = handler
    }
    
    open func bridgeMessageReceived(data: Data) throws {
        handler(data)
    }
}

open class WebBridgeCallbackHandler: WebBridgeHandler {
    
     public init(name: String, handler: @escaping (() -> Void)) { /* ... */ }

    /* ... */
}

open class WebBridgeRawStringCallbackHandler: WebBridgeHandler {
    
     public init(name: String, handler: @escaping ((String) -> Void)) { /* ... */ }

    /* ... */
}


open class WebBridgeObjectCallbackHandler<Object: Decodable>: WebBridgeObjectHandler {
    public init(name: String, handler: @escaping ((Object) -> Void)) { /* ... */ }

    /* ... */
}

Теперь разберёмся с отправкой ивентов из приложения в вебвью.

Аналогично хендлерам, мы не могли сразу полностью отказаться от использования нетипизированных структур данных и использовать только Codable. В результате чего на свет появилось два протокола:

public protocol WebBridgeEvent {
    static var name: String { get }
    
    func payload() -> Any
}

public extension WebBridgeEvent where Self: Encodable {
    func payload() -> Any {
        guard
            let data = try? JSONEncoder().encode(self),
            let object = try? JSONSerialization.jsonObject(with: data, options: [])
        else { return "" }
        
        return object
    }
}

Изначально мы просто преобразовывали результат payload() в JSON и передавали в необходимую нам js функцию. Однако со временем понадобилась возможность отправлять этот пейлоад для разных версий приложения по-разному, потому родилась идея вынести отправку в Dispatcher'ы.

public protocol WebBridgeDispatcher {
    func dispatch(_ event: WebBridgeEvent) throws 
}


open class DefaultWebBridgeDispatcher: WebBridgeDispatcher {
    
    private let webView: WKWebView
    
    public init(_ webView: WKWebView) {
        self.webView = webView
    }

    open func prepare(_ event: WebBridgeEvent) throws -> String {
        let data = try JSONSerialization.data(withJSONObject: event.getPayload(), options: [])

        guard let jsonString = String(data: data, encoding: .utf8) else {
            throw NSError(domain: "Can't create string from json data ", code: 0, userInfo: nil)
        }
        
        return """
            dispatchEvent(
              \(type(of: event).name)',
              JSON.parse("\(jsonString.replacingOccurrences(of: "\"", with: "\\\""))")
            )
        """
    }
  
    public func dispatch(_ event: WebBridgeEvent) throws {
        let jsString = try prepare(event)
        webView.evaluateJavaScript(jsString)
    }
}

Вынесение кода в Dispatcher'ы дало нам возможность куда проще менять способ отправки ивентов, а помимо этого упростило тестирование.

Допиливаем

Основные протоколы и классы у нас уже есть, однако всё ещё не было чего‑то, что свяжет их воедино. Всё держится на расширениях класса WKWebView — некруто.

  1. Выносим все эти методы в один класс, разумеется закрытый протоколом.

public protocol WebViewBridgeDelegate: AnyObject {
    func bridgeDidReceive(error: Error)
    func bridgeDidLoadPage()
}

public protocol WebViewBridge: AnyObject {
    var delegate: WebViewBridgeDelegate? { get set }
    var logger: WebBridgeLogger { get set }
    var dispatcher: WebBridgeDispatcher { get set }
  
    func load(url: URL)
    func load(request: URLRequest)
    
    func dispatchEvent(_ event: WebBridgeEvent)
    func addHandler(_ handler: WebBridgeHandler)
}

Реализация методов достаточно тривиальная — по сути этот объект лишь собирает все необходимые методы для работы с WKWebView, так что логика там минимальная, лишь вызовы методов WKWebView и её конфигурации.

  1. Закрываем WKWebView езде где только можно, например, в DefaultWebBridgeDispatcher на WebViewProtocol — протокол с основными методами WKWebView, чтоб в тестах было проще подсовывать моки.

  2. Добавляем WebBridgeLogger, логгируем все ошибки через него.

Чем все завершилось

Теперь всё взаимодействие с приложением выглядит куда опрятнее — во viewDidLoad регистрируем все необходимые хендленры:

bridge.logger = DebugService.shared
[
    TapticEngineHandler(),
    AnalyticsHandler(),
    WebBridgeCallbackHandler(name: "init", handler: self.didInit),
    WebBridgeCallbackHandler(name: "onReady", handler: self.onReady),
    WebBridgeCallbackHandler(name: "showInvitation", handler: self.showInvitation),
    WebBridgeCallbackHandler(name: "gameClose", handler: self.gameClose)
].forEach(bridge.addHandler)

А для того чтоб отправить ивент в веб-приложение, в нужном месте вызываем что-то вроде такого:

bridge.dispatchEvent(DataUpdateEvent(payload: string))

В сочетании с TypeScript мы получаем надёжный контракт между JS‑ и Swift‑частями приложения. Если пойти дальше, то можно использовать кодогенерацию по единому описанию моделей, что еще больше сократит возможность человеческой ошибки.

В общем и целом, нам понравилось)

Как можно заметить, про интеграцию ODR всё ещё ни слова, однако рассказ и без того затянулся, потому данную часть завершим на том как рефакторили бридж, а то как интегрировали ODR, оставим на потом и подробно эта тема будет раскрыта в 3 части статьи.

По всем вопросам пишите: t.me/zloysergunya или t.me/propertyWrapper

Комментарии (2)


  1. Gargo
    29.08.2023 05:11

    class WebKitHandler: NSObject, WKScriptMessageHandler {
        
        let handler: WebBridgeHandler
        
        init(handler: WebBridgeHandler) {
            self.bridge = bridge
            self.handler = handler
        }
        ...

    У вас тут ничего не пропущено - откуда берутся "bridge"?


    1. sergeykotov Автор
      29.08.2023 05:11

      Поправил, спасибо