タマネギプログラマーの雑記

たまねぎ剣士的なニュアンス

WKWebViewのログイン保持

iOSでWebページを表示するために使用するWKWebView
今回、WKWebViewでのログイン保持でハマったためワークアラウンドを書いておく

今回想定するアプリ

f:id:gaoxin-xixxix:20171004234507p:plain

一度webviewに行った後、webview内でログインを行う。 その後もう一度viewに戻り、再度webviewを開く。
この時、ログインが保持されていないという現象にあった。

どうしてこうなる

どうもセッションcookieをWKWebViewがストアしておかないことによるものらしい。

nsmutableurlrequest - Losing cookies in WKWebView - Stack Overflow

iOS WKWebView Tips | Professional Programmer

必要なcookieを調べて、WKWebViewから抜いて直接NSHTTPCookieStorageに突っ込めたりしないかと考えたが、そもそもWKWebViewからセッションcookieを抜くことができないっぽい。

解決策

解決策というか、ワークアラウンドなのだが、なんならバッドノウハウかもしれないので、ご意見があったらうかがいたいのだが。 今回は、上記二つ目のリンクでも述べられているWKProcessPoolを活用することにした。

WKProcessPoolとは、複数のwebview間でWeb Content Processを共有するためのものだ。

WKProcessPool - WebKit | Apple Developer Documentation

これを活用すると、cookieが複数のwebviewで(確かめてないから本当かわからないが)リアルタイムに共有されるらしい。
が、リアルタイムというのは今回はあまり重要ではない。
今回重要なのはWeb Content Processが共有されるという点だ。
つまり、同じWKProcessPoolを使うwebview間であれば、その存在が時系列上ずれていたとしてもセッションcookieを共有できるはずと考えた。
なので

extension WKProcessPool {  
    static let shared = WKProcessPool() 
}  
  
class ViewController: UIViewController {  
    override func viewDidLoad() {  
        let configuration = WKWebViewConfiguration()  
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)  
#中略  
    }  
}  

というようにしてやると良い。
複数のViewControllerで使わないのであれば、WKProcessPoolはシングルトンっぽくしなくても、propertyとしてもってやればいいだろう。

ここで注意しなければいけないのは、WKWebViewConfigurationはWKWebViewのinitializerに渡してやらなければならないという点だ。

let webview = WKWebView()
webview.configuration.processPool = WKProcessPool.shared

のようにやった場合はうまくいかなかった。
今度時間がある時にWKWebViewのコードを呼んで理由を探そうと思う。

RxWebKitがいい感じだったよ

RxSwiftの関連ライブラリーを調べていたところRxWebKitなるものを発見。

github.com

要はWebKitのRxSwiftラッパーです。
いい感じにWKWebViewを扱えてとても楽しい! 結構素敵な感じに使えたので、シンプルなサンプルアプリを作ってみます。

導入

まずは普通に

pod 'RxWebKit'

ってな感じにしてpod install。 Carthageでもできますが、例のごとく割愛いたします。

普通にViewを構築

WKWebViewはコードで追加するので、タイトルやロードビュー、進む戻るボタンなどをテキトーに配置します。 f:id:gaoxin-xixxix:20170531215605p:plain

IBOutletでViewControllerに引っ張ってきます。

import UIKit
import WebKit

class ViewController: UIViewController {
    @IBOutlet weak var progressbar: UIProgressView!
    @IBOutlet weak var refreshButton: UIBarButtonItem!
    @IBOutlet weak var forwardButton: UIBarButtonItem!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var backButton: UIBarButtonItem!
    @IBOutlet weak var toolbar: UIToolbar!
    @IBOutlet weak var containerView: UIView!
}

次に、viewDidLoad内で、WKWebViewを追加します。 navigationDelegateはぶっちゃけ今回いりませんが書いちゃってます。

setUpConstraints(for webView: WKWebView )では、WKWebViewをcontainerViewにぴったり貼り付ける制約を加えています。 webview.loadで、githubをロードし始めます。

override func viewDidLoad() {
        super.viewDidLoad()
        
        // set up WKWebView
        let webview = WKWebView()
        webview.navigationDelegate = self
        webview.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(webview)
        setUpConstraints(for: webview)

        // load github.com
        let request = URLRequest(url: URL(string: "https://www.github.com")!)
        webview.load(request)
}

private func setUpConstraints(for webview: WKWebView) {
        webview.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 0.0).isActive = true
        webview.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0.0).isActive = true
        webview.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0.0).isActive = true
        webview.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0.0).isActive = true
}

これでViewパーツの配置は終わりです。

webviewのプロパティをバインディングしていく

ここからRxWebKitを活用する作業に入っていきます。

webview.rx

からWKWebViewに拡張されたRX系のプロパティにアクセスできます。

まずは、RxSwift、RxWebKitをimportします。

import RxWebKit
import RxSwift

そして、webviewのtitleをtitleLabelのtextにbindします。 こうすることで、webviewが遷移する度に表示されているページのタイトルがtitleLabelに自動的に反映されます。 driveを使うか迷ったのですが、どっちが良いんでしょうねこれ。

// bind title to title label
webview.rx.title.bind(to: titleLabel.rx.text).addDisposableTo(disposeBag)

次に、進む戻るボタンのenabledを制御します。
WKWebViewには、canGoForwardとcanGoBackというプロパティがあり、それぞれ、進めるページがあるか、戻れるページがあるかを取得できます。
丁度ブラウザの進む戻るボタンと同じです。
これもRX化されているので、こちらで用意した進む戻るボタンのisEnabledにbindします。(こちらはdriveを使いました)

ただし、UIBarButtonのisEnabledはRxのobserverが用意されていません。
なので、自分で実装することになります。
今回は、RxSwift公式のサンプルに倣い、BindingExtension.swiftというファイルを作って実装しました。

import UIKit
import RxSwift
import RxCocoa

extension Reactive where Base: UIBarButtonItem {
    var isEnabled: UIBindingObserver<Base, Bool> {
        return UIBindingObserver(UIElement: base) { button, isEnable in
            button.isEnabled = isEnable
        }
    }
}

こんな具合に、送られてきたBoolをUIBarButtonItemのisEnableに対応させるようなObserverを作ります。 これに、先程のcanGoForwardとcanGorBackをbindすることで、自動的にUIBarButtonItemが有効化、無効化するようになります。

// handle back and forward buttons availablity
webview.rx.canGoForward.asDriver(onErrorJustReturn: false).drive(forwardButton.rx.isEnabled).addDisposableTo(disposeBag)
webview.rx.canGoBack.asDriver(onErrorJustReturn: false).drive(backButton.rx.isEnabled).addDisposableTo(disposeBag)

実際にこれらがタップされたときの処理と、 リフレッシュボタンがタップされたときの処理は、RxWebKitの機能を用いていないので割愛します。

最後に、プログレスバーの処理を設定します。
RxWebKitではページの読み込みに関係するプロパティとして、loadingプロパティとestimatedProgressが存在します。
前者は現在読み込みを行っているかを表しており、
後者は読み込みがどの程度完了しているか推定値で表しています。

プログレスバーにはUIProgressViewを用い、loadingプロパティを表示非表示に、estimatedProgressを進行度合いにbindしました。 なお、UIProgressViewのisHiddenと進行度合いを表すprogressはRX対応されていないので、またも自作observerを実装します。

先程のBindingExtensionに下記を追加します。 isProgressingは進行中かどうかisHiddenは消えているかを表しているため、 notを入れることでちょうど進行中に表示されるようになります。

extension Reactive where Base: UIProgressView {
    var progress: UIBindingObserver<Base, Float> {
        return UIBindingObserver(UIElement: base) { progressbar, progressRate in
            progressbar.progress = progressRate
        }
    }
    
    var isProgressing: UIBindingObserver<Base, Bool> {
        return UIBindingObserver(UIElement: base) { progressbar, isProgressing in
            progressbar.isHidden = !isProgressing
        }
    }
}

これらに前述のプロパティをbindします。

コメントの下の行では、WKWebViewより前に来るように、bringSubViewで最前線に持ってきています。

// set up progressbar
containerView.bringSubview(toFront: progressbar)
webview.rx.loading.asDriver(onErrorJustReturn: false).drive(progressbar.rx.isProgressing).addDisposableTo(disposeBag)
webview.rx.estimatedProgress.map { progress in
     return Float(progress)
}.asDriver(onErrorJustReturn: 0.0).drive(progressbar.rx.progress).addDisposableTo(disposeBag)

ここで気をつけなければいけないのはestimatedProgressのbindです。 estimatedProgressはDouble型、UIProgressViewのprogressはFloat型なので、一度mapを噛ませるなりして型を揃えてやる必要があります。

以上で必要な処理は全てです。

結果

f:id:gaoxin-xixxix:20170531224656g:plain

すっきりとしたコードでこんな感じのwebviewが実装できちゃいます!素敵!!

今回のサンプルのコードです。

github.com

Moyaでお手軽API通信

ブログ始めてみたんですけど、特に抱負も何もないので、しれっとMoyaで遊んだ事について書きます。

Moyaって?

github.com

APIとか叩く時に作られがちな、"APIManager"とか"NetworkModel"みたいな、 ネットワークを抽象化するレイヤーの役割をしてくれるライブラリ。 内部的にはAlamofireを叩いていて、言うならAlamofireのラッパーとも言えるかもしれない(言えないかもしれない)。

何が便利?

公式が言うには

  • 正しいAPIのエンドポイントにアクセスしてるかコンパイル時にチェックできる
  • enumの連想値使って異なるAPIの使い方をちゃんと定義できる
  • testする時stub的な役割を果たせる

個人的に思ったのは

  • siestaとかよりhttp通信の層をよく隠してくれるので楽
  • 仕組みが割とわかりやすいので使う上でリーズナブル

といったところでしょうか。

使い方

導入

Podfileに

pod 'Moya'

を記入し、pod installで簡単にインストールできます。 なお、Moyaを入れるとAlamofireも一緒に入るので別途記述する必要はありません。 ちなみにSwift Package ManagerやCarthageでも導入可能です。

今回は、 github.com を使って基本的な使い方をなぞってみます。 MoyaのレポジトリにあるBasicUsageとほぼ同じ内容です。

enumを作成

Moyaでは、自分が使いたいAPIの情報をenumに持たせます。 まずは、それぞれのAPIターゲットをenumとして列挙します。 また、それぞれのターゲットに対し渡すパラメータがある場合は、連想値でこれを定義します。

import Moya

enum HackersNewsAPI {
    case item(id: Int)
    case user(id: String)
    case maxItem
    case topStories
    case newStories
}

また、このenumTargetTypeプロトコルに準拠する必要があります。 具体的には

  • var baseURL: URL
  • var path: String
  • var method: Moya.Method
  • var parameters: [String: Any]
  • var parameterEncoding: ParameterEncoding
  • var sampleData: Data
  • var task: Task

のプロパティを設定しなければなりません。 今回は以下のように準拠させます。

extension HackersNewsAPI: TargetType {
    // ベースURL
    var baseURL: URL { 
          return URL(string: "https://hacker-news.firebaseio.com/v0")! 
    }
    
    // それぞれのターゲットごとのpath
    var path: String {
        switch self {
        case .item(let id), .user(let id):
            return "/\(id).json"
        case .maxItem:
            return "/maxitem.json"
        case .newStories:
            return "/newstories.json"
        case .topStories:
            return "/topstories.json"
        }
    }
    
    // それぞれのターゲットごとのhttpメソッド
    // 今回はgetしかないが、postなどある場合はswitch selfなどで適切な値を返す
    var method: Moya.Method {
        switch  self {
        case .item, .user, .maxItem, .newStories, .topStories:
            return .get
        }
    }

    // 今回はパラメーターとして渡さないのですべてnil
    var parameters: [String: Any]? {
        return nil
    }
    
    // パラメーターのエンコーディング指定
    // リクエストボデイにjsonとしてセットすることもできる
    var parameterEncoding: ParameterEncoding {
        return URLEncoding.default
    }
    
    // テスト時に使われる(なんでマストなのかわからない)
    // めんどくさかったので今回はずるします
    var sampleData: Data {
        return Data()
    }
    
    var task: Task {
        switch  self {
        case .item, .user, .maxItem, .newStories, .topStories:
            return .request
        }
    }
}

以上でenumの準備は完了です。

ここまで書いて、題材にしたAPIがあんま良くなかったことに気づく。 が、面倒くさいので強行します。

MoyaProviderを使ってAPIリクエストする

enumの準備が整ったら、MoyaProviderを生成し、リクエストを送ることができます。

let provider = MoyaProvider<HackersNewsAPI>()
provider.request(.newStories) { result in
    
}

provider.requestの第二引数となっているクロージャが受け取っているresultは、 .success(Moya.Response).failure(MoyaError)を持つenumです。 なので基本的には

provider.request(.newStories) { result in
    switch result {
    case let .success(moyaResponse):
        do {
            try moyaResponse.filterSuccessfulStatusCodes()
            let data = try moyaResponse.mapJSON()
            // do something with the data
        }
        catch {
           // failed to convert to JSON
        }
    case let .failure(error):
           // failed at api access
    }
            
}

のようにしてエラー検知すると良いと思います。

filterSuccessfulStatusCodesは、ステータスが200~299でなければ例外を投げるメソッド。 mapJSONは名前の通りデータをJSONマッピングしてくれるメソッドです。

どちらもMoya.Responseに組み込まれたメソッドです。

まとめ

今回は本当に触りしかやりませんでしたが、Moyaは、APIリクエストの際に挟み込めるクロージャーや、 テストを意識した機能、RX対応など、まだまだ魅力的な機能を持っています。 特にRX対応は割とAPIもイケてると思うので是非触ってみてもらえたらなと思います。

一応今回のソース

github.com