こんにちは〜! モバイルチームの向井田です。
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
を使ったクライアント証明書による認証が正常動作するようになったので、実装を紹介します。
下のサンプルコードは WKNavigationDelegate
の webView(_: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
オブジェクトにする必要があります。
SecIdentity
は PKSCS#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.response
は WKWebView
がロードしようとしているページのレスポンスで、URLResponse
型のオブジェクトです。
http通信のレスポンスであれば、navigationResponse.response
は URLResponse
クラスのサブクラスである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
に乗り換えていきましょう!