CIを前提としたプロジェクトのテンプレートができてた話
これはiOSアドベントカレンダーの10日目の記事です。
私にとって今年は、iOSエンジニアに転向した年でした。 それまではAndroid。 そして、携わっているプロジェクトのSwift化(未完)だったりiPhoneX対応だったりと劇的な半年でした。
ほぼゼロから始めたので、iOS SDKのサンプルアプリをいくつか作って勉強してたのですが、
そのうち、 CIを前提としたプロジェクトのテンプレートのような物が出来上がっていたのでそれを紹介したいと思います。
TL;DR
AnsibleでCIできるまでセットアップするプロジェクトテンプレートができてましたという話です。
大まかな使い方
- initialタグをチェックアウト
- プロジェクトをつくって、リポジトリと同じパスに配置。( initial-projectタグの状態を作る)
- プロビジョニング(Ansible)を実行(実行すると、apply-provisionタグの状態になる)
※ 実際に使う場合はトークンなどの認証情報が設定されている必要があります。
CI
- fastlane
Deploy
- Crashlytics Beta
Package Manager
- CocoaPods
Provisioning
- Ansible
- Bundler
テンプレートプロジェクト
下記のテンプレートプロジェクトをもとに詳細を書いていきます
https://github.com/matsuokah/ProjectTemplate-iOSgithub.com
リポジトリをクローンしてからfastlaneを叩くまで2,3コマンドでできるようにする
Ansibleで環境構築を自動化することで、fastlaneのコマンドを叩くまでの時間を短縮することができました。 なるべく前提を減らし、ビルドに必要なアプリケーションがなくても動くように作りました。
流れを書くと
make setup_tools
コマンドでシェルスクリプトをフック- シェルスクリプトでXcodeのインストール済みかどうかを判定。なければ、Safariを開いてXcodeのインストールを促す
- Ansibleがなければインストール、あればアップデート
- AnsibleのPlaybookを実行
Ansibleでやったこと
brew
で必要なアプリのインストール- 各テンプレートのデプロイ
bundler
のインストール
テンプレート
主に作ったテンプレートは下記の通り
.gitignore
Podfile
fastlane
関連Gemfile
.gitignore
.gitignore
は特にプロジェクト作る度にまずこれを作りますが、うっかり忘れたり、排除対象の列挙が漏れると思わぬファイルを追跡してしまいます。
gitignore.ioで作るのもめんどくさい
今回はある程度使うものを予め盛り込んだテンプレートで対応しました。
脱線しますが、CLIではcurl http://gitignore.io/api/swift >> .gitignore
という書き方も可能です。
Podfile
Crashlytics Betaを使いたいので予め盛り込んだ状態にしています。
XXXXX.xcodeproj
にヒットしたターゲット名で生成しています。これであとはpod install
するだけ。
よく使うライブラリは他にも盛り込んでもいいかも。RxSwiftとか。
fastlane
関連
テンプレート化したのは下記の3つ。Fastfile
以外は自前の定義ファイルです。
Fastfile
- ビルドシーケンス
AppContext
Env
Gemfile
- Bundlerのインストール
Fastlane
のEnv
の管理について
プライベートリポジトリを使っていたので、気にせず直書きしていました。(セキュリティの意識は甘々ですが別の漏れても被害はほぼないので。) また、サンプルを作るときには、Bitbucketを使っていました。Privateリポジトリの作成が無料かつ無制限につかえます。 余談ではありますがCIにはBitriseを使いました。Bitriseは10分以内ビルドなら月に200回まで無料で使えます。 iOSかつ、プライベートなリポジトリのCIが無料でできるのはBitriseだけ(俺調べ)
テンプレートの使い方まとめ(再掲)
- タグinitialをチェックアウト
- プロジェクトをつくる(実行すると、タグ: initial-projectの状態になる)
- プロビジョニング(Ansible)を実行(実行すると、タグapply-provisionの状態になる)
まとめ
ということで、Xcodeプロジェクトを作ってからfastlaneを走らせるまでの作業を自動化してみました。
殆どのプロジェクトでは初回のみなのであまり機会がなさそうなソースですが、
手順が自動化でき、自分にとってのプロジェクトの作成マニュアルができました。
今回は盛り込んでいませんが、xcodeprojを使えばシェルの埋め込みも可能だと思います。
まだまだプロジェクトのテンプレートは育ちそうです。
それでは次は17日のSwiftのAdvent Calendarで〜。
Macでスクリーンショットの保存先の変更と古いスクショの自動削除
デスクトップがスクショの嵐・・・!
こんなデスクトップになった経験はないでしょうか。 スクリーンショットの保存先はデスクトップなので、スクショを撮ってるうちにいつの間にかデスクトップがスクショで埋め尽くされることがあります。
精神衛生上よくない!!!
デスクトップは常にきれいでありたいものです。本当作業中で一時的なファイルを置くだけにしたい。
割れ窓理論があるように、デスクトップが心の余裕の無さや秩序を保とうとしているかを表してるんじゃないかという観念に狩られ るんですよね。もはやこれは健やかな精神なのかしるためのバロメータでもあるといえようッッ!!
ということで、整理される環境を整えたいと思います。
結論
2つの工夫で解決できます
1. ターミナルで保存先の変更
$ mkdir -p ~/Pictures/ScreenShots && defaults write com.apple.screencapture location ~/Pictures/ScreenShots
2. Automator + Calendarで自動的に古いスクリーンショットをゴミ箱に移動する
ターミナルで保存先の変更はDefaultsで解決!
約2年前の記事でも言及してますがdefaults-write.comでいろんな設定値をコマンドラインから設定することが可能です。
defaults
は、設定アプリで設定不可能な項目にアプローチできるので痒かった設定を自分好みに設定することでよりMacを使いやすくすることができます。
任意のディレクトリを作成し、そこに保存する設定を書き込みましょう。
私の場合は、写真フォルダの下にスクリーンショット用のフォルダを作成して、そこを保存先として扱うようにしました。
ターミナルで下記のコマンド2つを実行するのみです
$ mkdir -p ~/Pictures/ScreenShots $ defaults write com.apple.screencapture location ~/Pictures/ScreenShots
~/Pictures/ScreenShots
をFinderのサイドバーに登録しておくと便利です。
自動削除にはAutomator!!
次は自動削除です。デスクトップに保存しなくなった分、スクショが棚卸しされなくなりそうです。
いつの間にか凄まじい数のスクショになっているかもしれません。
スクショは往々にしてインスタントなデータなので作成日から1ヶ月過ぎたら消されてもほぼ問題無いと思います。
ということで、1ヶ月以上前のスクショを自動的に削除する仕組みを作っていきます。
Automatorを使います。スクリプトを書かない選択をしてみました。
こいつです。癖さえわかれば作業・業務を自動化できるので楽しくなります。
エンジニアなら自動化のスクリプトを組むのは日常茶飯事ですがそれがGUIベースでできるイメージです。
アプリケーションベースのアクションが定義されていてその結果を次の操作に繋いでいくイメージでしょうか。
アプリケーションベースのアクションとは
例えば、Finderだったら
- フォルダを取得する
- フォルダの名前を変更する
のような単位です。
立ち上げると
こんなウィンドウが立ち上がるので左のカラムに並んでいるアクションを、右のエリアにドラッグしてワークフローを作っていきます。
今回やりたいことは「30日以上前に作成したスクリーンショットの削除」
Automatorで扱えるフローに分解すると
これをAutomatorのフローに当てはめると
Get Specified Finder Items
Get Folder Contents
Filter Finder Items
Move Finder Items to Trash
ということでできました。
これで実行するとスクショフォルダ内にある作成日が31日以上前のファイルが一括でゴミ箱に移動されます。
Calendarアプリで定期実行に組み込む
実はCalendarアプリでアプリケーションの定期実行をすることができます。
手順としては
- ワークフロー実行用のカレンダーを作成
- 上記で作成したカレンダーにスクリーンショット削除の予定をカレンダーに追加
- 全日 or 0時などで 毎日繰り返し予定に
- カスタムアラートでその時間になったらファイルを開くを選択
こんな感じです
ワークフロー実行用のカレンダーを作成
なぜ、ワークフロー実行用のカレンダーを追加したかというと、毎日定期実行の予定がカレンダーを埋め尽くすことになるからです。 カレンダーは分けておいて、ワークフロー実行用のカレンダーを普段は非表示にしておくことをおすすめします。
まとめ
- defaultsでスクショの保存先を変えてDesktopにスクショがたまらないようにしよう!
- Automatorとカレンダーでスクショの棚卸しはPCにやらせよう!
Lottieで再生するアニメーションを作って読み込ませるまで
↑の記事は、Lottieを使ってアニメーションの再生をするところを実装しました。
実はハンバーガーアイコンのアニメーションには大きな余白が含まれていて
このまま使うとアイコン自体が非常に小さい表示になってしまっていました。
ということで、次はAfterEffectsプロジェクトを編集して、JSONを書き出すところ紹介したいと思います。
1. AfterEffectsのプロジェクトでアニメーションを編集する
まずはハンバーガーアイコンのプロジェクトをよみこみます。
おわかりかと思いますが、コンポジションのサイズ(いわゆる全体の大きさと思ってください)が800x600
になっていて、アイコンに対しての余白が非常に大きい状態です。
そこでComposition Settingsを開き、サイズをアイコンが収まるサイズの正方形にします。
設定すると、以下のようになります
2. 書き出す
次に書き出しです。
Lottieでは、このオープンソースライブラリでjson形式にExportすることを想定して作られています。
AdobeCCを使っていれば、↓のページでインストールすればAfterEffectsで使用可能になります。
https://exchange.adobe.com/addons/products/12557exchange.adobe.com
それでは、AfterEffectsでプラグインを起動します。
Window > Extensions > Bodymovin
で、起動できます
起動するとコンポジションを選択する画面がでてきます。
Render
で書き出してみます。
特に設定をしていないとこのように、書き出しが許可されてないよというエラーとともに解決方法が提示されます。
After Effects CC > Preference > General > Allow Scripts to Write Files and Access Network
にチェックを入れます
もう一度書き出すと成功します。
あとは、吐き出されたJSONファイルをiOS/Androidに組み込んで使うだけです。
これで、自作のアニメーションを組み込めるぞ 🎉🎉🎉🎉🎉🎉🎉
Lottieことはじめ
Lottieとは
Airbnb謹製のアニメーションツールでAfterEffectsでexportしたアニメーションをiOS、Androidで再生できるというすぐれものです。
上記のGIFはlottie-iosより転載
僕自身はこういうアニメーションはあまり好きではないのですが、
味気なさがなくなりますね!
ということで、シンプルなハンバーガーアイコンのスイッチを例に使い始めるまでの流れをまとめてみました。
1. Carthageで組み込み
github "airbnb/lottie-ios" "master"
cartfileに上記を記述してcarthage update --platform iOS
して、プロジェクトに組み込むだけですね。
2. スイッチを組み込む
LOTAnimatedSwitch
というクラスがあるのでそれを使います。
LOTAnimatedSwitch.h
をみるとスイッチを作成するクラスメソッドが用意されてるのでこちらを使います。
/// Convenience method to initialize a control from the Main Bundle by name + (instancetype _Nonnull)switchNamed:(NSString * _Nonnull)toggleName; /// Convenience method to initialize a control from the specified bundle by name + (instancetype _Nonnull)switchNamed:(NSString * _Nonnull)toggleName inBundle:(NSBundle * _Nonnull)bundle;
toggleNameにはアニメーションを記述したjsonファイルを指定する必要があります。
LottieではLottieFilesといって、
アニメーションをクリエイティブ・コモンズライセンスで公開しているストアがありますのでそこからダウンロードしてきます。
今回はハンバーガーアイコンをダウンロードしてきます。
ダウンロードされたzipを解答するとAfterEffectsのプロジェクトファイルと、Export済みのjsonがはいっているのでjsonをプロジェクトに組み込みます
let animatedSwitch = LOTAnimatedSwitch.init(named: "Hamburger")
これで読み込めるようになりました。
3. スイッチのアニメーションの範囲を決める
このままではアニメーションはうまく動きません。なぜならスイッチのON/OFFに対してのアニメーションの対応付をしていないからです。
スイッチはoff -> on
のアニメーションとon -> off
のアニメーションがあります
この間ですね。
LOTAnimatedSwitch.h
をみるとアニメーションの範囲を割合で設定するメソッドが用意されています
- (void)setProgressRangeForOnState:(CGFloat)fromProgress toProgress:(CGFloat)toProgress NS_SWIFT_NAME(setProgressRangeForOnState(fromProgress:toProgress:)); - (void)setProgressRangeForOffState:(CGFloat)fromProgress toProgress:(CGFloat)toProgress NS_SWIFT_NAME(setProgressRangeForOffState(fromProgress:toProgress:));
先ほどダウンロードしたHamburger.json
ではoff->on->off
ということでonに戻るまでのアニメーションが記述されています。
アニメーションの進捗割合に対応付けると
off -> on
: 0 -> 0.5
on -> off
: 0.5 -> 1.0
と表すことができます。
したがって、setProgressの記述は以下のようになります。
animatedSwitch.setProgressRangeForOnState(fromProgress: 0, toProgress: 0.5) animatedSwitch.setProgressRangeForOffState(fromProgress: 0.5, toProgress: 1.0)
この進捗割合はアニメーションの元ファイルに依存します。
off -> on
: 0 -> 1.0
on -> off
: 1.0 -> 0
で表せる場合もあるでしょう。
ということで、アニメーションの対応付が完了し、スイッチの作成ができました。
※レイアウトのコードは本筋から外れるので記載していません
4. 動かしてみる
タップしてるのですがわかりづらいですね(汗)
ということで、アニメーションの組み込みができました。
5. InterfaceBuilderで組み込めるようにしてみる
InterfaceBuilderで必要な要素だけを設定したらいい感じに動いてほしいです。
毎度、アニメーションの対応付のコードを書くのは面倒です。
ということで、InterfaceBuilderで設定できるようにします。
LottieSwitchView.swift
@IBDesignable @IBInspectable
を使って、Interface Builderでアニメーションを定義できるようにします
import UIKit import Lottie @IBDesignable class LottieSwitchView: UIView { @IBInspectable var filename: String = "" @IBInspectable var fromProgressToOn: CGFloat { set(newValue) { _fromProgressToOn = LottieSwitchView.shrinkInZeroToOne(value: newValue) } get { return _fromProgressToOn } } @IBInspectable var toProgressToOn: CGFloat { set(newValue) { _toProgressToOn = LottieSwitchView.shrinkInZeroToOne(value: newValue) } get { return _toProgressToOn } } @IBInspectable var fromProgressToOff: CGFloat { set(newValue) { _fromProgressToOff = LottieSwitchView.shrinkInZeroToOne(value: newValue) } get { return _fromProgressToOff } } @IBInspectable var toProgressToOff: CGFloat { set(newValue) { _toProgressToOff = LottieSwitchView.shrinkInZeroToOne(value: newValue) } get { return _toProgressToOff } } //// actual value private var _fromProgressToOn: CGFloat = 0 private var _toProgressToOn: CGFloat = 0.5 private var _fromProgressToOff: CGFloat = 0.5 private var _toProgressToOff: CGFloat = 1.0 override func awakeFromNib() { super.awakeFromNib() let animatedSwitch = LOTAnimatedSwitch.init(named: filename) animatedSwitch.setProgressRangeForOffState(fromProgress: fromProgressToOff, toProgress: toProgressToOff) animatedSwitch.setProgressRangeForOnState(fromProgress: fromProgressToOn, toProgress: toProgressToOn) self.addSubview(animatedSwitch) animatedSwitch.fitToParent() } } private extension LottieSwitchView { static func shrinkInZeroToOne(value: CGFloat) -> CGFloat { return min(1.0, max(value, 0)) } }
これで、InterfaceBuilderでファイル名、アニメーションの範囲を指定できるようになりました
InterfaceBuilder上ではUIViewで枠だけを作っていて
awakeFromNibで内部的にLOTAnimatedSwitch
を作ってaddSubviewしています。
fitToParent
は親Viewと同じframeになるようにConstraintsを設定しているだけです。
以上、Lottie事始めでした。
リポジトリ
https://github.com/matsuokah/LottieSamplegithub.com
次は、「AfterEffectsからアニメーションのJSONをExportする」記事を書こうと思います
↓書きました
アクションシートのクロージャをObservable化して処理を一本化する
アクションシートってよくつかわれるんですかね? 私が携わっているプロジェクトではそこそこ使われています。
コレです
アクションシートの基本的な使い方
let actionSheet = UIAlertController(title:"Title", message: "Message", preferredStyle: .actionSheet) let action1 = UIAlertAction(title: "Action 1", style: .default) { (action: UIAlertAction!) in print("Selected Action!") } // キャンセル let cancel = UIAlertAction(title: "Cancel", style: .cancel) { (action: UIAlertAction!) in print("Selected Cancel!") } actionSheet.addAction(action1) actionSheet.addAction(cancel) present(alert, animated: true, completion: nil)
- UIAlertControllerをつくる
- アクションを追加する
- 表示する
これが基本的な使い方です。
また、UIAlertActionには選択されたときのクロージャが用意されていて、都度、処理を定義する必要があります。
パッとみて感じるのが、繰り返しの記述ということです。 こういうのをループにしたくなるのがプログラマの性ですよね。
ここで私は2つ思い浮かびました
- UIAlertActionの生成を共通化できないか?
- 「どれが選択されたか」をストリームとして扱えないか?
ということで、Rxをつかってコレを実現したいと思います。
アクションシートの特徴
簡単に述べると下記の3点
- アクションシート自体はタイトルとメッセージを持つ
- アクション毎にタイトルと選択された時の処理を持つ
- キャンセルは例外
選択をObservable化するにあたっての方針
- アクションの生成元となる情報の配列を引数に取るメソッドを定義し、
UIAlertAction
の生成をまとめる。 - どのアクションが選択されたかをenumで表現する
- アクションシートの起動はUIViewControllerのextensionにまかせてします
実装
早速実装です。 なお、Githubにもソースを上げてます
ActionSheetAction.swift
まずは、アクションシートの各行を表現する構造体を定義します
// MARK: - Action internal struct ActionSheetAction<Type: Equatable> { internal let title: String internal let actionType: Type internal let style: UIAlertActionStyle }
ジェネリクス(Type
)を用いることによって外からアクションのタイプ(enum)をインジェクトできるようにします。
この型は最終的にObservableに用います。
UIViewController+RxActionSheet.swift
次に、UIViewControllerにUIAlertControllerを表示するextensionを定義します
import UIKit import RxSwift internal extension UIViewController { internal func showActionSheet<Type>(title: String?, message: String? = nil, cancelMessage: String = "Cancel", actions: [ActionSheetAction<Type>]) -> Observable<Type> { let actionSheet = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) return actionSheet.addAction(actions: actions, cancelMessage: cancelMessage, cancelAction: nil) .do(onSubscribed: { [weak self] in self?.present(actionSheet, animated: true, completion: nil) }) } }
サブスクライブされた時にアクションシートを表示しています
UIAlertController+RxActionSheet.swift
最後に、UIAlertControllerにアクション追加と同時にObservable化して返すextensionを実装します
import UIKit import RxSwift internal extension UIAlertController { internal func addAction<Type>(actions: [ActionSheetAction<Type>], cancelMessage: String, cancelAction: ((UIAlertAction) -> Void)? = nil) -> Observable<Type> { return Observable.create { [weak self] observer in actions.map { action in return UIAlertAction(title: action.title, style: action.style) { _ in observer.onNext(action.actionType) observer.onCompleted() } }.forEach { action in self?.addAction(action) } self?.addAction(UIAlertAction(title: cancelMessage, style: .cancel) { cancelAction?($0) observer.onCompleted() }) return Disposables.create { self?.dismiss(animated: true, completion: nil) } } } }
こんな感じでObservableを作成し、各アクションのクロージャでactionTypeとともにonNextを発行してあげることで
アクションシートの選択をストリーム化することができました。
使い方
AnimalSelectAction.swift
まずアクションの一覧をenumで定義します
enum AnimalSelectAction: String { case dog, cat, rabbit, panda static var AnimalSelectActions: [AnimalSelectAction] { return [.dog, .cat, .rabbit, .panda] } }
ViewController.swift
import UIKit import RxSwift final class ViewController: UIViewController { private let disposeBag = DisposeBag() @IBOutlet weak var showActionSheetButton: UIButton! override func viewDidLoad() { super.viewDidLoad() showActionSheetButton.rx .tap .asDriver() .drive(onNext: { [weak self] _ in self?.showActionSheet() }).disposed(by: disposeBag) } } private extension ViewController { func showActionSheet() { let actions = AnimalSelectAction.AnimalSelectActions .map { return ActionSheetAction(title: $0.rawValue, actionType: $0, style: .default) } // アクションシートを表示し、返ってくるObservableをSubscribeしておく。 showActionSheet(title: "Which Do you like?", actions: actions) .subscribe { (event: Event<AnimalSelectAction>) in NSLog(event.debugDescription) }.disposed(by: disposeBag) } }
動作
まとめ
ということで、ViewController側ではアクションシートの選択肢の列挙とそれに対して選択された時の記述をするだけで良くなりました。
UIAlertControllerもほぼ意識せずにできてるのはメリデメあるかもですが記述が簡潔になったかと思います。
extension最高\(^o^)/
サンプルリポジトリ
DecodableのDecodeを簡潔に書きたい
Swift4でDecodableつかってますか〜?
公式にサポートしてもらえると本当にありがたいですよね。
しかしながら汎用的なパースをしようとすると若干、コードが冗長になります
公式ドキュメントによると、カスタムなデコードの戦略を図る場合は下記のようにイニシャライザとデコードを実装する必要があります
extension Coordinate: Decodable { init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) latitude = try values.decode(Double.self, forKey: .latitude) longitude = try values.decode(Double.self, forKey: .longitude) let additionalInfo = try values.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo) elevation = try additionalInfo.decode(Double.self, forKey: .elevation) } }
冗長ですね。値がnilや予期しない値だった場合のハンドリングが考慮されていないのでバギーです
これらを簡潔に書く軽いExtensionを作ってみたので、例をもとに説明してみたいと思います。
目指した要件は2点です
- パースが失敗したときのデフォルト値を設定する
- String以外で表現できるプリミティブな型がStringで渡ってくる場合のフォールバック
1. パースが失敗したときのデフォルト値を設定する
犬が受けたワクチンをリストするAPIのJSONのレスポンスを例にしてみます。
Dog.json
[ { "name": "Spring", "vaccinations": [ { "name": "Rabies" }, { "name": "Corona" } ] } ]
構造体
Dog.swift
// 予防接種の種類 struct Vaccination: Decodable { let name: String } struct Dog: Decodable { let name: String let vaccinations: [Vaccination] }
例えば、予防接種を受けた数を表現するとき、受けたことがなかったら空の配列を作りたいとします。
しかし、空の配列は返さず、nullもしくはそもそもそのKey&Valueを返さないAPIなどもありえます。
Dog.json
[ { "name": "Spring", "vaccinations": null } ]
この場合、デコードエラーになるのでパースエラーのときにはvaccinationsに空配列をセットする実装を追加しましょう
Dog.swift
struct Dog: Decodable { let name: String let vaccinations: [Vaccination] init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) name = try values.decode(String.self, forKey: .name) do { vaccinations = try values.decode([Vaccination].self, forKey: .vaccinations) } catch { vaccinations = [] } } private enum CodingKeys: String, CodingKey { case name case vaccinations } }
問題点
do - try - catch
が冗長- パラメータが増える度に
do - try - catch
を実装する必要がある - decodeの型を明示的に指定する必要がある
ということで
失敗時のデフォルト値を指定しつつ、型推論でよしなに型を指定してくれる実装をしたいと思います。
KeyedDecodingContainerを拡張する
decoder.container(keyedBy: CodingKeys.self)
で取得できる型の拡張を書くのが手っ取り早いです
KeyedDecodingContainer+Helper.swift
extension KeyedDecodingContainer { func decode<ResultType: Decodable>(forKey key: Key, defaultValue: ResultType? = nil) -> ResultType? { do { return try decode(ResultType.self, forKey: key) } catch let error { NSLog(error) return defaultValue } } }
これで5行が1行になりました
Dog.swift
struct Dog: Decodable { let name: String let vaccinations: [Vaccination] init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) name = try values.decode(String.self, forKey: .name) vaccinations = values.decode(forKey: .vaccinations) ?? [] } private enum CodingKeys: String, CodingKey { case name case vaccinations } }
2. String以外で表現できるプリミティブな型がStringで渡ってくる場合のフォールバック
次に犬の名前と年をパースする例を上げてみます。
Dog.json
[ { "name": "Spring", "age": "10" } ]
Dog.swift
struct Dog: Decodable { let name: String let age: Int init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) name = try values.decode(String.self, forKey: .name) age = values.decode(forKey: .age) ?? 0 } }
age
がこれでは0になってしまいます。
ここで
- StringからIntにパースする
- それでも失敗したらデフォルト値にする
という要件をKeyedDecodingContainerのextensionで実装してみます
KeyedDecodingContainer+Helper.swift
extension KeyedDecodingContainer { func decodeWithString<ResultType: FallbackableResultType>(forKey key: Key, defaultValue: ResultType? = nil) -> ResultType? { do { return try decode(ResultType.self, forKey: key) } catch let error { NSLog(error) do { let string = try decode(String.self, forKey: key) return ResultType.toResultType(string: string, defaultValue: defaultValue) } catch let error { NSLog(error) return defaultValue } } } } internal protocol FallbackableType { static func toResultType(string: String, defaultValue: Self?) -> Self? } typealias FallbackableResultType = FallbackableType & Decodable extension Int: FallbackableType { internal static func toResultType(string: String, defaultValue: Int?) -> Int? { return Int(string) ?? defaultValue } }
これで、無事、デコードできるようになりました。
肝としては2点
- 最初に各型にデコードできるかトライ
age: 10
だった場合でも処理できるのでAPIを修正した場合にバグにならない
- FallbackableTypeを用意して、String→各型への変換処理を移譲している
- FallbackableTypeに準拠した型に制限していますので、Stringから該当する型に変換する戦略を書く必要があります。
まとめ
ということで、KeyedDecodingContainer
を拡張すればデコード処理は簡潔に書ける〜という話でした
汎用的なAPIClientの設計と実装
TL; DR
Swift4でDecodableを使いつつ、
フレキシブルなAPIレスポンスの設計をしていったら結局APIクライアント書いてたという話。
その設計・実装の流れを綴りました。
長くて読みきれないっていう場合はソースコード読んでもらったほうがいいと思います。
利用ライブラリ
- Alamofire
- Result
- RxSwift
今回は上記を用いてGithubのSearch APIのクライアントを書いてみました
APIClientの抽象化ポイント
まずは抽象化したいポイント、いわゆるジェネリクスで振る舞いの差し替え可能なポイントをさがします。
- リクエスト
- エンドポイント
- パラメータ
- レスポンス
- パース
- エラー
上記の通り、APIクライアントはリクエストに対し対応するレスポンスがあるという点がジェネリクスとの相性がいいです。
リクエストから抽象化していきましょう
リクエスト
protocol Request { var endpoint: URL { get } var parameters: Alamofire.Parameters { get } var responseFormat: ResponseFormat { get } var encoding: ParameterEncoding { get } }
リクエストはこのようにエンドポイントとパラメータを抽象化します。
responseFormat
はenumで対象とするフォーマットを列挙しておきます。
今回はレスポンスの型にDecodableを使うので、期待しているレスポンスのフォーマットに応じてデコーダーを差し替える必要があるので、
予めリクエストに仕込むのがいいです。
まずはエンドポイントから
protocol Endpoint { var endpoint: URL { get } var root: URL { get } var path: String { get } }
URLの構成としてはルートが有り、そちらにパスを付けてURLを完成する形式が汎用的でよいです。
// MARK: - GitHubAPIEndpoint protocol GitHubAPIEndpoint: Endpoint { var functionPath: String { get } } extension GitHubAPIEndpoint { var root: URL { return URL(string: URLConstants.GithubAPIURLRoot)! } var endpoint: URL { return root.appendingPathComponent([functionPath, path].joined(separator: "/")) } } enum GithubSearchAPIEndpoint: String, GitHubAPIEndpoint { case repositories var path: String { return self.rawValue } var functionPath: String { return "search" } }
GithubのAPIはroot+[機能]+[機能を絞ったパス]
という設計なので、functionPathを追加しています。
そこで、機能毎にenumを定義することで[機能を絞ったパス]
はenumにまかせています。
パラメータの抽象化とenumで扱えるようにする
protocol ParameterKey: Hashable { var key: String { get } } protocol AlamofireParameters { var parameters: Alamofire.Parameters { get } } protocol Parameter { associatedtype Key: ParameterKey var parameter: [Self.Key: Any] { get set } mutating func setParameter(_ value: Any, forKey key: Key) } extension Parameter { mutating func setParameter(_ value: Any, forKey key: Key) { parameter[key] = value } mutating func setParameter<T: RawRepresentable>(_ value: T, forKey key: Key) where T.RawValue == String { parameter[key] = value.rawValue } } extension AlamofireParameters where Self: Parameter { var parameters: Alamofire.Parameters { return parameter.associate { (key, value) in return (key.key, value) } } }
検索APIのパラメータは下記の通り
struct SearchAPIParameter: Parameter { var parameter: [SearchAPIParameterKey : Any] = [:] typealias Key = SearchAPIParameterKey init() {} enum SearchAPIParameterKey: String, ParameterKey { case q case sort case order var key: String { return self.rawValue } } } extension SearchAPIParameter: AlamofireParameters {}
varr parameter = SearchAPIParameter() parameter.setParameter(q, forKey: .q)
こんな感じで、クエリがenumで指定できるようにします。
これで、パラメータとエンドポイントをprotocol化して汎用的なリクエストを作ることができるようになりました。
クライアント
基本方針はGenericsによって返り値の型を特定し、結果がマップされるようにします。
protocol APIClient { var sessionManager: SessionManager { get } func get<Response: Decodable>(apiRequest: Request) -> Observable<Response> } internal extension APIClient { internal func _get<Response>(apiRequest: Request) -> Observable<Response> where Response: Decodable { return request(method: .get, apiRequest: apiRequest) } } private extension APIClient { private func request<Response: Decodable>(method: HTTPMethod, apiRequest: Request) -> Observable<Response> { return Single.create { observer in weak var request = self.sessionManager.request(apiRequest.endpoint, method: method, parameters: apiRequest.parameters, encoding: apiRequest.encoding) request? .validate() .responseData(queue: DispatchQueueManager.shared.queue) { self.handleResponse(response: $0, observer: observer) } return Disposables.create { request?.cancel() } }.asObservable() } private func handleResponse<Response: Decodable>(response: DataResponse<Data>, observer: ((SingleEvent<Response>) -> Void)) { guard let data = response.data else { return observer(.error(APIError())) } let jsonDecoder = JSONDecoder() do { let parsed = try jsonDecoder.decode(Response.self, from: data) observer(.success(parsed)) } catch let error { observer(.error(error)) } } }
基本形はこのようになります。
これで各クライアントに APIClient
のプロトコルを付加すれば通信できるようになりました
また、戻り値の中身であるResponse
にDecodableである制約を設けています。
こうすることによってパースの戦略は各Responseに任せつつ、Responseの型が差し替え可能になるので
拡張に開くことができています。
レスポンスのハンドリング
APIの中身をパースして異常だった場合にそのハンドリングをしたいケースもありえますが
ただ、Response
型に欲しい型を指定した場合にそのインターセプトができません、
また、ベースとなるAPIのレスポンスのフォーマットがある場合何度も書くのは面倒です
例えば、下記の場合、Itemsの中身だけがT型として変わる場合です。
{ total_count: 841, incomplete_results: false, items: [ {} ] }
そこで、
- Result型でsuccess
, failureを透過的に扱う - Result型からsuccess
の時はResultからResponseをアンラップする
という要件が出てきます
まずはItemのような差し替え可能な型を内包するProtocolを定義します
protocol BasicAPIResponse: Decodable { associatedtype ResponseT: Decodable }
次に、Githubに特化したベースとなるレスポンスの型を定義します
こんな感じで、T(itemsの型)を差し替え可能なようにしておきます
struct GithubAPIResponseBase<T: Decodable>: BasicAPIResponse { typealias ResponseT = T typealias ErrorT = APIError private let total_count: Int private let incomplete_results: Bool private let items: ResponseT? }
次に結果をResultで受け取れるようにします
Result型はsuccess, failureの2択で結果を透過的に扱えるようにしているenumのライブラリです
protocol APIResult { associatedtype ResponseT: Decodable associatedtype ErrorT: Swift.Error var result: Result<ResponseT, ErrorT> { get } } extension GithubAPIResponseBase: APIResult { var result: Result<ResponseT, APIError> { guard let response = items else { return .failure(APIError()) } return .success(response) } }
これで、特別なAPIのパース処理を書きたい時はGithubAPIResponseBase
のイニシャライザを用意すれば良くなりました。
しかしながらResult型はAPIの結果なのでクライアントを呼び出す側では意識したくありません。
そこでClientでResultをアンラップする仕組みを考えます
ここで使えるのがType Erasureです
struct AnyAPIResult<ResponseT: Decodable, ErrorT: Swift.Error>: APIResult { let _result: () -> Result<ResponseT, ErrorT> init<Base: APIResult> (_ base: Base) where Base.ResponseT == ResponseT, Base.ErrorT == ErrorT { _result = { () -> Result<ResponseT, ErrorT> in return base.result } } var result: Result<ResponseT, ErrorT> { return _result() } } protocol AnyAPIResultConvartibleType: APIResult { var anyAPIResult: AnyAPIResult<Self.ResponseT, Self.ErrorT> { get } } extension AnyAPIResultConvartibleType { var anyAPIResult: AnyAPIResult<Self.ResponseT, Self.ErrorT> { return AnyAPIResult(self) } }
TypeErasureを使ってAnyAPIResultを定義することで、
extension GithubAPIResponseBase: AnyAPIResultConvartibleType {}
GithubAPIResponseBaseをAnyAPIResultConvartibleTypeとして扱うことができるようになりました。
結果的にResult<Response, Error>の型がわかっていなくても、Anyがそこを回避してアンラップ処理を共通化することができるようになりました
extension Observable { func unwrapResult<T, E>() -> Observable<T> where Element == AnyAPIResult<T, E> { return self .asObservable() .map { result in switch result.result { case .success(let t): return t case .failure(let e): return Observable.error(e) } } } }
最終的なGithubAPIClient
struct GithubAPIClient: APIClient { var sessionManager: SessionManager init() { sessionManager = Alamofire.SessionManager(configuration: URLSessionConfiguration.default) } } extension GithubAPIClient { func get<Response>(apiRequest: Request) -> Observable<Response> where Response : Decodable { typealias BasicResponse = GithubAPIResponseBase<Response> return _get(apiRequest: apiRequest) .map { (res: BasicResponse) in return res.anyAPIResult } .unwrapResult() } }
ということで、外から欲しい型を注入し、戻り値の型から欲しい型を決めてレスポンスをパースするということが実現できました。
Github Search APIをたたくサービスをつくる
あとは、Itemsの中身の型を定義して
struct GithubRepositoryDTO: Decodable { let id: Int //以下略 }
Clientを使うサービスを定義し、戻り値の型に戻り値としたい型を指定するだけです。
protocol APIService { var client: APIClient { get } } protocol SearchAPI: APIService { func searchRepository(q: String) -> Observable<[GithubRepositoryDTO]> } struct SearchAPIService: SearchAPI { var client: APIClient { return _client } var _client = GithubAPIClient() func searchRepository(q: String) -> Observable<[GithubRepositoryDTO]> { var parameter = SearchAPIParameter() parameter.setParameter(q, forKey: .q) return client.get(apiRequest: APIRequest(apiEndpoint: GithubSearchAPIEndpoint.repositories, apiParameters: parameter, encoding: URLEncoding.default)) } }
まとめ
ということで、ほぼ、サービスやHTTPクライアントに依存しないインターフェイスができました。
APIClientの実装をAlamofireから別のHTTPクライアントにしたり、レスポンスをデコードする戦略を書き換えたりしても他の層には基本的に影響がない実装ができました。
ここまで、自分で読み返してもうまく説明できてる気がしないのでソースコードを見ることをおすすめします(笑)
Swift4のDictionaryのアプデの目玉が公式ブログ?に掲載されてたので読んでみた
原文はこちら
Swift4ではDictionaryがより便利に使えるようになっているということで サンプルを見ていく
Grouping Values By a Key
Swift3系では
グループ機能がなく、かなりの手順を要していた
// Swift <= 3.1 var grouped: [GroceryItem.Department: [GroceryItem]] = [:] for item in groceries { if grouped[item.department] != nil { grouped[item.department]!.append(item) } else { grouped[item.department] = [item] } }
- まずグループの配列があるかチェック
- あれば配列についか
- なければ配列を作って
grouped
に登録
これがSwift4ではDictionaryのイニシャライザに用意され、1行で書くことができるようになりました。
// Swift >= 4.0 let groceriesByDepartment = Dictionary.init(grouping: groceries, by: { item in item.department })
このまま紹介しても味気ないので僕は下記のように使ってみました
extension Sequence { func group<Key>(by predicate: (Element) throws -> Key) rethrows -> [Key: [Element]] { return try Dictionary(grouping: self, by: predicate) } } let groceriesByDepartment = groceries.group(by: { item in item.department }) // [seafood: [{…}, {…}], bakery: [{…}, {…}], produce: [{…}, {…}, {…}]]
grouping対象はSequenceに準拠している必要があるということで、もはやSequeceに用意しちゃえばいいじゃん!と。
Transforming a Dictionary’s Values
Swift3まではmapすると、(key, value)
形式で走査してましたがmapValue
を使えば、valueだけを走査することができるようになりました。
なお、transformの対象はValueのみ。
let nameByDepartment = groceriesByDepartment.mapValues { items in items.map { item in item.name } } // [seafood: ["Salmon", "Shrimp"], bakery: ["Croissants", "Bread"], produce: ["Apples", "Bananas", "Grapes"]]
Uniquing Keys With
let pairs = [("dog", "🐕"), ("cat", "🐱"), ("dog", "🐶"), ("bunny", "🐰")] let petmoji = Dictionary(pairs, uniquingKeysWith: { (old, new) in new }) // petmoji["cat"] == "🐱" // petmoji["dog"] == "🐶"
SequenceのElementの型がCompound Typeなのでこちらはextensionかけない…
Using Default Values
for item in [🍌, 🍌, 🍞] { cart[item, default: 0] += 1 }
下記でも解説されてますね
Merging Two Dictionaries Into One
var cart = [🍌: 1, 🍇: 2] let otherCart = [🍌: 2, 🍇: 3] cart.merge(otherCart, uniquingKeysWith: +)
この実装見ていて初めて気づいたんですが、closureとしてオペレータ渡せるんですね。
知らなかったので今後の実装に使えそう。
func +(left: T, right: T) -> T { }
冷静に考えると、+ operatorはinfixな関数なので、なるほどSwiftよくできてるな〜と感心した案件でした。