Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

iOSのWebKitをSwiftUIとSwift Concurrencyで今風にできる!?

こんにちは、YappliでiOSエンジニアをしているカンです。

最近、TechBoosterの書籍「プロと読み解くモバイル最前線~アプリを支える最新技術~」を読んでWebKitでのSwiftUI、Swift Concurrencyへの移行が取り上げられており、その内容を紹介してみようかと思います。

booth.pm

概要

iOSにおけるSwiftUIフレームワーク登場は2019年9月Swift Concurrencyのリリースは2021年9月です。 当初にあった問題も改善されてつつあり、弊社でもSwift ConcurrencyやSwiftUIを積極的に採用しています。

ただ古くから存在するフレームワークを用いた機能への適用だと、単に置き換えるというのが困難な場合もあります。

今回はその中でも歴史あるWebKitでのWebViewを対象として、UIKit側で提供されているWKWebViewをSwiftUIで実装し、加えてCookie周りを扱う処理をasync/awaitで置き換えるところまで紹介してみようかと思います。

Swift Concurrencyとは?

Swiftプログラミング言語で非同期処理を扱うための新しい機能セットです。これは、Swift 5.5から導入されています。

従来のSwiftの非同期処理は、GCD(Grand Central Dispatch)フレームワークを使用して実装されていました。しかし、GCDは複雑でコードが読みにくくなる可能性があり、エラーが発生しやすいという問題がありました。Swift Concurrencyは、これらの問題を解決するために導入されました。

Swift Concurrencyには、次のような新しい機能が含まれます。

  • async/await: 非同期関数を定義し、awaitキーワードを使用して非同期的な処理を同期的に書くことができます。
  • Actors: 並列処理を行うオブジェクト指向プログラミングパターンです。Actorは状態をカプセル化し、同期されたアクセスを提供します。

これらの機能により、Swift Concurrencyを使用することで、よりシンプルで安全な非同期処理を実装することができます。

developer.apple.com

WebKitをSwiftUIで扱う

WebKitは、iOSにおけるWebブラウジング機能を提供するフレームワークであり、WebViewを使用してアプリ内でWebコンテンツを表示することができます。

作成するアプリはWebViewが読み込み中ではインジケータが表示され、読み込み完了後にWebページを表示してインジケータを非表示にするシンプルな実装です。

UIKitでこれまで実装

UIKitで基本的なWebViewでWebページを表示するコードは以下のような感じでしょうか(一部コード割愛します)

import UIKit
import WebKit

class ViewController: UIViewController {

    private lazy var webView: WKWebView = {
        let webView = WKWebView(frame: view.frame)
        webView.navigationDelegate = self
        return webView
    }()

    private lazy var indicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView(style: .medium)
        indicator.center = view.center
        indicator.hidesWhenStopped = true
        return indicator
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(webView)
        view.addSubview(indicator)
                // ViewのConstraintを設定するメソッド
        setupView()
    }

    override func viewDidAppear(_ animated: Bool) {
        let url = URL(string: "https://yapp.li/")
        let request = URLRequest(url: url!)
        webView.load(request)
    }
        
        /// 略
        /// ・・・
}

extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        showIndicator()
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        hideIndicator()
    }
}

SwiftUIでの実装

WebKitWKWebViewUIViewの子クラスで、SwiftUIのViewでそのまま扱うことができません。

SwiftUIは宣言的なUIフレームワークであるのに対し、UIKitはイベント駆動のUIフレームワークです。

delegateを経由したイベント通知を上手く、SwiftUIと連携させていく必要があります。

UIViewをSwiftUIで扱う方法としてUIViewRepresentableがあります。

UIViewRepresentable | Apple Developer Documentation

下記のような実装で同じような動作のものができました。

import SwiftUI
import WebKit

struct ContentView: View {
    @State var isLoading: Bool = false

    var body: some View {
        ZStack {
            WebView(isLoading: $isLoading)
            if isLoading {
                ProgressView()
            }
        }
    }
}

struct WebView: UIViewRepresentable {
    @Binding var isLoading: Bool

    func makeUIView(context: Context) -> WKWebView {
        let url = URL(string: "https://yapp.li/")
        let request = URLRequest(url: url!)
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator
        webView.load(request)
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {}

    func makeCoordinator() -> Coordinator {
        return Coordinator(owner: self)
    }
}

class Coordinator: NSObject, WKNavigationDelegate {
    private let owner: WebView

    init(owner: WebView) {
        self.owner = owner
        super.init()
    }

    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        owner.isLoading = true
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        owner.isLoading = false
    }
}

ここでのCoordinatorとは何者でしょうか?

SwfitUIのViewはstructであるため、Delegateプロトコルを適合することができません。

そのため、UIViewRepresentableではCoordinatorと呼ばれる仕組みから適合させます。

Coordinator | Apple Developer Documentation

makeCoordinator()がmakeUIView()よりも先に呼び出されるため、そこでCoordinatorを作成してcontextにセットされたcoordinatorをdelegateにセットします。

func makeUIView(context: Context) -> WKWebView {
    let url = URL(string: "https://yapp.li/")
    let request = URLRequest(url: url!)
    let webView = WKWebView()
    webView.navigationDelegate = context.coordinator
    webView.load(request)
    return webView
}

func makeCoordinator() -> Coordinator {
    return Coordinator(owner: self)
}

そうすることでCoordinatorで適合したWKNavigationDelegateからのイベント通知をWebViewに伝えることができます。

class Coordinator: NSObject, WKNavigationDelegate {
    private let owner: WebView

    init(owner: WebView) {
        self.owner = owner
        super.init()
    }

    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        owner.isLoading = true
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        owner.isLoading = false
    }
}

Cookieを扱うメソッドをasync/awaitに置き換え

WebViewをSwiftUIで扱える状態となったので、次にSwift Concurrencyを用いてWebKitでCookieを扱う処理を置き換えてみます。

CookieをWKWebViewから取得し、条件に該当する Cookieを削除するケースを想定してみましょう。

async/awaitを用いない場合、下記のような感じかと思います。

let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore

httpCookieStore.getAllCookies() { (cookies) in
    if let cookie = cookies.first(where: { $0.domain.contains("hoge") }) {
        httpCookieStore.delete(cookie) {
            print("🍻:Delete Cookie \(cookie.name)")
        }
    }
}

Cookie一覧の取得、削除がそれぞれコールバックで呼び出すことになるため、より詳細にCookieを扱う場合には読み解きづらくコールバック地獄の要因にもなりかねません。

async/awaitでCookieは取得できるため下記のように書き換えることができます。

let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore
let cookies = await httpCookieStore.allCookies()

if let cookie = cookies.first(where: { $0.domain.contains("hoge") }) {
    await httpCookieStore.deleteCookie(cookie)
    print("🍻:Delete Cookie \(cookie.name)")
}

上から読み進めるだけで、Cookieを取得して条件に当てはまるドメインをもつCookieを削除してという処理が読み解きやすいですね。

まとめ

今回はWebKitの実装をSwiftUIとSwift Concurrencyで置き換えてどんなものかを確認してみました。

SwiftUI側にもWebViewが来てくれるといいですね。

紹介した書籍では他にも同じテーマでKVOを用いて、WebViewの戻る・進むも含めた実装例もありました。また、別のテーマではSwiftマクロがiOS周りで掲載されていました。こちらも非常に面白い内容でしたので、機会あれば改めて紹介してみたいですね。

興味ある方は是非手に取ってみてください。