WKWebViewにもUIWebViewと同じようにSession Cookieを使いたい

## はじめに
株式会社ピー・アール・オーのアドベントカレンダー4日目です。

今まで使えたUIWebView、いよいよ2020年12月で利用できなくなります。つい最近までUIWebViewからWKWebViewへの移行対応をやりましたが、WebAPIと一緒に返ってきたSession Cookieを使ってWebページを表示していて、WKWebViewに移行するとNSHttpCookieStorageからCookieを自動的に取れなくて、その対応についての調査は結構時間かかりました。

そこで、WKWebViewに移行するとき、Session Cookieをセットする方法の記事があったらいいなと思って、この記事を書きました。

## WKProcessPoolを用意する
さて、一番最初に用意しないといけないのは、WKProcessPoolのインスタンスです。これを利用して、WKWebViewを作成時に使われる設定値に設定します。複数WebViewで同じドメインのWebページを表示したい時に、全てのWebViewが同じWKProcessPoolのインスタンスを参照しないといけないです。それぞれのWebViewが独自のWKProcessPoolを持つと、WebViewにCookieをセットするとき、コンフリクトが発生する可能性があって、Cookieをセット失敗になるので、WKProcessPoolインスタンスを作るとき、常にアプリの同じ所を参照するようにします。

~~~objective-c
/// WKWebViewUtil.m
static WKProcessPool *processPool = nil;
+ (WKProcessPool *)sharedProcessPool {
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        processPool = [[WKProcessPool alloc] init];
    });
    return processPool;
}
~~~

## WKWebViewを初期化する
次はWebViewの初期化です。
WebViewが初期化される前に用意したWKProcessPoolのインスタンスをセットしないといけないので、今回のWebView作成はStroyboardではなく、全てコードで生成します。

~~~objective-c
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
configuration.processPool = [WKWebViewUtil sharedProcessPool];
WKWebView *webView = [[WKWebView alloc] initWithFrame:frame configuration:configuration];
~~~

## WKWebViewにCookieを渡す
WKWebViewの初期化が終わったので、次は作られたWebViewにCookieをセットします。
UIWebViewと違い、WKWebViewは自分自身の内部に持っていたWKHTTPCookieStoreの方でCookieを管理していたため、手動でNSHTTPCookieStorageに保存したSession CookieをWKHTTPCookieStoreに渡さないといけません。
WKWebViewにCookieをセットする処理は非同期のため、Webページ表示用に必要なCookieを全てセット完了後Webページを表示する必要があるため、全てのCookieをWKWebViewにセット完了のチェックが必要になります。

~~~objective-c
NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
for (NSHTTPCookie *cookie in cookieStorage.cookies) {
    // Cookieをセットする
    [webView.configuration.websiteDataStore.httpCookieStore] setCookie:cookie completionHandler:^{
        // Cookieセット処理は非同期になるので、ここで全部のCookieがセット完了チェックが必要
    }];
}
~~~

これだけだと、連続的に同じWebViewのインスタンスを作るとき、WebViewにCookieセット完了のハンドラーが呼ばれないことがあって、WebViewにCookieセット完了したかどうかは不明な状態になるので、`WKHTTPCookieStore setCookie: completionHandler:`を呼んだ後、下記の処理を追加して、強制的にCookieセット完了後ハンドラーが呼ばれるようにする必要があります。

~~~objective-c
// Cookieセット完了後必ずcompletionHandlerが呼ばれるように追加する処理
[[webView.configuration.websiteDataStore] fetchDataRecordsOfTypes:[NSSet<NSString *> setWithObject:WKWebsiteDataTypeCookies] completionHandler:^(NSArray<WKWebsiteDataRecord *> *records) {}];
~~~

## 初期化したWKWebViewを親のViewに追加する
WebViewが用意されたとで、次は画面上に表示するため、親のViewに追加する必要があります。ViewControllerの`viewDidLoad`に作成したWebViewを追加します。

~~~objective-c
[self.view addSubview:webview];
~~~

## NSURLRequestのヘッダーに同じCookieをセットする
Webページを表示する用のWebViewは準備できましたが、しかし今の状態はまだ画面が正しく表示できません。
画面表示する前に、URLRequestのヘッダーにもWebViewにセットしたCookieをセットしないといけません。

~~~objective-c
NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:url];
// WebViewにセットしたCookieをリクエストヘッダーにもセットする
NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
NSDictionary *headerFields = [NSHTTPCookie requestHeaderFieldsWithCookies:cookieStorage.cookies];
[urlRequest setAllHTTPHeaderFields:headerFields];

// WebViewを表示する
[webView loadRequest:urlRequest];
~~~

これで、UIWebViewと同じように、WebAPIと一緒に返ってきたSession Cookieを利用して、WKWebViewも認証必要なWebページ画面が正しく表示できます。

## 完成版のコード
~~~objective-c
/// WKWebViewUitl.m
static WKProcessPool *processPool = nil;
+ (WKProcessPool *)sharedProcessPool {
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        processPool = [[WKProcessPool alloc] init];
    });
    return processPool;
}

+ (void)initWebViewWithFrame:(CGRect)frame 
           completionHandler:(void (^)(WKWebView *))handler {
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    configuration.processPool = [WKWebViewUtil sharedProcessPool];
    WKWebView *webView = [[WKWebView alloc] initWithFrame:frame configuration:configuration];

    NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    if ([cookieStorage.cookies count] <= 0) {
        // Cookieは存在しない場合、そのまま返す
        handler(webView);
    }
    
    __block long setCookieCount = 0;
    for (NSHTTPCookie *cookie in cookieStorage.cookies) {
        // Cookieをセットする
        [webView.configuration.websiteDataStore.httpCookieStore] setCookie:cookie completionHandler:^{
            setCookieCount += 1;
            if (setCookieCount == [cookieStorage.cookies count]) {
                handle(webView);
            }
        }];
        [[webView.configuration.websiteDataStore] fetchDataRecordsOfTypes:[NSSet<NSString *> setWithObject:WKWebsiteDataTypeCookies] completionHandler:^(NSArray<WKWebsiteDataRecord *> *records) {}];
    }
}
~~~

~~~objective-c
/// ViewController.m
@property (strong, nonatomic) WKWebView *webView;
- (void)viewDidLoad {
    [super viewDidLoad];
    __weak typeof(self) weakSelf = self;
    [WKWebViewUtil initWebViewWithFrame:self.view.bounds completionHandler:^(WKWebView *)webView {
        weakSelf.webView = webView;
        [weakSelf.view addSubview: weakSelf.webView];
        NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:url];
        // WebViewにセットしたCookieをリクエストヘッダーにもセットする
        NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
        NSDictionary *headerFields = [NSHTTPCookie requestHeaderFieldsWithCookies:cookieStorage.cookies];
        [urlRequest setAllHTTPHeaderFields:headerFields];

        // WebViewを表示する
        [weakSelf.webView loadRequest:urlRequest];
    }];
}
~~~

## 最後に
- 今回WKWebViewにSession Cookieを渡して、Webページを正しく表示するために結構大変でした。やっぱり今後はWebAPIで返って来たSession Cookieを使ってWebページを表示するのはやめましょう。
- たとえまだ使えるだとしても、レガシーな設計やコードは放っておくと、いずれ使えなくなるので、今後技術的な負債に繋がる可能性は大いにあるから、可能なら早めに修正する方向で持っていきましょう。
- スマホアプリと連携するWebサービス・WebAPIはサーバエンジニアだけでなく、ちゃんとスマホエンジニアを巻き込んで設計しましょう。

## 参考サイト
- [Setting cookies with WKHTTPCookieStorage do not sync with wkwebview](https://developer.apple.com/forums/thread/97194)
- [Can I set the cookies to be used by a WKWebView?](https://stackoverflow.com/questions/26573137/can-i-set-the-cookies-to-be-used-by-a-wkwebview)