やっと UIWebView から WKWebView に乗り換えられる話

こんにちは〜! モバイルチームの向井田です。

iOS の UIWebView が deprecated になって数年、弊社の iOS アプリもようやく WKWebView に移行できるようになりました。 そこで、今回は私たちが WKWebView に乗り換えられるようになった理由を語っていきます。

TL;DR

iOS12 から WKWebView でクライアント証明書を使ったSSL通信が正常に動作するようになりました。

kintone モバイルのリニューアル

kintone モバイルが大幅リニューアルし、UIを一新しました 🎉

https://kintone.cybozu.co.jp/update/main/2019-05.html

リニューアルを期に、コードを Objective-C から Swift ベースに書き直しています。 kintone モバイルは JavaScript カスタマイズをサポートするために、大部分が WebView で構成されています。 iOS8 以上は WKWebView を使うように Apple からアナウンスされていますが、旧バージョンでは UIWebView を使い続けていました。 今回のリニューアルで、iOS12 以上であれば WKWebView を使用するようになりました。

なぜ UIWebView を使い続けていたのか

WKWebView への移行には、弊社のクラウドサービスが提供しているセキュアアクセスという機能が関係しています。

セキュアアクセスの説明

弊社のクラウドサービス基盤である cybozu.com が提供するセキュアアクセスは、サーバ側でユーザごとに発行されたクライアント証明書を用いて通信することで、IPアドレス制限がされた環境にも安全にアクセスする仕組みです。

https://www.cybozu.com/jp/service/option/

この機能を kintone モバイルでもサポートするために、WebView のリクエストにクライアント証明書を付与して認証する必要がありました。 旧バージョンでは、NSURLProtocol を継承した CustomHTTPProtocol を使い、UIWebView の通信をハンドルして実現していました。

https://developer.apple.com/library/archive/samplecode/CustomHTTPProtocol/Introduction/Intro.html

WKWebView ではクライアント証明書の認証がサポートされていたはずだったが…

UIWebView の後継である WKWebView では、公式に Authentication Challenge 時に呼ばれる Delegate が用意されました。

optional func webView(_ webView: WKWebView,
           didReceive challenge: URLAuthenticationChallenge,
    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)

https://developer.apple.com/documentation/webkit/wknavigationdelegate/1455638-webview

WKWebView が世に出たとき、私たちはもちろんこの Delegate に飛びつきましたが、この Delegate には重大なバグがあります。 iOS11 以下の場合、Delegate の中で PKSCS#12 形式の証明書を渡しても、Authentication Challenge に失敗します😱😱😱 つまり、WKWebView を使ったクライアント証明書認証による通信は実現できませんでした。

上記のバグは Apple のフォーラムでも報告されており、iOS12 でようやく改修されました。 そのため、私達も iOS12 以上の場合に限り、WKWebView をプロダクトで使用できるようになりました。

WKWebView でクライアント証明書の認証を実装する

めでたく WKWebView を使ったクライアント証明書による認証が正常動作するようになったので、実装を紹介します。 下のサンプルコードは WKNavigationDelegatewebView(_:didReceive:completionHandler:) を実装した例です。

challenge.previousFailureCount でリクエスト毎の認証チャレンジの失敗回数が取得できます。 失敗の上限を決めておかないと、クライアント証明書が無効のときに無限にリクエストを送り続けてしまうので注意です。

challenge.protectionSpace.authenticationMethod でチャレンジする認証の種類が取得できます。 クライアント証明書での認証時は、NSURLAuthenticationMethodClientCertificate で定義された文字列が challenge.protectionSpace.authenticationMethod に入っています。

クライアント証明書による認証が求められた場合は、クライアント証明書から URLCredential を生成し、completionHandler に渡します。

extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

        // 一度でも認証に失敗したらやり直さない
        if challenge.previousFailureCount > 0 {
            completionHandler(.performDefaultHandling, nil)
        }

        // クライアント証明書での認証が必要
        if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
            // クライアント証明書の取り出し
            if let identity = self.loadCertificate(from: certFilename, password: certPassword) {
                let credential = URLCredential(identity: identity, certificates: nil, persistence: URLCredential.Persistence.forSession)

                // クライアント証明書による認証にチャレンジする
                // iOS12 以上でないと正しく動作しない
                completionHandler(.useCredential, credential)
                return
            }
        }
        completionHandler(.performDefaultHandling, nil)
    }

PKSCS#12 形式のクライアント証明書ファイル(.pfx ファイル)から URLCredential を生成するには、ファイルをインポートしてSecIdentity オブジェクトにする必要があります。 SecIdentityPKSCS#12 ファイルに含まれるデジタル証明書と秘密鍵がセットになったオブジェクトです。 SecPKCS12Import(_:_:_:) を使って .pfx ファイルを SecIdentity に変換します。

以下のコードは、プロジェクトファイルに予め配置しておいた .pfx ファイルから SecIdentity を生成する例です。

private func loadCertificate(from filename: String, password: String) -> SecIdentity? {
    guard
        let path = Bundle.main.path(forResource: filename, ofType: "pfx"),
        let pfxData = try? Data(contentsOf: URL(fileURLWithPath: path))
        else {
            return nil
    }

    var result: CFArray?
    let query = [
        kSecImportExportPassphrase as String: password,
    ]

    // result にインポートした結果が格納される
    let status = SecPKCS12Import(pfxData as NSData, query as NSDictionary, &result)
    if (status != errSecSuccess) {
        return nil
    }

    let resultDict = result as? [[String : Any]]
    let identity = resultDict?.first?[kSecImportItemIdentity as String] as! SecIdentity
    return identity
}

WKWebView のインスタンスに delegate をセットするのを忘れないでください。

class ViewController: UIViewController {
    var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad(
        self.webView = WKWebView(frame: self.view.frame)
        self.view.addSubview(self.webView

        // ViewController で Delegate を処理する
        self.webView.navigationDelegate = self
    }
    ...

ここが便利だぞ WKWebView!

今更ですが、WKWebView を導入したことで得たメリットを紹介します。

タップ時の遅延が改善される

UIWebView は、ダブルタップでズームできるようにするために、シングルタップしてから画面が反応するまでに350msほど遅延がありました。 このタップ遅延の問題は多くの開発者を悩ませていたようで、FastClick というサードパーティのライブラリまで登場しました。 UIWebView で構成された旧バージョンの kintone モバイルでもタップ遅延が発生しており、ユーザの操作感が非常に悪いものになる要因でした。

WKWebView ではこの問題が解消されており、Safariと同様に反応が良いアプリを実現することができます。

https://webkit.org/blog/5610/more-responsive-tapping-on-ios/

HTTPステータスコードがハンドルできる

私たちのようにネイティブアプリ内で WebView を使った UI を提供している場合、WebView が受け取ったレスポンス内に含まれているHTTPステータスコードをハンドルしたいケースがあります。 しかし、UIWebView は WebView 内のレスポンスが外部から参照できない形になっており、HTTPステータスコードを取得できない仕様でした。

WKWebView であれば、以下のサンプルのようにして ロードしたいページの HTTP ステータスコードを取得できます。

navigationResponse.responseWKWebView がロードしようとしているページのレスポンスで、URLResponse 型のオブジェクトです。 http通信のレスポンスであれば、navigationResponse.responseURLResponse クラスのサブクラスであるHTTPURLResponse に置換できます。 この HTTPURLResponse が持っている statusCode プロパティが HTTP ステータスコードです。

func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
             decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
    if let response = navigationResponse.response as? HTTPURLResponse {
        if response.statusCode == 200 {
            print("success💯")
        }
        if response.statusCode > 300 {
            print("failure...🤮")
        }
    }
    decisionHandler(.allow)
}

まとめ

UIWebView はパフォーマンスやセキュリティの観点で様々な問題があるので、より安全で高速な WKWebView に乗り換えていきましょう!