ZhgChg.Li

WKWebViewのDesign Patterns実践|Builder・Strategy・Chain of Responsibilityで効率開発

iOS WKWebViewの封装で直面する課題を、Builder・Strategy・Chain of ResponsibilityのDesign Patternsで解決。コードの保守性と拡張性を向上させる具体的実装例を紹介します。

WKWebViewのDesign Patterns実践|Builder・Strategy・Chain of Responsibilityで効率開発
本記事は AI による翻訳です。お気づきの点があればお知らせください。

Design Patterns の実践応用記録—WKWebView における Builder、Strategy、Chain of Responsibility パターンの活用

iOS WKWebView をラップする際に使われるデザインパターンの場面(ストラテジー、チェーンオブレスポンシビリティ、ビルダー)

Photo by Dean Pugh

写真提供:Dean Pugh

デザインパターンについて

Design Patterns を語る前に必ず触れるべきこととして、最も有名な GoF の23種類のデザインパターンが発表されてから既に30年が経過していること(1994年発行)があります。ツールや言語の変化、ソフトウェア開発の方法論の進化により、当時とは全く異なる状況になっています。その後、さまざまな分野で新たなデザインパターンも多数生まれています。Design Patterns は万能解でも唯一の解決策でもなく、むしろ「言語の代名詞」のようなもので、適切な場面で適切なデザインパターンを適用することで、開発や協力の障害を減らすことができます。例えば、ここで戦略パターンを適用すれば、後から保守・拡張する人は戦略パターンの構造に従って繰り返し作業ができ、また多くのデザインパターンは優れた疎結合を実現しているため、拡張性やテストのしやすさにも大きく寄与します。

デザインパターンの使用心得

  • 唯一の解決策ではありません

  • 万能ではありません

  • 無理に当てはめるのではなく、解決すべき問題の種類(生成?振る舞い?構造?)や目的に応じて適切なデザインパターンを選択する必要があります。

  • 魔改造は避けるべきです。魔改造は後のメンテナンス担当者に誤解を招きやすく、言語と同じで、みんなが「Apple」を「Apple」と呼ぶのに対し、自分だけ「Banana」と定義すると特別に知っておく必要があり、開発コストが増加します。

  • 可能な限りキーワードを避けること。例えば Factory Pattern を表す際に XXXFactory と命名する習慣がありますが、工場パターンでない場合はこの命名キーワードを使用すべきではありません。

  • 自分でパターンを慎重に作成する こと。前述の通り、古典的なものは23種類しかありませんが、各分野で長年の進化を経て多くの新しいパターンもあります。まずはネットの資料を参考に適したパターンを探しましょう(やはり三人寄れば文殊の知恵)。どうしても見つからなければ、新しい設計パターンを提案し、できるだけ発表して異なる分野や場面の人々と共に検討・調整することを目指しましょう。

  • プログラムは結局、人がメンテナンスするためのものです。メンテナンスしやすく、拡張しやすければ、必ずしもデザインパターンを使う必要はありません。

  • チームでデザインパターンの共通理解がある場合にのみ使用すべきです。

  • Design Pattern はさらに Design Pattern を組み合わせる技術です。

  • デザインパターンは、実務で繰り返し経験することで、どのような場面に適しているか、また適していないかの感度が高まっていきます。

補助ツール ChatGPT

ChatGPT が登場してから、Design Patterns(設計パターン)の実務での応用学習がより簡単になりました。具体的に問題を説明し、そのシーンに適した設計パターンを尋ねるだけで、いくつかの適切なパターンと説明を提示してくれます。すべての回答が完璧に合うわけではありませんが、少なくともいくつかの実行可能な方向性を示してくれます。あとはそれらのパターンを自分の実務シーンに深く結びつけて考えれば、最終的に良い解決策を選べるのです!

WKWebView のデザインパターン実践応用シーン

今回のデザインパターン実践は、現在のコードベースにある WKWebView オブジェクトの機能特性を集約し、統一された WKWebView コンポーネントを開発する際に、いくつかの適切なロジック抽象ポイントでデザインパターンを適用した経験の共有です。

完全なデモプロジェクトのソースコードは記事の最後に添付します。

元の抽象化されていない書き方

class WKWebViewController: UIViewController {

    // MARK - いくつかの変数やスイッチを定義し、外部から init 時に特性を注入可能にする...

    // ビジネスロジックのシミュレーション:特殊パスにマッチしたらネイティブ画面を開くスイッチ
    let noNeedNativePresent: Bool
    // ビジネスロジックのシミュレーション:DeeplinkManager チェックのスイッチ
    let deeplinkCheck: Bool
    // ビジネスロジックのシミュレーション:ホームページかどうか?
    let isHomePage: Bool
    // ビジネスロジックのシミュレーション:WKWebView に注入する WKUserScript のスクリプト
    let userScripts: [WKUserScript]
    // ビジネスロジックのシミュレーション:WKWebView に注入する WKScriptMessageHandler のスクリプト
    let scriptMessageHandlers: [String: WKScriptMessageHandler]
    // WebView からタイトルを取得して ViewController のタイトルを上書きするかどうか
    let overrideTitleFromWebView: Bool
    
    let url: URL
    
    // ... 
}
// ...
extension OldWKWebViewController: WKNavigationDelegate {
    // MARK - iOS WKWebView の navigationAction Delegate、読み込むリンクの処理を決定するために使用
    // 終了時には必ず decisionHandler(.allow) または decisionHandler(.cancel) を呼ぶこと
    // decisionHandler(.cancel) は読み込み中のページを中断する

    // ここでは異なる変数やスイッチによって異なるロジックをシミュレートしている:

    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.allow)
            return
        }
        
        // ビジネスロジックのシミュレーション:WebViewController の deeplinkCheck == true (DeepLinkManager チェックを通してページを開く必要がある)
        if deeplinkCheck {
            print("DeepLinkManager.open(\(url.absoluteString)")
            // DeepLinkManager のロジックをシミュレートし、URL が成功したら開いて処理を終了する。
            // if DeepLinkManager.open(url) == true {
                decisionHandler(.cancel)
                return
            // }
        }
        
        // ビジネスロジックのシミュレーション:WebViewController の isHomePage == true (ホームページを開いている) & WebView がホームページを閲覧中の場合、TabBar のインデックスを切り替える
        if isHomePage {
            if url.absoluteString == "https://zhgchg.li" {
                print("UITabBarController をインデックス 0 に切り替え")
                decisionHandler(.cancel)
            }
        }
        
        // ビジネスロジックのシミュレーション:WebViewController の noNeedNativePresent == false (特殊パスにマッチしたらネイティブ画面を開く必要がある)
        if !noNeedNativePresent {
            if url.pathComponents.count >= 3 {
                if url.pathComponents[1] == "product" {
                    // http://zhgchg.li/product/1234 にマッチ
                    let id = url.pathComponents[2]
                    print("ProductViewController を表示(\(id)")
                    decisionHandler(.cancel)
                } else if url.pathComponents[1] == "shop" {
                    // http://zhgchg.li/shop/1234 にマッチ
                    let id = url.pathComponents[2]
                    print("ShopViewController を表示(\(id)")
                    decisionHandler(.cancel)
                }
                // その他...
            }
        }
        
        decisionHandler(.allow)
    }
}
// ...

問題

  1. クラス内に変数やスイッチを並べると、どれが設定用なのか分かりにくいです。

  2. WKUserScript の変数設定を直接外部に公開せず、注入する JS を管理し、特定の動作のみ注入を許可したいと考えています。

  3. WKScriptMessageHandler の登録ルールを制御できない問題があります。

  4. 似たような WebView を初期化する際に、注入パラメータのルールを何度も書く必要があり、パラメータルールの再利用ができません。

  5. navigationAction Delegate 内部は変数で処理の流れを制御しており、処理の順序や内容を変更・削除する際はコード全体に手を加える必要があり、正常に動作していた処理を壊してしまう可能性があります。

Builder Pattern 建造者パターン

Builder Pattern(ビルダーパターン)は 生成型 デザインパターンに属し、オブジェクトの生成手順とロジックを分離します。利用者はパラメータを一つずつ設定して再利用でき、最後に目的のオブジェクトを生成します。また、同じ生成手順で異なるオブジェクトを作成することも可能です。

上図はピザ作りを例に、まずピザ作りの工程をいくつかのメソッドに分け、PizzaBuilder というプロトコル(インターフェース)で宣言しています。ConcretePizzaBuilder は実際にピザを作るオブジェクトで、例えば「ベジタリアン PizzaBuilder」や「ミート PizzaBuilder」があります。異なる Builder は材料が異なる場合がありますが、最終的には build()Pizza オブジェクトを生成します。

WKWebView のシナリオ

WKWebView のシーンに戻ると、最終的な出力オブジェクトは MyWKWebViewConfiguration です。WKWebView に必要なすべての設定変数をこのオブジェクトにまとめ、Builder パターンの MyWKWebViewConfigurator を使って段階的に Configuration の構築を行います。

public struct MyWKWebViewConfiguration {
    let headNavigationHandler: NavigationActionHandler?
    let scriptMessageStrategies: [ScriptMessageStrategy]
    let userScripts: [WKUserScript]
    let overrideTitleFromWebView: Bool
    let url: URL
}
// すべてのパラメータはモジュール内のみ公開(Internal)

MyWKWebViewConfigurator(ビルダーパターン)

ここでは MyWKWebView のビルドのみが必要なため、MyWKWebViewConfigurator をさらにプロトコル(インターフェース)に分割していません。

public final class MyWKWebViewConfigurator {
    
    private var headNavigationHandler: NavigationActionHandler? = nil
    private var overrideTitleFromWebView: Bool = true
    private var disableZoom: Bool = false
    private var scriptMessageStrategies: [ScriptMessageStrategy] = []
    
    public init() {
        
    }
    
    // パラメータのカプセル化と内部管理
    public func set(disableZoom: Bool) -> Self {
        self.disableZoom = disableZoom
        return self
    }
    
    public func set(overrideTitleFromWebView: Bool) -> Self {
        self.overrideTitleFromWebView = overrideTitleFromWebView
        return self
    }
    
    public func set(headNavigationHandler: NavigationActionHandler) -> Self {
        self.headNavigationHandler = headNavigationHandler
        return self
    }
    
    // 新しいロジックルールをここにカプセル化可能
    public func add(scriptMessageStrategy: ScriptMessageStrategy) -> Self {
        scriptMessageStrategies.removeAll(where: { type(of: $0).identifier == type(of: scriptMessageStrategy).identifier })
        scriptMessageStrategies.append(scriptMessageStrategy)
        return self
    }
    
    public func build(url: URL) -> MyWKWebViewConfiguration {
        var userScripts:[WKUserScript] = []
        // 生成時にのみ追加
        if disableZoom {
            let script = "var meta = document.createElement('meta'); meta.name='viewport'; meta.content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; document.getElementsByTagName('head')[0].appendChild(meta);"
            let disableZoomScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
            userScripts.append(disableZoomScript)
        }
        
        return MyWKWebViewConfiguration(headNavigationHandler: headNavigationHandler, scriptMessageStrategies: scriptMessageStrategies, userScripts: userScripts, overrideTitleFromWebView: overrideTitleFromWebView, url: url)
    }
}

さらに一層分けることで、Access Control を使ってパラメータの使用権限をより適切に分離できます。本シナリオでは、WKUserScript を直接 MyWKWebView に注入できるようにしたい一方で、利用者が自由に注入できるように開放しすぎたくありません。そのため、Builder Pattern と Swift の Access Control を組み合わせ、MyWKWebView がモジュール内に配置された後、MyWKWebViewConfigurator は外部に操作メソッド func set(disableZoom: Bool) として公開し、内部で MyWKWebViewConfiguration を生成する際に WKUserScript を付加します。MyWKWebViewConfiguration のすべてのパラメータは外部から変更不可で、MyWKWebViewConfigurator を通じてのみ生成可能です。

MyWKWebViewConfigurator + シンプルファクトリー

MyWKWebViewConfigurator ビルダーができたら、簡単なファクトリーを作成して、構築手順を再利用できます。

struct MyWKWebViewConfiguratorFactory {
    enum ForType {
        case `default`
        case productPage
        case payment
    }
    
    static func make(for type: ForType) -> MyWKWebViewConfigurator {
        switch type {
        case .default:
            return MyWKWebViewConfigurator()
                .add(scriptMessageStrategy: PageScriptMessageStrategy())
                .set(overrideTitleFromWebView: false) // WebViewからのタイトル上書きを無効化
                .set(disableZoom: false) // ズームを無効化しない
        case .productPage:
            return Self.make(for: .default).set(disableZoom: true).set(overrideTitleFromWebView: true) // ズーム無効化・タイトル上書きを有効化
        case .payment:
            return MyWKWebViewConfigurator().set(headNavigationHandler: paymentNavigationActionHandler) // 支払い用のナビゲーションハンドラを設定
        }
    }
}

Chain of Responsibility Pattern 責任連鎖パターン

責任連鎖パターン(Chain of Responsibility Pattern)は振る舞い型デザインパターンに属し、オブジェクトの処理操作をカプセル化して連鎖構造でつなげます。リクエストはチェーンに沿って伝達され、処理されるまで続きます。連結された操作のカプセル化は自由に組み合わせや順序変更が可能です。

責任チェーンは、受け取ったものを処理するかどうかに集中し、処理しない場合は次に渡します そのため、途中で処理を中断したり、入力オブジェクトを変更して次に渡すことはできません;そのような場合は別のインターセプターパターンを使用します。

上図は Tech Support(または OnCall..)を例にしています。問題オブジェクトが入ってきたら、まず CustomerService を通り、処理できなければ次の階層の Supervisor に渡されます。まだ処理できなければさらに下の TechSupport へ進みます。また、異なる問題に対して異なる責任連鎖を作ることも可能で、例えば大口顧客の問題は直接 Supervisor から処理を開始します。
Swift UIKit の Responder Chain も責任連鎖パターンを使っており、UI上のユーザー操作に応答しています。

WKWebView のシナリオ

私たちの WKWebView のシナリオでは、主に func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) という Delegate メソッドに適用しています。

システムがURLリクエストを受け取ると、このメソッドを通じて遷移を許可するかどうかを判断し、処理終了後に decisionHandler(.allow) または decisionHandler(.cancel) を呼び出して結果を通知します。

WKWebView の実装では、多くの判定や特定のページ処理が他と異なり回避が必要になることがあります:

// 元の書き方...
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.allow)
            return
        }
        
        // ビジネスロジックのシミュレーション:WebViewController deeplinkCheck == true(DeepLinkManagerでチェックしてページを開く必要がある)
        if deeplinkCheck {
            print("DeepLinkManager.open(\(url.absoluteString)")
            // DeepLinkManagerのロジックをシミュレート。URLが正常に開ければ開いて処理を終了。
            // if DeepLinkManager.open(url) == true {
                decisionHandler(.cancel)
                return
            // }
        }
        
        // ビジネスロジックのシミュレーション:WebViewController isHomePage == true(ホームページを開いている)&WebViewがホームを閲覧中の場合、TabBarのインデックスを切り替える
        if isHomePage {
            if url.absoluteString == "https://zhgchg.li" {
                print("UITabBarControllerをインデックス0に切り替え")
                decisionHandler(.cancel)
            }
        }
        
        // ビジネスロジックのシミュレーション:WebViewController noNeedNativePresent == false(特定パスにマッチしてネイティブ画面を開く必要がある)
        if !noNeedNativePresent {
            if url.pathComponents.count >= 3 {
                if url.pathComponents[1] == "product" {
                    // http://zhgchg.li/product/1234 にマッチ
                    let id = url.pathComponents[2]
                    print("ProductViewControllerを表示(\(id))")
                    decisionHandler(.cancel)
                } else if url.pathComponents[1] == "shop" {
                    // http://zhgchg.li/shop/1234 にマッチ
                    let id = url.pathComponents[2]
                    print("ShopViewControllerを表示(\(id))")
                    decisionHandler(.cancel)
                }
                // その他...
            }
        }
        
        // その他...
        decisionHandler(.allow)
}

時間が経つにつれて機能が複雑化し、ここでのロジックも増えていきます。さらに処理の順序まで変わると、大変なことになります。

まずは Handler プロトコルを定義します:

public protocol NavigationActionHandler: AnyObject {
    var nextHandler: NavigationActionHandler? { get set }

    /// Webビューのナビゲーションアクションを処理します。処理した場合はtrue、そうでなければfalseを返します。
    func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool
    /// ナビゲーションアクションのポリシー決定を実行します。現在のハンドラーが処理しない場合は、チェーン内の次のハンドラーが実行されます。
    func exeute(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)
}

public extension NavigationActionHandler {
    func exeute(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        if !handle(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) {
            self.nextHandler?.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) ?? decisionHandler(.allow)
        }
    }
}
  • 操作は func handle() 内で実装され、次の処理がある場合は true を返し、そうでなければ false を返します。

  • func exeute() はデフォルトのチェーンアクセス実装で、ここから操作チェーン全体の遍歴を実行します。デフォルトの動作は、func handle()false(このノードで処理できないことを意味する)を返した場合、自動的に次の nextHandlerexecute() を呼び出して処理を続行し、終了するまで繰り返します。

実装:

// デフォルト実装、通常は最後に配置
public final class DefaultNavigationActionHandler: NavigationActionHandler {
    public var nextHandler: NavigationActionHandler?
    
    public init() {
        
    }
    
    public func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool {
        decisionHandler(.allow)
        return true
    }
}

//
final class PaymentNavigationActionHandler: NavigationActionHandler {
    var nextHandler: NavigationActionHandler?
    
    func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool {
        guard let url = navigationAction.request.url else {
            return false
        }
        
        // ビジネスロジックの例:Payment 支払い関連、二段階認証 WebView...など
        print("支払い確認ビューコントローラを表示")
        decisionHandler(.cancel)
        return true
    }
}

//
final class DeeplinkManagerNavigationActionHandler: NavigationActionHandler {
    var nextHandler: NavigationActionHandler?
    
    func handle(webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) -> Bool {
        guard let url = navigationAction.request.url else {
            return false
        }
        
        
        // DeepLinkManager のロジック例、URLが正常に開ければ開いて処理を終了
        // if DeepLinkManager.open(url) == true {
            decisionHandler(.cancel)
            return true
        // } else {
            return false
        //
    }
}

// More...

使用:

extension MyWKWebViewController: WKNavigationDelegate {
    public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
       let headNavigationActionHandler = DeeplinkManagerNavigationActionHandler()
       let defaultNavigationActionHandler = DefaultNavigationActionHandler()
       let paymentNavigationActionHandler = PaymentNavigationActionHandler()
       
       headNavigationActionHandler.nextHandler = paymentNavigationActionHandler
       paymentNavigationActionHandler.nextHandler = defaultNavigationActionHandler
       
       headNavigationActionHandler.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
    }
}

このようにリクエストを受け取ると、定義した処理チェーンに従って順番に処理されます。

前述の Builder Pattern と組み合わせて MyWKWebViewConfigurator headNavigationActionHandler をパラメータとして外部から渡すことで、この WKWebView の処理内容や順序を外部で決定できます:

extension MyWKWebViewController: WKNavigationDelegate {
    public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        configuration.headNavigationHandler?.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) ?? decisionHandler(.allow)
    }
}

//...
struct MyWKWebViewConfiguratorFactory {
    enum ForType {
        case `default`
        case productPage
        case payment
    }
    
    static func make(for type: ForType) -> MyWKWebViewConfigurator {
        switch type {
        case .default:
            // デフォルトのケースでこれらのハンドラーがあることを模擬
            let deplinkManagerNavigationActionHandler = DeeplinkManagerNavigationActionHandler()
            let homePageTabSwitchNavigationActionHandler = HomePageTabSwitchNavigationActionHandler()
            let nativeViewControllerNavigationActionHandlera = NativeViewControllerNavigationActionHandler()
            let defaultNavigationActionHandler = DefaultNavigationActionHandler()
            
            deplinkManagerNavigationActionHandler.nextHandler = homePageTabSwitchNavigationActionHandler
            homePageTabSwitchNavigationActionHandler.nextHandler = nativeViewControllerNavigationActionHandlera
            nativeViewControllerNavigationActionHandlera.nextHandler = defaultNavigationActionHandler
            
            return MyWKWebViewConfigurator()
                .add(scriptMessageStrategy: PageScriptMessageStrategy())
                .add(scriptMessageStrategy: UserScriptMessageStrategy())
                .set(headNavigationHandler: deplinkManagerNavigationActionHandler)
                .set(overrideTitleFromWebView: false)
                .set(disableZoom: false)
        case .productPage:
            return Self.make(for: .default).set(disableZoom: true).set(overrideTitleFromWebView: true)
        case .payment:
            // 支払いページはこれらのハンドラーのみ必要で、paymentNavigationActionHandlerが最優先
            let paymentNavigationActionHandler = PaymentNavigationActionHandler()
            let deplinkManagerNavigationActionHandler = DeeplinkManagerNavigationActionHandler()
            let defaultNavigationActionHandler = DefaultNavigationActionHandler()
            
            paymentNavigationActionHandler.nextHandler = deplinkManagerNavigationActionHandler
            deplinkManagerNavigationActionHandler.nextHandler = defaultNavigationActionHandler
            
            return MyWKWebViewConfigurator().set(headNavigationHandler: paymentNavigationActionHandler)
        }
    }
}

Strategy Pattern 戦略パターン

ストラテジーパターン(Strategy Pattern)は 振る舞い型 デザインパターンに属し、実際の操作を抽象化します。これにより、複数の異なる操作を実装でき、外部は状況に応じて柔軟に切り替えて利用できます。

上図は異なる支払い方法の例です。支払いを Payment プロトコル(インターフェース)として抽象化し、各支払い方法がそれぞれの実装を行います。PaymentContext(外部利用を模擬)では、ユーザーが選択した支払い方法に応じて対応する Payment インスタンスを生成し、統一して pay() を呼び出して支払いを行います。

WKWebView のシナリオ

WebView とフロントエンドページのインタラクションに使用。

フロントエンドの JavaScript が呼び出すとき:

window.webkit.messageHandlers.Name.postMessage(Parameters);

WKWebView は対応する NameWKScriptMessageHandler クラスを見つけて、処理を実行します。

システムにはすでに定義された Protocol と対応する func add(_ scriptMessageHandler: any WKScriptMessageHandler, name: String) メソッドがあり、私たちは自分の WKScriptMessageHandler の実装を定義して WKWebView に追加するだけです。システムは Strategy Pattern(戦略パターン)に従い、受け取った name に基づいて対応する具体的な戦略に処理を委譲します。

こちらでは簡単に Protocol を拡張し、WKScriptMessageHandler を継承しています。add(.. name:) のために identifier:String を追加しています:

public protocol ScriptMessageStrategy: NSObject, WKScriptMessageHandler {
    static var identifier: String { get } // 識別子を定義
}

実装:

final class PageScriptMessageStrategy: NSObject, ScriptMessageStrategy {
    static var identifier: String = "page"
    
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // jsから呼ばれる例: window.webkit.messageHandlers.page.postMessage("Close");
        print("\(Self.identifier): \(message.body)")
    }
}

//

final class UserScriptMessageStrategy: NSObject, ScriptMessageStrategy {
    static var identifier: String = "user"
    
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // jsから呼ばれる例: window.webkit.messageHandlers.user.postMessage("Hello");
        print("\(Self.identifier): \(message.body)")
    }
}

WKWebView の登録使用:

var scriptMessageStrategies: [ScriptMessageStrategy] = []
scriptMessageStrategies.forEach { scriptMessageStrategy in
  webView.configuration.userContentController.add(scriptMessageStrategy, name: type(of: scriptMessageStrategy).identifier)
}

// 各ScriptMessageStrategyをwebViewのuserContentControllerに追加しています。

前述の Builder Pattern と組み合わせて MyWKWebViewConfigurator は外部から ScriptMessageStrategy の登録を管理します:

public final class MyWKWebViewConfigurator {
    //...
    
    // 新しいロジックルールをここにカプセル化できます
    public func add(scriptMessageStrategy: ScriptMessageStrategy) -> Self {
        // ここでは identifier が重複した場合、古いものを先に削除する処理のみ実装
        scriptMessageStrategies.removeAll(where: { type(of: $0).identifier == type(of: scriptMessageStrategy).identifier })
        scriptMessageStrategies.append(scriptMessageStrategy)
        return self
    }
    //...
}

//...

public class MyWKWebViewController: UIViewController {
    //...
    public override func viewDidLoad() {
        super.viewDidLoad()
       
        //...
        configuration.scriptMessageStrategies.forEach { scriptMessageStrategy in
            webView.configuration.userContentController.add(scriptMessageStrategy, name: type(of: scriptMessageStrategy).identifier)
        }
        //...
    }
}

質問: このシナリオは Chain of Responsibility Pattern(責任の連鎖パターン)に変更することもできますか?

ここまで読んで、「ここでのストラテジーパターンは責任連鎖パターンで代用できるのか?」と疑問に思う方もいるかもしれません。

これら二つのデザインパターンはどちらも振る舞い型であり、置き換え可能です。しかし実際には要件や状況によります。ここでは典型的なストラテジーパターンで、WKWebViewはNameに応じて異なるストラテジーを選択します。もし異なるストラテジー間でチェーン依存やリカバー関係があり、例えばAStrategyが処理しなければBStrategyに渡す必要がある場合にのみ、責任連鎖パターンを検討します。

Strategy v.s. Chain of Responsibility

Strategy と Chain of Responsibility の違い

  • Strategyパターン:すでに明確な実行戦略があり、戦略同士に関係がない場合。

  • Chain of Responsibilityパターン:実行する戦略は各実装で決定され、処理できない場合は次の実装に渡されます。

複雑なシナリオでは、Strategy Pattern の中に Chain of Responsibility Pattern を組み合わせて実現できます。

最終組み合わせ

  • Simple Factory シンプルファクトリーパターン MyWKWebViewConfiguratorFactoryMyWKWebViewConfigurator の生成手順をカプセル化します。

  • Builder Pattern 建造者パターン MyWKWebViewConfiguratorMyWKWebViewConfiguration のパラメータと構築手順をカプセル化します。

  • MyWKWebViewConfigurationMyWKWebViewController で使用するために注入されます。

  • Chain of Responsibility Pattern 責任連鎖パターン MyWKWebViewControllerfunc webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) から headNavigationHandler?.exeute(webView: webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler) を呼び出し、チェーン処理を実行します。

  • Strategy Pattern 戦略パターン MyWKWebViewControllerwebView.configuration.userContentController.addUserScript(XXX) は対応する JS コーラーを対応する処理戦略に割り当てます。

完全なデモリポジトリ

関連記事

Post MediumからZMediumToMarkdownを使って変換。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント