📲

【iOS】WKWebViewを閉じて外部ブラウザに遷移するJSIFを実装する

に公開
2025/12/16

本記事は、『Swift Advent Calendar 2025』12日目の記事です。
https://qiita.com/advent-calendar/2025/swift

はじめに

iOS SDKの開発で「WebViewを閉じて、指定されたURLを外部ブラウザで開く」
という機能を実装する機会がありました。
本記事では、JavaScript Interface(JSIF)を使ってこの機能を実装する手順を解説します。

対象読者

  • WKWebViewを使ったアプリを開発している方
  • Web↔ネイティブ連携に興味がある方

実現したいこと

外部サービスとの連携フローで、以下の動作が必要でした:

  1. WebView内でユーザーが操作
  2. 特定のタイミングでWebViewを閉じる
  3. 外部ブラウザで指定されたURLを開く

JSIF(JavaScript Interface)とは

Web側(JavaScript)からネイティブ(Swift)の機能を呼び出すための仕組みです。

WebViewとは?(前提)

まず、WebViewとは「アプリの中にWebページを表示する機能」です。

┌─────────────────────────────┐
│  ネイティブアプリ(Swift)     │
│                             │
│  ┌───────────────────────┐  │
│  │     WebView           │  │
│  │  (Webページを表示)     │  │
│  │                       │  │
│  │   HTML/CSS/JavaScript │  │
│  │   で作られた画面        │  │
│  └───────────────────────┘  │
│                             │
└─────────────────────────────┘

なぜJSIFが必要なのか?

WebView内のJavaScriptだけでは、以下のようなスマホ固有の機能にアクセスできません:

  • 画面の明るさを変える
  • 端末に安全にデータを保存する
  • 他のアプリ(Safari等)を開く
  • WebView自体を閉じる

そこで、JavaScriptから「この処理をやって!」とネイティブ側にお願いする仕組みが必要です。
これがJSIF(JavaScript Interface)です。

全体の流れ

Web(JavaScript)
    ↓ postMessage
WKScriptMessageHandler(メッセージ受信)
    ↓ パース
enum(メソッド判定)
    ↓ 
ViewController(実際の処理)

パースとは?

「パース」とは、データを解析してプログラムが扱いやすい形に変換することです。

Web側から送られてくるデータはJSON形式の文字列です。
これをSwiftの構造体に変換することで、簡単に値を取り出せるようになります。

状態 データの形
パース前 "{\"url\": \"https://example.com\"}" (文字列)
パース後 OpenExternalBrowserArgs(url: "https://example.com") (構造体)

パース後は args.url のように簡単に値を取得できます。

実装手順

Step 1: 引数オブジェクトを定義する

Web側から送られてくるJSON文字列を、Swiftで扱いやすい形に変換(パース)するには、
変換先となる「型」が必要です。これが引数オブジェクト(構造体)です。

struct OpenExternalBrowserArgs: Codable {
    /// 外部ブラウザで開くURL
    let url: String
}

なぜ必要?

  • JSONDecoder は「どんな形に変換するか」を知る必要がある
  • 構造体を定義することで、変換先の形を指定できる

例えば、
json dataで

json = {
  "name": "太郎"
  "age": "20"
}

let name = json["name"]

と書かれているとき、swiftで構造体化することにより下記のように書くことができます。

struct User: Codable {
    let name: String
    let age: String
}

let user = User(name: "太郎", age: "20")
let name = user.name

ポイント:

  • Codableに準拠することでJSONから自動変換できる

参考記事:
https://developer.apple.com/documentation/swift/codable

Step 2: メソッドをenumに登録する

enumとは?

enum(列挙型)とは、関連する値をグループ化するための仕組みです。

例えば、信号機の色を表す場合:

enum Signal {
    case red
    case yellow
    case green
}

これにより「信号機の色は赤・黄・緑のどれか」という制限ができます。
存在しない色(例:青)を指定するとコンパイルエラーになるため、
バグを防ぐことができます。

なぜJSIFでenumを使うのか?

JSIFのメソッド名を文字列で管理すると、タイプミスに気づけません:

// ❌ 文字列だとタイプミスに気づけない
if method == "opneExternalBrowser" {  // スペルミス!
    // 実行されない...
}

enumで管理すれば、コンパイラがチェックしてくれます:

// ✅ enumならコンパイルエラーで気づける
case .opneExternalBrowser:  // エラー:そんなcaseはありません

JSIFのメソッド一覧を管理するenumに追加します

enum JavaScriptInterface {
    // ... 既存のcase
    case openExternalBrowser(OpenExternalBrowserArgs)
}
enum Method: String {
    // ... 既存のcase
    case openExternalBrowser
}

ここで学んだこと:

Swiftのenumには2種類あります:

番号 種類 特徴
① Associated Value 引数を持てる(例:case xxx(Args))
② Raw Type (: String) 文字列と対応、引数を持てない

今回は両方を使い分ける必要がありました。

なぜ2種類必要?

// Web側から送られてくるのは「文字列」
let methodName = "openExternalBrowser"  // 文字列

// 文字列 → enum に変換するために ②Raw Type が必要
let method = Method(rawValue: methodName)  // Method.openExternalBrowser

// 処理には引数データが必要なので ①Associated Value も必要
case openExternalBrowser(OpenExternalBrowserArgs)  // 引数を持てる

Step 3: パース処理を追加する

Web側から送られてきたJSONをパースします。

case .openExternalBrowser:
    guard let paramsJson = object["params"] else {
        handler(.failure(.paramsNotFound))
        return
    }
    guard let args = try? JSONDecoder().decode(
        OpenExternalBrowserArgs.self, 
        from: Data(paramsJson.utf8)
    ) else {
        handler(.failure(.paramsParseError))
        return
    }
    handler(.success(.openExternalBrowser(args)))

コード解説:

  1. object["params"] でパラメータを取得
    • objectはWeb側から送られてきたメッセージを辞書型に変換したものです
    • 元はmessage.body as? [String: String]で型変換されている
      • message.bodyは「何でも入る型(Any型)」
      • as?で辞書型に変換することで、object["params"]のようにアクセスできる
    • ["params"]でキーを指定して値を取り出す

参考:

  • Apple公式のWKScriptMessage(Web側からのメッセージを受け取る仕組み)

https://developer.apple.com/documentation/webkit/wkscriptmessage

  • 辞書型の値の取り方について

https://mogi0506.com/swift-dictionary/

  1. JSONDecoder().decode() でSwiftの構造体に変換

  2. 成功したら次の処理に渡す

Step 4: 実際の処理を実装する

WebViewを閉じて、外部ブラウザでURLを開きます。

func openExternalBrowser(_ args: OpenExternalBrowserArgs) {
    // WebViewを閉じる
    dismiss(animated: true)
    
    // コールバックを呼ぶ
    delegate?.onPageClosed(reason: "OPEN_BROWSER")
    
    // 外部ブラウザでURLを開く
    if let url = URL(string: args.url) {
        UIApplication.shared.open(url)
    }
}

コード解説:

  1. dismiss(animated: true) でWebViewを閉じる

    • 現在表示中の画面を閉じるメソッド
  2. delegate?.onPageClosed(reason: "OPEN_BROWSER") でコールバックを呼ぶ

    • SDKを使っているアプリに「WebViewが閉じたよ」と通知する仕組み
    • ?はdelegateがnilの場合は何もしない(Optional Chaining)

https://iosswiftclarifydoubts.com/optional-chaining/

  1. if let url = URL(string: args.url) でURLを開く
    • 文字列をURL型に変換(失敗する可能性があるのでif letで安全に処理)
    • UIApplication.shared.open(url)で外部ブラウザを起動

参考:

  • Apple公式: UIApplication.open

https://developer.apple.com/documentation/uikit/uiapplication/open(_:options:completionhandler:)

動作確認の方法

サンプルアプリにテスト用のボタンを追加して動作確認しました。

  1. サンプルアプリにJSIFを呼び出すボタンを追加
  2. シミュレータまたは実機でアプリを起動
  3. ボタンをタップしてJSIFを実行
  4. 以下を確認:
    • WebViewが閉じるか
    • 外部ブラウザが開くか
    • 正しいURLが表示されるか

確認したログ

[JSIF_CALL] method: openExternalBrowser
[CALLBACK] onPageClosed, reason: OPEN_BROWSER
[DEBUG] WebViewController deinit

ログで以下が確認できれば成功です:

  • JSIFが呼び出されている
  • コールバックが実行されている
  • WebViewControllerが解放されている(deinit)

まとめ

JSIFを追加する手順:

  1. 引数オブジェクトを定義(Codable)
  2. enumにcase追加
  3. パース処理を追加
  4. 実際の処理を実装

Web↔ネイティブ連携は複雑に見えますが、既存のコードパターンに従えば実装できることが分かりました。

Discussion