kicksterter/ios-oss を観察してみて思ったこと(その1)
海外のクラウドファンディングサイト kicksterter.com は自社の iOSアプリ のソースコードをGithubで公開しています。
なかなか評判が良さそうだったのでソースコードを観察してみました。
最も印象的だった部分はだいたい他の人の記事で分かりやすくまとめられていたので、 僕は俯瞰的な視点で思ったことをダイジェスト的に書くことにしました。
動機
僕は現在新規のiOSアプリプロジェクトに参加しています。 アプリ開発は最初が肝心なので、なるべく他のプロジェクトを参考にして構成を参考にしようと思い、オープンソースで公開されているkicksterterのiOSプロジェクトを観察してみました。
アプリ開発は最初が肝心
ソフトウェア開発も最初が肝心です。 初期段階のフェーズでの判断次第で、未来の開発環境が大きく変わります。
アプリ開発でも、最初に作った基盤の上で開発していくので長期間の開発を経るほどにその基盤を変更するコストはどんどん膨らんでいき修正するのが難しくなっていきます。 開発をするほどに技術的負債は膨らんでいってしまいます。
初期段階で急いで開発をして技術的負債を多く残しつつもアプリをリリースし、汚いコードやイケてない仕組みの上で実装を続けていくしかないプロジェクトも数多く存在しているはずです。
途中で技術的負債を解消するために経営陣を説得してサービスの進化を一時停止させてコストを投資し途中からリファクタを頑張ることも可能ですが、
最も良い方法は、もともと最初から技術的負債を最小限に抑えることです。
開発初期の段階で適切な設計や仕組みを導入する決断をしていき、なるべく良い開発環境を整え、なるべく良いレールを敷いておくことが大切です。
そのためにまずはOSSなどでしっかりとした構成で作られたプロジェクトに目を通しておくと大変参考になります。
ロケットを打ち上げてすぐの頃は、少し方向を変えるだけで後に大きな違いとなる
— takuya (@taku_oka) 2017年7月9日
今はまだその初期段階だから、正しい方向に動かせばもっと良くなるはず pic.twitter.com/PRVyPyJsQx
読んでみた結果
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ではUIView
とUIViewController
に独自のライフサイクルメソッドである bindStyles()
と bindViewModel()
を追加していました。
独自のライフサイクルといっても、もともとあるライフサイクルと同じタイミングで特定のメソッドを呼ぶようにしているだけです。 実装を見るとわかりますが、method swizzling か extensionを使ったオーバーライドによってそれを実現しています。
スタイルを定義する場所とViewModelとのバインドを定義する場所が決まっているとプロジェクト全体で統一感がでて綺麗になるし実装する時に迷わずにすむので良さそうです。
独自のライフサイクルメソッド
bindViewModels()
: ViewModelをバインドする処理を書くところbindStyles()
: Viewのフォントや色などを適応するところ
それらが呼ばれるタイミング
UIView
bindViewModels()
-> awakeFromNibbindSytles()
-> traitCollectionDidChange
UIViewController
bindViewModel()
-> viewDidLoadbindStyles()
-> traitCollectionDidChange + viewWillAppear(初回のみ)
基本的にどちらもほぼ初期化のタイミングで呼ばれます。
traitCollectionDidChangeでbindSytles()してるのが新鮮でした
このメソッドは本来 画面回転のイベントをフックするタイミングで呼ばれるので、ここでスタイルの適用をしているのが個人的には参考になりました。
traitCollectionDidChange呼ばれるタイミングは以下です。
UIView の traitCollectionDidChange が呼ばれるタイミング
- 自身が
addSubview
された時 - 画面回転時
UIViewController の traitCollectionDidChange が呼ばれるタイミング
- アプリケーションの起動時
- ViewControllerが初めてロードされたタイミング
- 画面回転時
traitCollectionDidChangeが呼ばれるタイミングは画面回転時だけではなく、初期化時にも呼ばれるので確かにスタイルを設定するタイミングとしては適していそうです。
また、bindViewModelsのタイミングとしてawakeFromNibを使う辺り、徹底して全てのViewでInterfaceBuilderを使用する方針が伺えます。
ViewModelの書き方がイケてる
KicksterterのViewModelでは、inputs
と outputs
を 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で流れてきます。)
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)) }
演算子の定義をしてるファイルはこちら
いいかも
プロジェクトの学習コストは少しは上がりますが、とてもスッキリ書けそうです。 学習コスト上がるとはいえ超短期でプロジェクトに入るエンジニアなんて滅多に居ないので独自の演算子の導入は検討してみても良いかもしれないなと思いました。
あとがき
随所で参考になるのでなかなか読み応えがありました。 まだ完全に読み込めてないのでもう少し読んでみて、もう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を抜き出す
まず、HttpOnly
なCookieかどうかで取得する方法が変わります。
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の場合
HttpOnly
なCookieはJavaScriptから得ることができません。
取得するには 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周りはよくわからない挙動も多いので助かりました。 また、理解していた知識でも、いざ記事にしてみようと思うと意外と頭のなかで整理されてなかったりしたのが整理されました。
実験含め、記事を書くのは少し大変でしたがアウトプットすることで得られるものは様々あると実感しました。 これからもやっていきたいと思います。 よろしくお願いします。