61
46

More than 5 years have passed since last update.

iOS(swift)ガワアプリの作成で色々苦労した話

Last updated at Posted at 2018-11-10

さいしょに

iOSでWebページを表示するいわゆるガワアプリの作成でつまずいたことのまとめです。
とりあえず動作はしていますがこの解決法がいいのかはわかりません...

今回はiOS11以降はWKWebView、iOS10以前のバージョンではUIWebViewを利用しました。

POSTリクエスト

WKWebviewでは割と有名な話なのかしれませんがここに記載されているように、POSTのhttpBodyがnilになるというバグがあります。

iOS11以降

iOS11以降は解決しているようで下記のようにiOS11では普通にPOSTできます。

var request = URLRequest(url: URL(string: "https://www.xxxxx")!)
request.httpMethod = "POST"
bodyData = "A=Value"
request.httpBody = bodyData.data(using: .utf8)!
webView.load(request)//WKWebView

iOS10以前

iOS10以前の場合はここにあるように下記の方法でURLSession経由で表示できるそうなのですが、今回表示するWebページではログイン処理があり通信時にWKWebViewとURLSessionでセッションIDを共有しないといけないのですが、後述しますがセッションIDの取得ができずこの方法が使えませんでした。

var request = URLRequest(url: URL(string: "https://www.xxxxx")!)
request.httpMethod = "POST"
bodyData = "A=Value"
request.httpBody = bodyData.data(using: .utf8)!
let task = URLSession.shared.dataTask(with: request) { data, response, error in
    if let data = data, let response = response {
        DispatchQueue.main.async {
            webView.loadHTMLString(html, baseURL: URL(string: "https://www.xxxx")!)//WKWebView
        }
    } 
}
task.resume()

解決策としてなくなくUIWebViewを使いました。(iOS10以前では表示は全てUIWebViewでするようにしました。)

var request = URLRequest(url: URL(string: "https://www.xxxxx")!)
request.httpMethod = "POST"
bodyData = "A=Value"
request.httpBody = bodyData.data(using: .utf8)!
webView.loadRequest(request)//UIWebView

JavaScriptのアラート表示

WKWebViewの場合

ここに記載されているようにWKWebViewでJavaScriptのアラートを表示する際は、下記のようにWKUIDelegateを設定する必要があります。

extension ViewController: WKUIDelegate {

    func webView(_ webView: WKWebView,
                 runJavaScriptAlertPanelWithMessage message: String,
                 initiatedByFrame frame: WKFrameInfo,
                 completionHandler: @escaping () -> Void) {
        let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
        let otherAction = UIAlertAction(title: "OK", style: .default) {
            action in completionHandler()
        }
        alertController.addAction(otherAction)
        present(alertController, animated: true, completion: nil)
    }

    func webView(_ webView: WKWebView,
                 runJavaScriptConfirmPanelWithMessage message: String,
                 initiatedByFrame frame: WKFrameInfo,
                 completionHandler: @escaping (Bool) -> Void) {
        let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) {
            action in completionHandler(false)
        }
        let okAction = UIAlertAction(title: "OK", style: .default) {
            action in completionHandler(true)
        }
        alertController.addAction(cancelAction)
        alertController.addAction(okAction)
        present(alertController, animated: true, completion: nil)
    }

    func webView(_ webView: WKWebView,
                 runJavaScriptTextInputPanelWithPrompt prompt: String,
                 defaultText: String?,
                 initiatedByFrame frame: WKFrameInfo,
                 completionHandler: @escaping (String?) -> Void) {
        let alertController = UIAlertController(title: "", message: prompt, preferredStyle: .alert)
        let okHandler = { () -> Void in
            if let textField = alertController.textFields?.first {
                completionHandler(textField.text)
            } else {
                completionHandler("")
            }
        }
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) {
            action in completionHandler("")
        }
        let okAction = UIAlertAction(title: "OK", style: .default) {
            action in okHandler()
        }
        alertController.addTextField() { $0.text = defaultText }
        alertController.addAction(cancelAction)
        alertController.addAction(okAction)
        present(alertController, animated: true, completion: nil)
    }
}

UIWebViewの場合

特に何も設定せずに表示されました。

別ウィンドウでのページ遷移

Webページを表示する際、window.open/closeが反応しないのでそれぞれ自前で処理してやる必要があります。

WKWebViewの場合

ここを参考に下記のような処理で動作しました。

extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView,
                 createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWindowFeatures) -> WKWebView?
    {
        if navigationAction.targetFrame?.isMainFrame != true {
            // 別Window表示の場合
            // 開く処理
            let newWebView = WKWebView(frame: webView.frame,
                                       configuration: configuration)
            newWebView.translatesAutoresizingMaskIntoConstraints = true
            newWebView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            newWebView.load(navigationAction.request)
            newWebView.uiDelegate = self
            webView.superview?.addSubview(newWebView)
            return newWebView
        }

        return nil
    }

    func webViewDidClose(_ webView: WKWebView) {
         // 閉じる処理
        webView.removeFromSuperview()
    }
}

UIWebViewの場合

ここを参考に下記の処理で動作しましたが、この方法でいいのかは...(JavaScriptを書き換えてopen/closeの処理を取得しています)

extension ViewController: UIWebViewDelegate {

    func webViewDidFinishLoad(_ webView: UIWebView) {
        // window.open/colse用に書き換え
        webView.stringByEvaluatingJavaScript(from: "window.close = function () {window.location.assign('close://' + window.location);};")
        webView.stringByEvaluatingJavaScript(from: "window.open = function (url, d1, d2) {window.location = 'open://' + url;};")
    }

    func webView(_ webView: UIWebView,
                 shouldStartLoadWith request: URLRequest,
                 navigationType: UIWebViewNavigationType) -> Bool {
        if isWindowOpen(url: request.url!)  {
            // 別Window表示の場合
            // 開く処理
            let newWebView = UIWebView(frame: webView.frame)
            newWebView.translatesAutoresizingMaskIntoConstraints = true
            newWebView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            newWebView.scalesPageToFit = true
            newWebView.delegate = self
            let newRequest = URLRequest(url: convertWindowOpenUrl(url: request.url!))
            newWebView.loadRequest(newRequest)
            webView.superview?.addSubview(newWebView)
            return false
        }

        if isWindowClose(url: request.url!) {
            // 閉じる処理
            webView.removeFromSuperview()
            return false
        }

        return true
    }

    private func isWindowOpen(url: URL) -> Bool {
        if url.scheme == "open"  {
            return true
        }
        return false
    }

    private func isWindowClose(url: URL) -> Bool {
        if url.scheme == "close"  {
            return true
        }
        return false
    }

    private func convertWindowOpenUrl(url: URL) -> URL {
        //open://で書き換えられているURLをここで元に戻す
        return URL(string: "https://wwww.xxxxx")!
    }
}

セッションIDの保持

今回のアプリにはWebページでログイン処理があり、アプリ再起動時にもログイン状態を保持する必要がありました。アプリを終了した際はセッションIDが破棄されるのでこれをアプリ側で保持して、再起動時の初回リクエスト時に設定してやる必要があります。
ここが一番苦労しました...

セッションIDの取得

iOS11以降

iOS11以降の場合は簡単に取得することができました。ログイン処理後のページ遷移時に下記のように処理。
残念ながらこのhttpCookieStoreはiOS11以降しか使えません。

extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
        cookieStore.getAllCookies() { (cookies) in
            for cookie in cookies {
                if cookie.domain == "www.xxxxxx" &&
                    cookie.name == "SESSION_ID" {
                    // UserDefaultsに保存
                    let cookieData = NSKeyedArchiver.archivedData(withRootObject: cookie)
                    UserDefaults.standard.set(cookieData, forKey: "Cookie")
                    UserDefaults.standard.synchronize()
                }
            }
        }
    }
}

iOS10以前

レスポンスヘッダにセッションIDがあれば下記の方法で取得できるようですが、今回のWebページでは含まれていなかったので下記の方法では取得できませんでした。

extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationResponse: WKNavigationResponse,
                 decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
        let response = navigationResponse.response as! HTTPURLResponse
        let cookies = HTTPCookie.cookies(withResponseHeaderFields: response.allHeaderFields as! [String : String],
                                         for: response.url!)
        for cookie in cookies {
            if cookie.domain == "www.xxxxxx" &&
                cookie.name == "SESSION_ID" {
                // UserDefaultsに保存
                let cookieData = NSKeyedArchiver.archivedData(withRootObject: cookie)
                UserDefaults.standard.set(cookieData, forKey: "Cookie")
                UserDefaults.standard.synchronize()
            }
        }
        decisionHandler(.allow)
    }
}

HTTPCookieStorageというものがあるようで、下記の方法も試してみましたがセッションIDは取得できませんでした。

/// viewDidloadでHTTPCookieStorage.shared.cookieAcceptPolicy = .always設定

extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.xxxx")!) {
            for cookie in cookies {
                if cookie.domain == "www.xxxxxx" &&
                    cookie.name == "SESSION_ID" {
                    // UserDefaultsに保存
                    let cookieData = NSKeyedArchiver.archivedData(withRootObject: cookie)
                    UserDefaults.standard.set(cookieData, forKey: "Cookie")
                    UserDefaults.standard.synchronize()
                }
            }
        }
    }
}

悩んだ末にUIWebViewで下記の方法でセッションIDを取得することができました。

/// viewDidloadでHTTPCookieStorage.shared.cookieAcceptPolicy = .always設定

extension ViewController: UIWebViewDelegate {
    func webView(_ webView: UIWebView,
                 shouldStartLoadWith request: URLRequest,
                 navigationType: UIWebViewNavigationType) -> Bool {
        if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.xxxx")!) {
            for cookie in cookies {
                if cookie.domain == "www.xxxxxx" &&
                    cookie.name == "SESSION_ID" {
                    // UserDefaultsに保存
                    let cookieData = NSKeyedArchiver.archivedData(withRootObject: cookie)
                    UserDefaults.standard.set(cookieData, forKey: "Cookie")
                    UserDefaults.standard.synchronize()
                }
            }
        }
        return true
    }
}

セッションIDの設定

保持したセッションIDをアプリ再起動時に設定する処理も色々苦労しました...

WKWebViewの場合

iOS11以降では下記で設定できると思ったのですができず

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        guard let cookie = getCookie() else {
            return
        }
        let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
        cookieStore.setCookie(cookie) {
            var request = URLRequest(url: URL(string: "https://www.xxxxx")!)
            request.allHTTPHeaderFields = ["Cookie":"SESSIONID=\(cookie.value)"]
            self.webView.load(request)
        }
    }

ここを参考に下記の方法で設定できました。

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        guard let cookie = getCookie() else {
            return
        }
        let userContentController = WKUserContentController()
        let script = "document.cookie='_session_id=\(cookie.value); domain=\(cookie.domain); path=\(cookie.path);"
        let cookieScript = WKUserScript(source: script,
                                        injectionTime: .atDocumentStart,
                                        forMainFrameOnly: false)
        userContentController.addUserScript(cookieScript)

        let configuration = WKWebViewConfiguration()
        configuration.userContentController = userContentController
        webView = WKWebView(frame: view.bounds, configuration: configuration)
        var request = URLRequest(url: URL(string: "https://www.xxxxx")!)
        request.allHTTPHeaderFields = ["Cookie":"SESSIONID=\(cookie.value)"]
        webView.load(request)
    }

UIWebViewの場合

UIWebViewの場合は下記の方法で設定できました。

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        HTTPCookieStorage.shared.cookieAcceptPolicy = .always
        guard let cookie = getCookie() else {
            return
        }
        HTTPCookieStorage.shared.setCookie(cookie)
        var request = URLRequest(url: URL(string: "https://www.xxxxx")!)
        request.allHTTPHeaderFields = ["Cookie":"SESSIONID=\(cookie.value)"]
        webView.loadRequest(request)
    }

JavaSriptでの値取得(2018/11/12追記)

Webページのformの値などを取得したい場合があります。
下記のようなWebページのformの値の取得法について記載します。

<html>
    <body>
        <h1>TEST</h1>
        <form action="/test" name="test_form" method="POST">
            <input type="text" name="test_name1">
            <input type="text" name="test_name2">
            <input type="submit">
        </form>
    </body>
</html>

WKWebViewの場合

下記の方法で取得できました。

webView.evaluateJavaScript("document.test_form.test_name1.value") { (result, error) in
        if let text = result as? String {
                print(text)// test_name1の値
        }
}

即時関数を使えば下記のように複数の値も取得できました。

let script =
        """
        (function () {
            var list = [];
            list.push(document.test_form.test_name1.value);
            list.push(document.test_form.test_name2.value);
            return list;
        })();
        """
webView.evaluateJavaScript(script) { (result, error) in
        if let list = result as? [String] {
                print(list)
        }
}

UIWebViewの場合

下記の方法で取得できました。

if let text = webView.stringByEvaluatingJavaScript(from: "document.test_form.test_name1.value") {
        print(text)// test_name1の値
}

stringByEvaluatingJavaScriptは返り値がString型なので上記のような即時関数を使用すると空文字が返ってきました。

下記のように配列を連結して文字列に変換すれば複数の値の取得ができました。

let script =
        """
        (function () {
            var list = [];
            list.push(document.test_form.test_name1.value);
            list.push(document.test_form.test_name2.value);
            return list.join(',');
        })();
        """
if let text = webView.stringByEvaluatingJavaScript(from: script) {
        print(text)// test_name1の値,test_name2の値
}

もしくは下記のように取得したい数分呼び出せば複数の値も取得可能です。

if let text = webView.stringByEvaluatingJavaScript(from: "document.test_form.test_name1.value") {
        print(text)// test_name1の値
}
if let text = webView.stringByEvaluatingJavaScript(from: "document.test_form.test_name2.value") {
        print(text)// test_name2の値
}

JavaScriptでアクセスできる値であれば上記の方法で取得できるはずです。Javaサーブレットなどで保持している値はJavaScriptでアクセスできないので取得できないと思います。(たぶん...)

UserAgentの書き換え(2018/12/13追記)

Web側でアプリからのアクセスであることを判定するために既存のUAを取得して末尾に何か文字列を足してUAを書き換える方法を紹介します。UAの変更はすぐにできたのですが、既存のUAの末尾に足すのに少し手間取ったので...

WKWebViewの場合

ここを参考に下記の方法でできました。

var dummyWebView: WKWebView? = WKWebView()
dummyWebView.evaluateJavaScript("navigator.userAgent") { (result, error) in
    dummyWebView = nil 
    if let userAgent = result as? String {
        self.webView.customUserAgent = "\(userAgent)suffix"
    }
}

ポイントは使用するWebViewとは別のWebViewを生成して取得することです。navigator.userAgent をしてしまうとそのWebViewのuserAgentの書き換えができなくなります。あとは変数を宣言し、クロージャの処理が終わるまで保持することです。
下記のような方法だとクロージャの処理が終わるまでにWebViewが解放されてしまいresultがnilになってしまいます。

WKWebView().evaluateJavaScript("navigator.userAgent") { (result, error) in
    if let userAgent = result as? String {
        self.webView.customUserAgent = "\(userAgent)suffix"
    }
}

UIWebViewの場合

ここを参考に下記の方法でできました。

if let userAgent = UIWebView().stringByEvaluatingJavaScript(from: "navigator.userAgent") {
    UserDefaults.standard.register(defaults: ["UserAgent":"\(userAgent)suffix"])
}

ポイントは使用するWebViewを生成する前に上記の処理を行うことです。

Web側からアプリのメソッドを呼び出す(2018/12/13追記)

Web側から任意のタイミングでswiftのメソッドを呼び出せないかと悩んだ結果、下記の方法でどうにかできました。
WKWebViewしか使わない場合はここのようにWKUserContentControllerを利用すればできるようです。

ここを参考に無理矢理ですがなんとか動くものはできました。

location.href='testapp://value';

Web側でメソッドを呼び出したい箇所で上記の処理を書きます。
アプリ側でUIWebViewならfunc webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest,navigationType: UIWebViewNavigationType) -> BoolWKWebViewならfunc webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)でURLのスキームがtestappか判定し任意のメソッドを呼び出します。
下記のようにurlからvalueの部分を取り出しメソッドを呼び出せば値を渡せます。valueの部分をJSON文字列にすれば複数の値も渡すことができます。

let value = url.absoluteString.replacingOccurrences(of: "testapp://", with: "")
testAction(value: value)
private func testAction(value: String) {

}

とりあえず動きましたが、これが良い実装とは思えません...

さいごに

とりあえずは動作するものができたのですが、この実装方法でいいのかは不明です。

どなたかもっといい方法を知っている方いればぜひ教えてください。

作った感想としてはWeb側の回収が可能であればできるだけアプリ専用のものに作り直してもらった方がいいです。アプリ側だけで無理矢理やるのは保守とか考えるとかなり無茶な気がします。(まあ、大抵そんな簡単に改修できないと思いますが...)
複雑なサイトでガワアプリなんて作るもんじゃないなと思いました。

参考

61
46
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
61
46