こんにちは、開発部 iOSチームの杉田です。
今回は「WKWebViewをHiddenWebViewとして動かす方法」について、ご紹介します。 少々ニッチな記事ですが、背景と合わせて読んでいただければと思います。
背景
あるプロジェクトで特定のURLを開き、ログイン情報をcookieで保存させ、別のURLをWebViewで表示した際も、cookieによりログイン状態が引き継がれた状態で利用できるようにするという要件がありました。
WebView自体は画面上に表示すると、他のViewや表示が触れなくなるなどの問題点があったため、画面上には表示させないという条件を設けました。 今回はその要件を実現するため、どのような実装を行ったのかを紹介したいと思います。 補足情報として、様々な都合によりURL Sessionを使った実装ができなかったため、今回紹介している手法を用いています。
仕様
仕様は下記流れとなります。
メイン画面でWebViewを非表示(Hidden)状態で開く → そのWebViewでログイントークンをヘッダーに付けたURLを読み込む → ログインした扱いとなりログイン状態のcookieが保存される → 別のURLをWebViewで表示した際も、cookieによりログイン状態が引き継がれた状態で利用できる
実装
まず、表示させないWebViewを実装するにあたってWebViewをどう管理するかという問題がありました。今回はWebViewクラスをNSObjectとして扱い、HiddenWebViewとした上で、クラス内でWebViewを設定し、使いたいところで親Viewから呼び出します。 また、HiddenWebViewModelを別途作成し、URLの設定をしています。
HiddenWebViewの実装
1. プロパティの設定
各種プロパティを設定します。 タイマーはWebViewが特定の条件でタイムアウトになったり、異常系でエラーになった際に閉じるように設定しています。タイマー秒数は本実装では5秒としています。
private var webView: WKWebView! private var completion: (() -> Void)? private let timeoutInterval: TimeInterval = 5.0 // タイムアウト秒数 private var timer: Timer? private weak var parentView: UIView? var model: HiddenWebViewModel // コンテンツ内容を管理
2. イニシャライズ
WebViewを設定し、指定された親ビューに追加します。 WebViewのisHiddenをtrueにすることにより、非表示にします。
// MARK: - Initilizer init(parentView: UIView, model: HiddenWebViewModel) { webView = WKWebView(frame: .zero, configuration: MainViewController.shared.configuration) self.model = model super.init() webView.navigationDelegate = self webView.isHidden = true parentView.addSubview(webView) self.parentView = parentView }
3. デイニシャライザ
不要になったWebViewを適切に解放するために、WebViewを使用後に親ビューから削除し、HiddenWebViewインスタンスを適切に解放します。これによって、メモリリークを防ぎます。また、タイマーを無効化します。
// MARK: - Deinitializer deinit { timer?.invalidate() webView.navigationDelegate = nil webView.removeFromSuperview() webView = nil }
4. 読み込み処理
ロードが完了したときに実行するクロージャを設定し、ロードメソッドを呼び出します。
func load(completion: @escaping () -> Void) { self.completion = completion startLoad() }
5. ロード処理
タイマーを設定して、指定された時間内にリクエストが完了しない場合にタイムアウトを処理します。createUrl内の指定されたURLとトークンを使用してHTTPリクエストを設定し、WebViewにロードさせます。
private func startLoad() { // タイマーを設定してタイムアウトを処理 timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: timeoutInterval, repeats: false) { [weak self] _ in self?.handleTimeout() } webView.load(createUrl()) // WebViewにロードさせる }
6.タイムアウト処理
WebViewのロードを停止し、完了処理メソッド(completeAndCleanUp)を実行します。
private func handleTimeout() { webView.stopLoading() completeAndCleanUp() }
7. WebViewの処理完了メソッド(正常系)
WebViewのロードが正常に完了したときに呼び出されます。
また、タイマーを無効化し、完了処理を実行します。
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { timer?.invalidate() completeAndCleanUp() }
8. WebViewの処理完了メソッド(異常系)
WebViewのロードが失敗したときに呼び出されます。
正常系同様、タイマーを無効化し、完了処理を実行します。 異常系のみ別の処理をしたい場合はメソッド内に記載します。
//読み込み途中に起きたエラー func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { timer?.invalidate() // そのままWebViewを閉じる completeAndCleanUp() } // 読み込み開始時にエラー(圏外等) func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { timer?.invalidate() // 例外エラー時もそのままWebViewを閉じる completeAndCleanUp() }
9. 完了処理メソッド
completionクロージャを呼び出して、リクエストの完了を通知します。- WebViewを親ビューから削除して、メモリリークを防ぎます。
private func completeAndCleanUp() { completion?() webView.removeFromSuperview() }
10. URL設定メソッド
WebViewのロードに必要なURLを指定し、URLRequestで返します。 ログイン済み、ログアウト済みで場合を分けて、URLとヘッダーを設定しています。 私の実装事例では、ログイン情報を送るのが目的だったので、”ログイントークン”部分でログイン情報(トークン)設定していました。別の使用用途がある場合は、必要に応じてヘッダーを変えることも可能です。
private func createUrl() -> URLRequest { // URLに条件によるパラメーター追加 let urlStr = model.url switch model { case .authenticated: var urlRequest = URLRequest(url: urlStr) // 必要な情報をヘッダーに追加する urlRequest.addValue("ログイントークン", forHTTPHeaderField: "Sample-Header") return urlRequest case .notAuthenticated: var urlRequest = URLRequest(url: urlStr) // 必要な情報をヘッダーに追加する urlRequest.addValue("ログイントークン", forHTTPHeaderField: "Sample-Header") return urlRequest } }
全体コード
class HiddenWebView: NSObject, WKNavigationDelegate { private var webView: WKWebView! private var completion: (() -> Void)? private let timeoutInterval: TimeInterval = 5.0 // タイムアウト秒数 private var timer: Timer? private weak var parentView: UIView? var model: HiddenWebViewModel // コンテンツ内容を管理 // MARK: - Initilizer init(parentView: UIView, model: HiddenWebViewModel) { webView = WKWebView(frame: .zero, configuration: MainViewController.shared.configuration) self.model = model super.init() webView.navigationDelegate = self webView.isHidden = true parentView.addSubview(webView) self.parentView = parentView } // MARK: - Deinitializer deinit { timer?.invalidate() webView.navigationDelegate = nil webView.removeFromSuperview() webView = nil } func load(completion: @escaping () -> Void) { self.completion = completion startLoad() } private func startLoad() { // タイマーを設定してタイムアウトを処理 timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: timeoutInterval, repeats: false) { [weak self] _ in self?.handleTimeout() } webView.load(createUrl()) // WebViewにロードさせる } private func handleTimeout() { webView.stopLoading() completeAndCleanUp() } // MARK: - WKNavigationDelegate func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { timer?.invalidate() completeAndCleanUp() } //読み込み途中に起きたエラー func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { timer?.invalidate() // そのままWebViewを閉じる completeAndCleanUp() } // 読み込み開始時にエラー(圏外等) func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { // そのままWebViewを閉じる completeAndCleanUp() } private func createUrl() -> URLRequest { // URLに条件によるパラメーター追加 let urlStr = model.url switch model { case .authenticated: var urlRequest = URLRequest(url: urlStr) // 必要な情報をヘッダーに追加する urlRequest.addValue("ログイントークン", forHTTPHeaderField: "Sample-Header") return urlRequest case .notAuthenticated: var urlRequest = URLRequest(url: urlStr) // 必要な情報をヘッダーに追加する urlRequest.addValue("ログイントークン", forHTTPHeaderField: "Sample-Header") return urlRequest } } }
HiddenWebViewModelの実装
こちらはモデルでログイン状態を管理し、状態によって設定するURLをしています。
import Foundation enum HiddenWebViewModel { case authenticated // ログイン状態の場合 case notAuthenticated // ログアウト状態の場合 var url: URL { let urlString: String switch self { case .authenticated: urlString = "https://www.sample.authenticated" case .notAuthenticated: urlString = "https://www.sample.notAuthenticated" } guard let url = URL(string: urlString) else { fatalError() } return url } }
呼び出し方
今回はMainViewControllerとして、UITabBarControllerを継承したクラスを用意しています。 呼びたい場所で、HiddenWebViewのインスタンス生成をすると画面にはWebViewは表示されないまま、WebViewの処理が走ります。 画面表示が無いため、「HiddenWebView(authenticated) load complete」というログを出して、正常にWebViewがロードされているか確認しています。
import UIKit class MainViewController: UITabBarController { private var hiddenWebView: HiddenWebView! override func viewDidLoad() { super.viewDidLoad() } func viewDidAppear() { // HiddenWebViewのインスタンス生成 hiddenWebView = HiddenWebView(parentView: self.view, content: .authenticated) hiddenWebView.load() { print("HiddenWebView(authenticated) load complete") } } }
まとめ
今回は上記実装で、画面にWebViewを表示させずに特定のURLを開きWebViewの処理を走らせるということを実現しました。様々な都合によりURL Sessionを使った実装ができないなど、条件がある場合に裏技として、WebViewの処理を裏で読み込むというニッチなパターンとなります。 少しでも参考になる方がいらっしゃれば幸いです。
開発環境
- Xcode15.2
- Swift5