RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

UIWebViewからWKWebViewへのリプレイス作業が完了したのでまとめてみた

APP / UX チームで iOS アプリを担当しています玉置(@tamappe)です。

今回はレアジョブアプリでリリース当初から使われていた UIWebView を全て WKWebView にリプレイスできましたので、その時に取り組んだ事を知見としてまとめることにしました。 iOS アプリ開発において UIWebView の撲滅作業は一つの鬼門ですね。

リプレイスの背景

UIWebView とは Apple が提供するアプリ内ウェブブラウザの機能を持つ UIKit です。 簡単にいえば、アプリ内ウェブビューを実現するものになります。

ですが Apple が去年リリースした iOS13 の誕生とともに、正式に2020年4月からほぼ使えなくなることになります。 UIWebView が使えなく代わりに iOS8 から追加された WKWebView を使うようにと強制されるようになりました。新規アプリに至っては2020年4月以降から UIWebView を使ったアプリの申請はリジェクトが確定しています。既存アプリに関しても1度警告が入るだけですが、2020年12月末以降からはアップデートの申請が出せなくなります。

Updating Apps that Use Web Views

レアジョブアプリも例に漏れず UIWebView を使っている画面がありますので、今回を機に WKWebView へのリプレイスを実施することにしました。

注意すべきこと

では、 WKWebView へのリプレイス作業に関してやるべきことと注意すべきことをを箇条書きにします。

  1. UIWebView のインスタンスを WKWebView のインスタンスに置き換える
  2. WKWebView でのページのローディングで WKNavigationDelegate を使うようにする
  3. アプリ側で JavaScript 実行(同期)をしている箇所は、 WKWebView の場合は非同期(コールバック)になる
  4. ネイティブ画面でログインAPIを叩いている場合、 Cookie の共有の扱いに注意する
  5. UIWebView で UserAgent の操作をしている場合には、取得の仕方を考え直す

以上、5点になりました。

それでは一つずつ見ていきます。

UIWebView のインスタンスを WKWebView のインスタンスに置き換える

ほとんどの場合は UIWebView は storyboard から使って操作していたと思います。それに対して、 WKWebView は Xcode 10 まで Apple のバグにより storyboard から参照できませんでした。そのため、Xcode 10 まではコード上で WKWebView のインスタンスを生成するしかありませんでした。それが Xcode 11 からは Apple がこのバグを直したためか storyboard 上で使ってもビルドエラーが起こらなくなりました。

そのため、「だいたいの場合」は storyboard から WKWebView を使えば置き換え可能になりました。

UIWebView

f:id:qed805:20200223190550p:plain
UIWebViewの場合

WKWebView

f:id:qed805:20200223190654p:plain
WKWebViewの場合

WKWebView の背景色のデフォルトはグレーがかかっています。これだけで WKWebView への置き換えが可能ですが、後述する Cookie の共有ではこのロジックが使えなくなるのでコードでの生成も記載します。

var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        let webview = WKWebView(frame: .zero, configuration: configuration)
        return webview
    }()

computed property を使ってクロージャーの中で初期化宣言すればそのままプロパティとして利用できます。ただし、これだけでは画面上の view には乗らないのでどこかで乗せないといけません。

    override func viewDidLoad() {
        super.viewDidLoad()
        setupWKWebViewLayout()
    }

    private func setupWKWebViewLayout() {
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0).isActive = true
        webView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
        webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
        webView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true
    }

オートレイアウトを使いたいので translatesAutoresizingMaskIntoConstraints を false にしています。コードから生成する場合はこれで事足ります。

WKWebView でのページのローディングで WKNavigationDelegate を使うようにする

次にウェブブラウザのローディングで使うデリゲートメソッドが変わりますので、それぞれ置き換える必要が出てきます。ものすごく省略していますが、UIWebView と WKWebView とでこのように変わります。

/// UIWebView
extension ViewController: UIWebViewDelegate {
    func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebView.NavigationType) -> Bool {
        guard let host = request.url?.host else {
            // ページのローディングを許可しない
            return false
        }
        // ページのローディングを許可する
        return true
    }
}

/// WKWebView
extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let host = navigationAction.request.url?.host else {
            // ページのローディングを許可しない
            decisionHandler(.cancel)
            return
        }
        // ページのローディングを許可する
        decisionHandler(.allow)
        return
    }
}

大きく異なるのが UIWebView ではページの読み込みを許可するかどうかは Bool で判断するところが、 WKWebView では decisionHandler で処理するように変わります。 またロードしたページの url は request からではなく navigationAction から取得するようになりました。こちらは気をつければいいだけでした。

アプリ側で JavaScript 実行(同期)をしている箇所は、 WKWebView の場合は非同期(コールバック)になる

UIWebView での JavaScript 実行は同期的でしたが、 WKWebView では非同期的に変わります。

/// UIWebView
let string = webView.stringByEvaluatingJavaScript(from: "JavaScriptのスクリプト")

/// WKWebView
webView.evaluateJavaScript("JavaScriptのスクリプト", completionHandler: { result, error in
    if let result = result as? String {
        let string = result
    }
})

そのため、戻り値を String で返す関数がある場合はロジックが破綻してしまうので completionHandler などを用意して関数にコールバック引数を用意してあげる必要が出てきます。ちなみに非同期を同期的に無理やり変換しようとするとデットロックしてしまうので注意です。

ネイティブ画面でログインAPIを叩いている場合、 Cookie の共有の扱いに注意する

これが一番大変でした。 アプリ自体にネイティブ実装のログイン画面が存在しログインAPIを使ってログインしています。そして、レアジョブアプリはレッスンルームと教材の部分でアプリ内WebViewを使っています。このアプリ内 WebView はクッキー共有が前提の設計になっていて、ログインAPIで取得した sessionId をアプリ内 WebView に渡して表示させています。ですので、アプリがログイン中であればアプリ内 WebView はログインを維持していないといけないです。 (途中でセッションが切れてしまった場合はログインページを表示させています)

UIWebView でもこの Cookie 共有が実装されていましたので、 WKWebView でも Cookie 共有が実装されていなければなりませんでした。結論からいえば、これは解決しました。 UIWebView の時に Cookie のセットができていました。 HTTPCookieStorage に保存すれば共有できます。

    class func setCookie(_ cookie: [HTTPCookiePropertyKey: Any]) {
        let newCookie = HTTPCookie(properties: cookie)
        // HTTPCookieStorageにクッキーを保存する
        HTTPCookieStorage.shared.setCookie(newCookie!)
    }

このあとに WKWebView で保存した Cookie をセットしなければ行けなかったのですが、どうやってセットすればいいのかが情報が少なすぎてわかりにくかったです。

答えは WKProcessPool に存在していました。この WKProcessPool を WKWebView の WKWebViewConfigurationインスタンスにセットして WKWebView を生成すれば Cookie を設定できます。

extension WKProcessPool {
    static let shared = WKProcessPool()
}

class ViewController: UIViewController {
    var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)
        return webview
    }()

    /*
     中略
     */
}

ちなみに WKWebView には configuration プロパティが存在するからコードではなく storyboard から @IBOutlet で接続してセットしなおせばいいのでは?と思われるかも知れませんが、 WKWebView のインスタンスを生成した後に WKProcessPool.shared を設定しても共有されません。

var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        /// これは共有される
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)
        /// これは共有されない
        webview.configuration.processPool = WKProcessPool.shared
        webview.backgroundColor = UIColor.white
        return webview
    }()

つまり、 Cookie を維持したい場合には storyboard にある WKWebView では実現できなく、コード生成でのみ実現可能です。これは謎の仕様ですが、ハマリポイントでもありますので注意が必要です。

最後に

このような手順を踏むことで WKWebView へのリプレイス作業が完了しました。 UIWebView に未練はありませんが、もうちょっと WKWebView の使い勝手を改善してほしいですね。まだ UIWebView を使っているアプリがあればこちらの記事が役に立てば幸いです。

補足

レアジョブアプリは RxSwift という外部ライブラリを使っていて、この RxSwift のライブラリの中でも UIWebView が使われていました。 正確には RxSwift の RxCocoa で UIWebView が使われています。

github.com

もちろん今回の Appleガイドラインにおいて、外部ライブラリに含まれる UIWebView も例外ではありません。 ガイドラインの対象になりますのでこの問題をどうしようかと悩んでいました。 ですが先月3月に、RxSwiftのライブラリがこのガイドラインに対応したバージョンをリリースしたそうです。

https://github.com/ReactiveX/RxSwift/releases/tag/5.1.0

RxSwift と RxCocoa のバージョンを 5.1.0 にアップグレードすると UIWebView がなくなっていることが分かります。 これで正式に UIWebView の撲滅が達成したことになりますね!

では、引き続きレアジョブアプリの改善を続けていこうと思います。いつもながら長文になりましたが最後まで読んでいただきありがとうございます。