おぼえがき

アウトプットで知識を体系化していきます

Embedded Frameworkの導入の検討 / firefox-iosの例

f:id:taku_oka:20170821020210p:plain

個人的なアプリでは導入してみていますが、途中から入った現在のプロジェクトでも導入を検討しているので、色々と整理してみました。

Embedded Frameworkを取り入れる利点

ビルドパフォーマンスの改善

EmbeddedFrameworkを導入することで差分コンパイルが効きやすくなるため、ビルドの時間を減らすことに大きく貢献します。

そのためビルド時間短縮の有効な手段の1つとしてよく取り上げられています。

本格的なiOS開発では、ビルドパフォーマンスを意識せずに開発を続けていくと大抵の場合フルビルドに8分~15分くらいかかるようになっていきます。しかもメソッドを1つ追加するだけでも全体をビルドし直しになったりします。

Swiftはコードが増えてくると凄く生産性が悪くなるのでビルド時間の短縮化は開発者の間でよく取り扱われるテーマの1つとなっています。

ウィジェットなどとの間で機能を共通化できる

iOSアプリ本体に加えて、ウィジェットやシェア拡張などのAppExtensionを開発する場合やiPad向けアプリやAppleTV向けアプリやWatch向けアプリを開発する際、ビルドターゲットはアプリとは別になります。

ターゲットの切り分けをしていない場合、 TodayウィジェットからAPIを叩く必要があるときは、アプリのコードを使用することができず、結果、二重にAPIへのリクエスト部分を実装することになってしまいますが、 機能がEmedded Frameworkとして切り離されていればアプリとウィジェット間で共通の機能を使うことが出来ます。

コードがキレイになる

依存がはっきりとわかれて、名前空間も別れるので簡潔なコードが書きやすくなります。

ターゲットを分けずにアプリ開発している場合はアクセス修飾子のinternal / public の違いがなくなりますが、 フレームワーク内ではinternal/publicが意味を持つようになり、より安全に分かりやすく開発していくことが可能です。 ターゲット内でのみ使えるinternalなクラスを作ったり、外部に公開するクラスやメソッドを限定的にしてモジュールを使いやすく分かりやすいインターフェイスにしていけます。

Embedded Frameworkの作り方

参考になりそうな記事を貼っておきます。

Embedded Frameworkを導入してXcodeのビルドパフォーマンスをあげる - VASILY DEVELOPERS BLOG

Embedded Frameworkの分け方の例

以下のように層ごとで分けることが多いかと思います。

メインターゲット

Embed Framework

  • Util (全体で使う汎用的な機能やExtensionなど)
  • APIClient
  • Model
  • in-App-Purchase機能

mozilla-mobile/firefox-ios の例

mozilla-mobile/firefox-ios

embed frameworkを取り入れている例として firefox-iosオープンソースプロジェクトをざっくり観察してみました。

ターゲットのわけ方

f:id:taku_oka:20170821020346p:plain:w300

メインターゲット

  • Client(iOSアプリ)
  • ShareTo(ShareExtension)
  • Today(TodayExtension)
  • NotificationService(NotificationExtension)
  • SendTo(ActionExtension)
  • ViewLater(ActionExtension)

Embed Frameworks

  • Shared: 汎用的なextensionやUtil系クラス
  • Storage: ブックマークなどの永続化層?
  • Account: アカウント管理?
  • Sync: ブックマークなどの同期?
  • ReadingList: リーディングリストの管理?
  • Telemetry: 効果測定?

Model,APIClient…というふうに層で分けるのもいいですが、firefoxのように機能ごとに分けるのもよさそうですね。

ライブラリの管理をどうしているのか

f:id:taku_oka:20170821020408p:plain

Carthageで取得してきたframeworkたちが一番上の階層のFrameworksフォルダにまとめられており、各ターゲットがおのおの必要なフレームワークを取り入れていました。

具体的には各ターゲットの”Linked Framewoks and Libraries”, ”Linked Binary with Libraries” に使用するframeworkを追加して、使用しているファイル内でimport文を追加しています。

f:id:taku_oka:20170821020430p:plain

EmbedFrameworkを使いすぎると起動が遅くなる件について

EmbedFrameworkを使いすぎると起動が遅くなるそうです。 (参考資料: More Swift App Startup Time)

つまりEmbedFrameworkを使用してビルド時間を短縮することとアプリの起動時間が伸びるのはトレードオフとのこと。 (参考資料: iOSプロジェクトのBuildを高速化する)

しかし実際 firefoxのアプリの起動時間を体感してみたところそこまで起動の遅さが感じられなくて安心しました。 むしろ平均以上の速さのように感じました。

firefoxのEmbeded Frameworkの数

  • 外部framework 23個
  • 内部framework 6個

このframework数でiPhone6sで250msほどで起動していました。

firefoxのEmbeded Frameworkの数は少なくもなく多くもなく通常のアプリ開発はこのくらいの数になると思います。

これで充分に起動時間が速いので、過剰な数のEmbeded Frameworkを使いさえしなければ起動時間の伸びについては特に問題になることはなさそうだと思いました。

まとめ

Embeded Frameworkは利点が多く、ウィジェットの開発を視野に入れていたりビルド時間を肥大化させたくない場合は早めに導入できると良さそうだと思いました。

firefox-iosは Embed Frameworkを活用している例としては貴重で参考になりました。 大手のプロジェクトでEmbed Frameworkを活用した例があると安心感があります。 僕の会社でも最近のiOSプロジェクトはEmbed Frameworkを導入いる例がいくつかあるので参考にしてみたいと思いました。

導入時期の検討

Embed Frameworkの導入はアプリ開発が進むほどにコストが高まっていきます。

僕のアプリの場合はリリースが近くなってしまったので、今導入するとデグレの危険性があるためリリース直後くらいに行えたら良いなと思っています。

JavaScriptで定期的にWi-FiをOFF➜ONする

僕は普段はSwiftを書いているのですがインスタントな雑スクリプトJavaScriptで書きます。

カフェでドヤマックしてたのですがWifiの調子が悪くて定期的にon/offしていたので、それを自動化したいと思い雑なnodeJSを書きなぐりました。

Wifiを「切」にして3秒後に「入」にする`を60秒ごとに行うスクリプトです。 Shell Commandをタイマー実行しているだけですが。

toggleWifi.js
const exec = require('child_process').exec;

var intervalSec = 60;

function main() {
    console.log("start to toggle wifi");
    setInterval(function() {
        toggleWifi();
    }, 1000 * intervalSec)
    toggleWifi();   
}

function toggleWifi() {
    console.log("toggle...");
    changeWifiPower(false);
    setTimeout(function() {
        changeWifiPower(true);
    }, 1000 * 3);
}

function changeWifiPower(isOn) {
    var nextState = isOn ? "on" : "off";
    var command = "networksetup -setairportpower en0 " + nextState;
    doShellCommand(command);
    console.log("wifi turn " + nextState);
}

function doShellCommand(command) {
    exec(command, (err, stdout, stderr) => {
      if (err) {
        console.log(err);
      }
      console.log(stdout);
    });
}

main();

使い方

$ node toggleWifi.js

WKWebViewのリダイレクト時の挙動

WKWebViewのリダイレクト周りの挙動が分からなかったので調べてみました。 (iOS10 / iOS9) (Swift3.2)

リダイレクトはWKNavigationDelegateで検知するしかなさそう

自分の実験の中ではWKWebViewではリダイレクトのステータスコード(300系)を観測できなかった。 どうやらWKWebViewが内部的にリダイレクトは処理してしまっているようだったので、 WKNavigationDelegateによってそのリダイレクトによる遷移を検知した。

主要なWKNavigationDelegateのメソッド

func webView(WKWebView, decidePolicyFor: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void)

Decides whether to allow or cancel a navigation.

  • リクエストを開始するときに呼ばれる。そのまま遷移をさせるか、キャンセルするかを決める。
  • リダイレクトが起これば連続してよばれる。
  • navigationTypeを見るとクリックよる遷移か、リダイレクトによる遷移かがわかる。

func webView(WKWebView, decidePolicyFor: WKNavigationResponse, decisionHandler: (WKNavigationResponsePolicy) -> Void)

Decides whether to allow or cancel a navigation after its response is known.

  • レスポンスヘッダが返ってきたときに呼ばれる。そのまま遷移をさせるか、キャンセルするかを決める。
  • このあとにリダイレクトでまたnavigationActionが呼ばれることもあるので、ここで終わりとは限らない。

func webView(WKWebView, didCommit: WKNavigation!)

Called when the web view begins to receive web content.

  • リダイレクトが終わってコンテンツのロードが始まった時に呼ばれる。

リダイレクトの流れ

リダイレクトが終わるまで

webView(WKWebView, decidePolicyFor: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void)

webView(WKWebView, decidePolicyFor: WKNavigationResponse, decisionHandler: (WKNavigationResponsePolicy) -> Void)

連続で呼ばれ続け、

リダイレクトが終わってコンテンツのロードが始まると

webView(WKWebView, didCommit: WKNavigation!)が呼ばれる。

気をつけること

  • decidePolicyFor: WKNavigationResponseが呼ばれても終わりとは限らない。さらにそのままdecidePolicyFor: WKNavigationActionが呼ばれることがあり、didCommitが呼ばれるまで終わりとは限らないので気をつける。
  • didCommitが呼ばれて初めてリダイレクトが終了したとわかるが、Webコンテンツのロードが始まらない限り最後のURLがわからない。

P.S.

その他のWKNavigationDelegateのメソッド

didStartProvisionalNavigationdidReceiveServerRedirectForProvisionalNavigationは呼ばれない事もあったのでよく分かっていない・・・

func webView(WKWebView, didStartProvisionalNavigation: WKNavigation!)

Called when web content begins to load in a web view.

これが呼ばれるとリダイレクトがおわりURLが決定しているようだった。これのあとに最後のリクエストかレスポンスがきた。

これのあとにリクエストが来た場合はそれが最後のリダイレクトなのか、そのあとにdidReceiveServerRedirectForProvisionalNavigationが呼ばれ、レスポンスヘッダがきた。

※ なぜか、呼ばれないこともあった…

func webView(WKWebView, didReceiveServerRedirectForProvisionalNavigation: WKNavigation!)

Called when a web view receives a server redirect.

リダイレクトされたときの最後のリクエストのあとで呼ばれ、そのあとにレスポンスがきた。

※ なぜか呼ばれないこともあった…

発表しました。「Swiftがだいたい読めるようになるセッション」

先日、社内勉強会でエンジニアがiOSを雰囲気で読めるようになるのを目指すLTをさせていただきました。

Swiftはとても読みやすい言語なので、普段読んだことのないエンジニアの人でも要所さえ抑えてしまえば言語自体は簡単に読めると思います。

加えてiOSアプリケーション開発の全体像を掴めば、iOSプロジェクトがだいたい雰囲気読めるようになるのではないかと思いこの発表をしてみました。

リーディングの題材としてはこちらのオープンソースプロジェクトをお借りしました。 少しリファクタしましたが、手頃なサンプルプロジェクトを1から作る時間が無かったので助かりました。 github.com

最近はAndroid界隈でKotlinというSwiftにとても似てる言語が普及している流れも有りAndroidエンジニアとiOSエンジニアでお互いにコードを読みやすくなっているので、僕もそろそろKotolinのAndroidを雰囲気で読めるようになっておきたいところです。

シンタックスハイライトしたコードをスライドに貼り付ける

Keynoteパワポのスライドにシンタックスハイライトしたコードを貼り付ける方法です。

f:id:taku_oka:20170719030629p:plain

インストー

まず、highlightをインストールします。

$ brew install highlight

実行

Keynoteなどに貼り付けるにはリッチテキスト方式で貼り付ける必要があります。 例えば codes.swiftを リッチテキスト方式でクリップボードにコピーするにはこんなコマンドになります。

$ highlight codes.swift -O rtf | pbcopy

貼り付け

f:id:taku_oka:20170719030936p:plain

オプション

さらにオプションを使えばシンタックスハイライトのスタイルやフォント、タブのサイズなどを詳しく設定することも可能です。

$ highlight codes.swift --style=github --replace-tabs=4 --font=NotoSansMonoCJKjp-Regular --font-size=36 -O rtf | pbcopy

※フォント名はPostScript名で、MacではFontBookから確認可能です。

他にも色々なオプションがあるみたいです。 詳しくは -h オプションでヘルプを呼び出してみてください。

$ highlight -h

kicksterter/ios-oss を観察してみて思ったこと(その1)

海外のクラウドファンディングサイト kicksterter.com は自社の iOSアプリソースコードGithubで公開しています。

kicksterter/ios-oss

f:id:taku_oka:20170710015303p:plain

なかなか評判が良さそうだったのでソースコードを観察してみました。

最も印象的だった部分はだいたい他の人の記事で分かりやすくまとめられていたので、 僕は俯瞰的な視点で思ったことをダイジェスト的に書くことにしました。

動機

僕は現在新規のiOSプリプロジェクトに参加しています。 アプリ開発は最初が肝心なので、なるべく他のプロジェクトを参考にして構成を参考にしようと思い、オープンソースで公開されているkicksterterのiOSプロジェクトを観察してみました。

アプリ開発は最初が肝心

ソフトウェア開発も最初が肝心です。 初期段階のフェーズでの判断次第で、未来の開発環境が大きく変わります。

アプリ開発でも、最初に作った基盤の上で開発していくので長期間の開発を経るほどにその基盤を変更するコストはどんどん膨らんでいき修正するのが難しくなっていきます。 開発をするほどに技術的負債は膨らんでいってしまいます。

初期段階で急いで開発をして技術的負債を多く残しつつもアプリをリリースし、汚いコードやイケてない仕組みの上で実装を続けていくしかないプロジェクトも数多く存在しているはずです。

途中で技術的負債を解消するために経営陣を説得してサービスの進化を一時停止させてコストを投資し途中からリファクタを頑張ることも可能ですが、

最も良い方法は、もともと最初から技術的負債を最小限に抑えることです。

開発初期の段階で適切な設計や仕組みを導入する決断をしていき、なるべく良い開発環境を整え、なるべく良いレールを敷いておくことが大切です。

そのためにまずはOSSなどでしっかりとした構成で作られたプロジェクトに目を通しておくと大変参考になります。

読んでみた結果

README.mdに、アプリのコードをOSSとして公開する理由は “教育のため” と書いてありましたが、まさに勉強になりました。しっかりとしたモダンな構成になっているので、iOSアプリのプロジェクト例としてとても参考になります。 このプロジェクトはだれでも見ることができるので、新規でアプリを作る人は目を通しておけば何かと得るものがあると思います。


前置きが長くなってしまいましたが、

印象的だった点をいくつかピックアップして書いていきます。

どこまでOSSにしてるのか

オープンソースとはいえ、さすがにサーバーサイド周りの機密情報は公開してません。 秘密にすべき情報は Secrets.swift に集約されており、オープンソース上では例としてウソの情報が入っていました。

Secrets.swift の isOSS を true に変えるとAPI層がモックに差し替わる仕組みになっています。


どのように storyboad / xib を使っているか

セオリー通り、 1つのViewController に対して 1つのstoryboadで、基本的に全てのUIViewController, UIVIew, Cellに対してstoryboard or xibが存在していました。

ちなみにモダンにStackViewを使用しています。

全てのStoryboardは enum で管理されていて、enumからインスタンス化していました。

どこまでをStoryboadで定義しているか

storyboard / xib では主にレイアウト情報を定義しているだけでした。 フォントや色などのスタイルの定義はコードで設定しています。

最もポピュラーなやり方だと思う

やはりkicksterterのように storyboad / xib を主にレイアウトのみで使うことで レイアウトのコードを省きつつ、Viewの構造を理解しやすくする。フォントや色はアプリ全体で統一した値を使うためにコードで書く。 というのが多分 現状では一番ポピュラーなやりかたなんじゃないかと僕は思っています。(意見ください)

(今後は、Xcode9で追加されるAssets catalogsのnamed colors supportなどによってアプリで統一している色やフォントもInterfaceBuilder上で定義しやすくなってこの辺もstoryboardで定義するのが良くなっていくのでしょうか。)


独自のライフサイクルを追加している

kicksterterではUIViewUIViewControllerに独自のライフサイクルメソッドである bindStyles()bindViewModel() を追加していました。

独自のライフサイクルといっても、もともとあるライフサイクルと同じタイミングで特定のメソッドを呼ぶようにしているだけです。 実装を見るとわかりますが、method swizzling か extensionを使ったオーバーライドによってそれを実現しています。

スタイルを定義する場所とViewModelとのバインドを定義する場所が決まっているとプロジェクト全体で統一感がでて綺麗になるし実装する時に迷わずにすむので良さそうです。

独自のライフサイクルメソッド

  • bindViewModels(): ViewModelをバインドする処理を書くところ
  • bindStyles(): Viewのフォントや色などを適応するところ

それらが呼ばれるタイミング

UIView

  • bindViewModels() -> awakeFromNib
  • bindSytles() -> traitCollectionDidChange

UIViewController

  • bindViewModel() -> viewDidLoad
  • bindStyles() -> traitCollectionDidChange + viewWillAppear(初回のみ)

基本的にどちらもほぼ初期化のタイミングで呼ばれます。

traitCollectionDidChangeでbindSytles()してるのが新鮮でした

このメソッドは本来 画面回転のイベントをフックするタイミングで呼ばれるので、ここでスタイルの適用をしているのが個人的には参考になりました。

traitCollectionDidChange呼ばれるタイミングは以下です。

UIView の traitCollectionDidChange が呼ばれるタイミング

  • 自身がaddSubviewされた時
  • 画面回転時

UIViewController の traitCollectionDidChange が呼ばれるタイミング

  • アプリケーションの起動時
  • ViewControllerが初めてロードされたタイミング
  • 画面回転時

traitCollectionDidChangeが呼ばれるタイミングは画面回転時だけではなく、初期化時にも呼ばれるので確かにスタイルを設定するタイミングとしては適していそうです。


また、bindViewModelsのタイミングとしてawakeFromNibを使う辺り、徹底して全てのViewでInterfaceBuilderを使用する方針が伺えます。


ViewModelの書き方がイケてる

KicksterterのViewModelでは、inputsoutputs を protocol として定義しており、 「入力」と「出力」が明確に別れて列挙されており大変シンプルでわかりやすいインターフェイスを実現していました。

これはとても分かりやすいと思ったので自分のプロジェクトでも導入を検討しています!

protocol MyViewModelInputs {
   // タップや文字入力などを受け取るメソッドたち
}

protocol MyViewModelOutputs {
   // Viewがobserveするプロパティたち
}

// 使用側はこの型を使用することでシンプルに扱える
protocol MyViewModelType {
  var inputs: MyViewModelInputs { get }
  var outputs: MyViewModelOutputs { get }
}

final class MyViewModel: MyViewModelType, MyViewModelInputs, MyViewModelOutputs {
  // 本体クラスはよしなにinputs, outputsのインターフェイスを実現
}

実際のコードはこんな感じです: CommentsViewModel.swift

また、Swiftはヘッダファイルがないので、こういったアプローチをすることでもヘッダファイルのようなインターフェイスを実現することができるという気付きも新鮮でした!

詳細は@muukii0803さんの「Kickstarter-iOSのViewModelの作り方がウマかった 」をご覧ください。


MVVMで画面遷移

画面遷移の方法が参考になりました。

  • ViewModel側に showHoge のような Observableを公開しておき、
  • ViewControllerがそれを監視して画面遷移します。
  • (次の画面生成に必要なオブジェクトはObsesrvableで流れてきます。)

SignupViewController#L144:

self.helpViewModel.outputs.showWebHelp
  .observeForControllerAction()
  .observeValues { [weak self] helpType in
    self?.goToHelpType(helpType)
}

詳細は@star__hoshiさんの「[MVVM] kickstarter/ios-oss での画面遷移のやり方」をご覧ください。


独自の演算子を使用している

|>, ||>, ?|>, , >>>, <<<, <>, .~, ^*, , .., >•>, %~, %~~, <>~

elixirにもあるパイプライン演算子 |> しか読めませんでしたが、確かにこれは便利かもしれません。 演算子を使わない場合と比べると、とてもシンプルに書けているのではないでしょうか。

let backing = .template
  |> Activity.lens.id .~ 62
  |> Activity.lens.project .~ (.cosmicSurgery
    |> Project.lens.photo.med .~ ""
    |> Project.lens.photo.full .~ ""
    |> Project.lens.stats.fundingProgress .~ 0.88
  )
  |> Activity.lens.user .~ (.template
    |> User.lens.name .~ "Judith Light"
    |> User.lens.avatar.small .~ ""
  )
  |> Activity.lens.category .~ .backing

ActivitiesViewControllerTests.swift

所見ではマジでイミフです。 けど、慣れれば記述力と可読性が上がるのだと思います。

public let activitySampleCellStyle = baseTableViewCellStyle()
  <> UITableViewCell.lens.backgroundColor .~ .clear
  <> UITableViewCell.lens.contentView.layoutMargins %~~ { _, view in
    view.traitCollection.isRegularRegular
      ? .init(top: Styles.grid(4), left: Styles.grid(30), bottom: Styles.grid(3), right: Styles.grid(30))
      : .init(top: Styles.grid(4), left: Styles.grid(2), bottom: Styles.grid(3), right: Styles.grid(2))
}

ActivitySampleStyles.swift

演算子の定義をしてるファイルはこちら

いいかも

プロジェクトの学習コストは少しは上がりますが、とてもスッキリ書けそうです。 学習コスト上がるとはいえ超短期でプロジェクトに入るエンジニアなんて滅多に居ないので独自の演算子の導入は検討してみても良いかもしれないなと思いました。


あとがき

随所で参考になるのでなかなか読み応えがありました。 まだ完全に読み込めてないのでもう少し読んでみて、もう1本記事を書きたいと思います。

他にも mozilla-mobile/firefox-ios 等も読んでいきたいと思いました。

間違いの指摘等ございましたら気軽にコメントよろしくお願いします🙏

ガワネイティブのCookie同期

はじめまして。 僕は普段アウトプットが足りておらず、リスペクトする先輩から「6月中にブログを作って記事を2本書け」と言っていただき、焦りながらなんとか1本目の記事を書きました。 ツッコミ等ございましたら気軽にコメントしていただけると助かります。


現在、WebViewを多用しているiOSアプリを開発しており、複数のWebView間とネイティブのAPI層でのCookieの同期が必要になってきました。 あるアクションをしたあとに特定のCookieを複数のWebViewとネイティブのAPI層に即時に同期させて更新しなければならず、わりと手こずったのでWebViewのCookie周りについてメモを残します。

※ 対応しているバージョンは iOS10 / iOS9 です。

※ iOS11からはこの辺がとてもラクになるらしいです。


複数のWebView間でのCookie同期

同一のWKProcessPoolを使うことで、リアルタイムで各WebViewのCookieが同期されます。 WebView間の同期であればこれでOKです。

class WebViewController: UIViewController {

    static let processPool = WKProcessPool()

    let webview = WKWebView()

    viewDidLoad() {
        super.viewDidLoad()

        // 共通のProcessPoolを設定
        webview.configuration.processPool = sharedProcessPool
    }
}

バグに苛まれて本件を疑ってStackOverflowで自問自答しつつめっちゃ実験したのですがやっぱりちゃんとリアルタイムに同期されていました。 リクエストヘッダのCookieがセキュリティの問題で確認できなかったりして不安になる人もいるかもしれませんが、ここは大丈夫そうです。

WebViewからAPI層へのCookie同期

認証をWebViewで行う場合は認証情報をCookieから抜き出してAPI層に反映する必要があります。 これをやるためには必要なCookie情報を抜き出して保存しておき、API通信時にそのCookieを付与させる必要があります。

自分はこのような形でAPI通信時にCookieを直接指定するようにしました。

// 通信を行ってるリクエスト (URLRequest) のヘッダーに任意のクッキーを追加
let cookie = Cookie(name: "ninsyoID", value: "abc")
urlRequest.setCookieField([cookie])
extension URLRequest {
    
    mutating func setCookieField(_ cookies: [Cookie]) {
        let cookiesString = cookies
            .map { "\($0.name)=\($0.value)" }
            .joined(separator: "; ")
        setValue(cookiesString, forHTTPHeaderField: "Cookie")
    }
}
typealias Cookie = (name: String, value: String)

上記のように直接CookieをsetしなくてもHTTPCookieStorage.sharedに任意のCookieをsetしておけば反映されるそうです。

HTTPCookieStorage.shared.setCookie(cookie)

WebView内の認証から任意のCookieを抜き出す

まず、HttpOnlyCookieかどうかで取得する方法が変わります。

HttpOnly とは、通信のレスポンスヘッダーにしか表示しないというCookieの設定です。これが設定してあるとJavaScriptから見ることができないようになっています。認証などで用いる機密な情報は大抵これが設定してあります。

HttpOnlyじゃないCookieの場合

以下のようにJavaScriptの実行で取得することが可能です。

extension WebView {
    
    /// JavaScriptから得られるのは httpOnly ではないクッキーのみ
    func getCookiesByJavaScript(completion: @escaping ([Cookie]?)->Void) {
        evaluteJavaScript("document.cookie") { res, err in
            guard let res = res as? String else {
                completion(nil)
                return
            }
            var cookies: [Cookie] = []
            let cookieStrings = res.split("; ")
            for cookieStr in cookieStrings {
                let str = cookieStr.split("=")
                if str.count >= 2 {
                    cookies.append((name: str[0], value: str[1]))
                }
            }
            completion(cookies)
        }
    }
}
typealias Cookie = (name: String, value: String)

HttpOnlyなCookieの場合

HttpOnlyCookieJavaScriptから得ることができません。

取得するには WKNavigationDelegate を使ってWebViewへのレスポンスヘッダを監視することで得ることができます。

func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
    if
        let url = navigationResponse.response.url,
        let cookies = navigationResponse.cookies(for: url)
    {
        print(cookies)
    }
}
extension WKNavigationResponse {
    
    func cookies(for url: URL) -> [HTTPCookie]? {
        guard
            let response = self.response as? HTTPURLResponse,
            let allHeaderFields = response.allHeaderFields as? [String : String]
        else {
            return nil
        }
        return HTTPCookie.cookies(withResponseHeaderFields: allHeaderFields, for: url)
    }
}

WebView内のJSの通信で得ているHttpOnlyなCookieの場合

HttpOnlyかつ、WebView内のJavaScriptの通信等でそのページがCookieを得ている場合は、そのCookieを取ってくることはできません。 WebView内のJSの通信などで得るCookieはWKNavigationDelegateのレスポンスヘッダにもそのCookieは現れず、 その場合は、WebViewが実行しているJSの通信のレスポンスヘッダを監視する必要がありますが、それもセキュリティ上できないみたいなので、採ることができません。

その他

HTTPCookieStrageとWKProcessPool間で行われる同期

どうやらHTTPCookieStrageとWKProcessPool間では不定期に同期が行われてるそうです。 僕も開発していてそんな感じがしました。 ここはまた実験してみたいと思います。 体感では、即時~30秒で同期されているように感じました。

WKWebView Persistent Storage of Cookies

Getting cookies from NSHTTPCookieStorage.sharedHTTPCookieStorage(). This one seems buggy, apparently the cookies aren’t immediately saved for NSHTTPCookieStorage to find them. People suggest to trigger a save by resetting the process pool, but I don’t know whether that reliably works. You might want to try that out for yourself, though.

WKWebViewの永続化したCookieストレージとしては、NSHTTPCookieStorage.sharedから取り出す方法があるが、これはバグいようだ。どうも(WebViewでクッキーを取得してもそっちには)クッキーが即時に保存されていない。人々はProcessPoolの再代入によってその保存が引き起こせると言っているが、それが信頼できるかどうか私は分からない。

OSから勝手に同期が行われるので、HTTPCookieStorageに意図しない値が入っていた場合にWKProcessPool側へ誤ったCookieが入ってバグを起こすこともあるので注意が必要です。

技術ブログを書いてみてどうだったか

正直、この記事を書こうとしてみたら各知識についてしっかりとした確証がもてず、コードを書いて実験しました。 記事を書くということは書いてある内容に対してそれなりの責任をもたなければいけないという義務感と、間違えてたら恥ずかしいという羞恥心が生まれるので、自信がない知識に関してはちゃんと調べるきっかけになります。特にWebView周りはよくわからない挙動も多いので助かりました。 また、理解していた知識でも、いざ記事にしてみようと思うと意外と頭のなかで整理されてなかったりしたのが整理されました。

実験含め、記事を書くのは少し大変でしたがアウトプットすることで得られるものは様々あると実感しました。 これからもやっていきたいと思います。 よろしくお願いします。