will and way

ただの自分用メモを人に伝える形式で書くことでわかりやすくまとめてるはずのブログ

CIを前提としたプロジェクトのテンプレートができてた話

これはiOSアドベントカレンダーの10日目の記事です。

私にとって今年は、iOSエンジニアに転向した年でした。 それまではAndroid。 そして、携わっているプロジェクトのSwift化(未完)だったりiPhoneX対応だったりと劇的な半年でした。

ほぼゼロから始めたので、iOS SDKのサンプルアプリをいくつか作って勉強してたのですが、
そのうち、 CIを前提としたプロジェクトのテンプレートのような物が出来上がっていたのでそれを紹介したいと思います。

TL;DR

AnsibleでCIできるまでセットアップするプロジェクトテンプレートができてましたという話です。

大まかな使い方

  1. initialタグをチェックアウト
  2. プロジェクトをつくって、リポジトリと同じパスに配置。( initial-projectタグの状態を作る)
  3. プロビジョニング(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のコマンドを叩くまでの時間を短縮することができました。 なるべく前提を減らし、ビルドに必要なアプリケーションがなくても動くように作りました。

流れを書くと

  1. make setup_toolsコマンドでシェルスクリプトをフック
  2. シェルスクリプトXcodeのインストール済みかどうかを判定。なければ、Safariを開いてXcodeのインストールを促す
  3. Ansibleがなければインストール、あればアップデート
  4. 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
    • Xcodeのターゲット名やConfigurationなど、アプリを作るための環境変数の解決
  • Env

    • ビルドを実行するためのコンスタントな環境変数の定義
      • Fabricのトークンやシークレットキー、SlackのWebhook URLを定義
  • Gemfile

    • Bundlerのインストール

FastlaneEnvの管理について

プライベートリポジトリを使っていたので、気にせず直書きしていました。(セキュリティの意識は甘々ですが別の漏れても被害はほぼないので。) また、サンプルを作るときには、Bitbucketを使っていました。Privateリポジトリの作成が無料かつ無制限につかえます。 余談ではありますがCIにはBitriseを使いました。Bitriseは10分以内ビルドなら月に200回まで無料で使えます。 iOSかつ、プライベートなリポジトリのCIが無料でできるのはBitriseだけ(俺調べ)

テンプレートの使い方まとめ(再掲)

  1. タグinitialをチェックアウト
  2. プロジェクトをつくる(実行すると、タグ: initial-projectの状態になる)
  3. プロビジョニング(Ansible)を実行(実行すると、タグapply-provisionの状態になる)

まとめ

ということで、Xcodeプロジェクトを作ってからfastlaneを走らせるまでの作業を自動化してみました。

殆どのプロジェクトでは初回のみなのであまり機会がなさそうなソースですが、
手順が自動化でき、自分にとってのプロジェクトの作成マニュアルができました。

今回は盛り込んでいませんが、xcodeprojを使えばシェルの埋め込みも可能だと思います。
まだまだプロジェクトのテンプレートは育ちそうです。

それでは次は17日のSwiftのAdvent Calendarで〜。

Macでスクリーンショットの保存先の変更と古いスクショの自動削除

デスクトップがスクショの嵐・・・!

f:id:matsuokah:20171102013640p:plain

こんなデスクトップになった経験はないでしょうか。 スクリーンショットの保存先はデスクトップなので、スクショを撮ってるうちにいつの間にかデスクトップがスクショで埋め尽くされることがあります。

精神衛生上よくない!!!

デスクトップは常にきれいでありたいものです。本当作業中で一時的なファイルを置くだけにしたい。

割れ窓理論があるように、デスクトップが心の余裕の無さや秩序を保とうとしているかを表してるんじゃないかという観念に狩られ るんですよね。もはやこれは健やかな精神なのかしるためのバロメータでもあるといえようッッ!!

ということで、整理される環境を整えたいと思います。

結論

2つの工夫で解決できます

1. ターミナルで保存先の変更

$ mkdir -p ~/Pictures/ScreenShots &&  defaults write com.apple.screencapture location ~/Pictures/ScreenShots

f:id:matsuokah:20171102012746g:plain

2. Automator + Calendarで自動的に古いスクリーンショットをゴミ箱に移動する

ターミナルで保存先の変更はDefaultsで解決!

blog.matsuokah.jp

約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を使います。スクリプトを書かない選択をしてみました。

f:id:matsuokah:20171102024333p:plain

こいつです。癖さえわかれば作業・業務を自動化できるので楽しくなります。
エンジニアなら自動化のスクリプトを組むのは日常茶飯事ですがそれがGUIベースでできるイメージです。
アプリケーションベースのアクションが定義されていてその結果を次の操作に繋いでいくイメージでしょうか。

アプリケーションベースのアクションとは
例えば、Finderだったら

  • フォルダを取得する
  • フォルダの名前を変更する

のような単位です。

立ち上げると

f:id:matsuokah:20171102025033p:plain

こんなウィンドウが立ち上がるので左のカラムに並んでいるアクションを、右のエリアにドラッグしてワークフローを作っていきます。

今回やりたいことは「30日以上前に作成したスクリーンショットの削除」

Automatorで扱えるフローに分解すると

  1. スクリーンショットが保存されているディレクトリのパスを取得する
  2. スクリーンショットをリストする
  3. スクリーンショットが作成された日を30日以上前のものにフィルタする
  4. ゴミ箱に移動する

これをAutomatorのフローに当てはめると

  1. Get Specified Finder Items
  2. Get Folder Contents
  3. Filter Finder Items
  4. Move Finder Items to Trash

f:id:matsuokah:20171102030054p:plain

ということでできました。

これで実行するとスクショフォルダ内にある作成日が31日以上前のファイルが一括でゴミ箱に移動されます。

Calendarアプリで定期実行に組み込む

実はCalendarアプリでアプリケーションの定期実行をすることができます。

手順としては

  1. ワークフロー実行用のカレンダーを作成
  2. 上記で作成したカレンダーにスクリーンショット削除の予定をカレンダーに追加
  3. 全日 or 0時などで 毎日繰り返し予定に
  4. カスタムアラートでその時間になったらファイルを開くを選択

こんな感じです

f:id:matsuokah:20171102031142p:plain
予定の追加

ワークフロー実行用のカレンダーを作成

なぜ、ワークフロー実行用のカレンダーを追加したかというと、毎日定期実行の予定がカレンダーを埋め尽くすことになるからです。 カレンダーは分けておいて、ワークフロー実行用のカレンダーを普段は非表示にしておくことをおすすめします。

まとめ

  • defaultsでスクショの保存先を変えてDesktopにスクショがたまらないようにしよう!
  • Automatorとカレンダーでスクショの棚卸しはPCにやらせよう!

Lottieで再生するアニメーションを作って読み込ませるまで

blog.matsuokah.jp

↑の記事は、Lottieを使ってアニメーションの再生をするところを実装しました。

実はハンバーガーアイコンのアニメーションには大きな余白が含まれていて
このまま使うとアイコン自体が非常に小さい表示になってしまっていました。

ということで、次はAfterEffectsプロジェクトを編集して、JSONを書き出すところ紹介したいと思います。

1. AfterEffectsのプロジェクトでアニメーションを編集する

まずはハンバーガーアイコンのプロジェクトをよみこみます。

f:id:matsuokah:20171014131427p:plain

おわかりかと思いますが、コンポジションのサイズ(いわゆる全体の大きさと思ってください)が800x600になっていて、アイコンに対しての余白が非常に大きい状態です。

そこでComposition Settingsを開き、サイズをアイコンが収まるサイズの正方形にします。

f:id:matsuokah:20171014131922p:plain

設定すると、以下のようになります

f:id:matsuokah:20171014132035p:plain

2. 書き出す

次に書き出しです。
Lottieでは、このオープンソースライブラリでjson形式にExportすることを想定して作られています。

github.com

AdobeCCを使っていれば、↓のページでインストールすればAfterEffectsで使用可能になります。

https://exchange.adobe.com/addons/products/12557exchange.adobe.com

それでは、AfterEffectsでプラグインを起動します。

Window > Extensions > Bodymovinで、起動できます

f:id:matsuokah:20171014132345p:plain

起動するとコンポジションを選択する画面がでてきます。

f:id:matsuokah:20171014132431p:plain

Renderで書き出してみます。

特に設定をしていないとこのように、書き出しが許可されてないよというエラーとともに解決方法が提示されます。

f:id:matsuokah:20171014132612p:plain

After Effects CC > Preference > General > Allow Scripts to Write Files and Access Networkにチェックを入れます

f:id:matsuokah:20171014132807p:plain

もう一度書き出すと成功します。

f:id:matsuokah:20171014132841p:plain

あとは、吐き出されたJSONファイルをiOS/Androidに組み込んで使うだけです。

f:id:matsuokah:20171014120502g:plain

これで、自作のアニメーションを組み込めるぞ 🎉🎉🎉🎉🎉🎉🎉

Lottieことはじめ

Lottieとは

Airbnb謹製のアニメーションツールでAfterEffectsでexportしたアニメーションをiOSAndroidで再生できるというすぐれものです。

f:id:matsuokah:20171014111823g:plain

上記の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をプロジェクトに組み込みます

f:id:matsuokah:20171014114015p:plain

let animatedSwitch = LOTAnimatedSwitch.init(named: "Hamburger")

これで読み込めるようになりました。

3. スイッチのアニメーションの範囲を決める

このままではアニメーションはうまく動きません。なぜならスイッチのON/OFFに対してのアニメーションの対応付をしていないからです。

スイッチはoff -> onのアニメーションとon -> offのアニメーションがあります

f:id:matsuokah:20171014114656p:plain

この間ですね。

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. 動かしてみる

f:id:matsuokah:20171014120502g:plain

タップしてるのですがわかりづらいですね(汗)

ということで、アニメーションの組み込みができました。

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))
    }
}

f:id:matsuokah:20171014123342p:plain

これで、InterfaceBuilderでファイル名、アニメーションの範囲を指定できるようになりました

InterfaceBuilder上ではUIViewで枠だけを作っていて
awakeFromNibで内部的にLOTAnimatedSwitchを作ってaddSubviewしています。

fitToParentは親Viewと同じframeになるようにConstraintsを設定しているだけです。

以上、Lottie事始めでした。

リポジトリ

https://github.com/matsuokah/LottieSamplegithub.com

次は、「AfterEffectsからアニメーションのJSONをExportする」記事を書こうと思います
↓書きました

blog.matsuokah.jp

アクションシートのクロージャをObservable化して処理を一本化する

アクションシートってよくつかわれるんですかね? 私が携わっているプロジェクトではそこそこ使われています。

f:id:matsuokah:20171012231640g:plain

コレです

アクションシートの基本的な使い方

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)
  1. UIAlertControllerをつくる
  2. アクションを追加する
  3. 表示する

これが基本的な使い方です。

また、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)
    }
}

動作

f:id:matsuokah:20171012225321g:plain

まとめ

ということで、ViewController側ではアクションシートの選択肢の列挙とそれに対して選択された時の記述をするだけで良くなりました。
UIAlertControllerもほぼ意識せずにできてるのはメリデメあるかもですが記述が簡潔になったかと思います。
extension最高\(^o^)/

サンプルリポジトリ

github.com

DecodableのDecodeを簡潔に書きたい

Swift4でDecodableつかってますか〜?

公式にサポートしてもらえると本当にありがたいですよね。

しかしながら汎用的なパースをしようとすると若干、コードが冗長になります

Apple Developer Documentation

公式ドキュメントによると、カスタムなデコードの戦略を図る場合は下記のようにイニシャライザとデコードを実装する必要があります

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点です

  1. パースが失敗したときのデフォルト値を設定する
  2. String以外で表現できるプリミティブな型がStringで渡ってくる場合のフォールバック

1. パースが失敗したときのデフォルト値を設定する

犬が受けたワクチンをリストするAPIJSONのレスポンスを例にしてみます。

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
    }
}

問題点

  1. do - try - catchが冗長
  2. パラメータが増える度にdo - try - catchを実装する必要がある
  3. 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になってしまいます。

ここで

  1. StringからIntにパースする
  2. それでも失敗したらデフォルト値にする

という要件を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点

  1. 最初に各型にデコードできるかトライ
    1. age: 10だった場合でも処理できるのでAPIを修正した場合にバグにならない
  2. FallbackableTypeを用意して、String→各型への変換処理を移譲している
    1. FallbackableTypeに準拠した型に制限していますので、Stringから該当する型に変換する戦略を書く必要があります。

まとめ

ということで、KeyedDecodingContainerを拡張すればデコード処理は簡潔に書ける〜という話でした

汎用的なAPIClientの設計と実装

TL; DR

Swift4でDecodableを使いつつ、
フレキシブルなAPIレスポンスの設計をしていったら結局APIクライアント書いてたという話。

その設計・実装の流れを綴りました。

長くて読みきれないっていう場合はソースコード読んでもらったほうがいいと思います。

github.com

利用ライブラリ

  • Alamofire
  • Result
  • RxSwift

今回は上記を用いてGithubのSearch APIのクライアントを書いてみました

APIClientの抽象化ポイント

まずは抽象化したいポイント、いわゆるジェネリクスで振る舞いの差し替え可能なポイントをさがします。

  1. リクエス
    1. エンドポイント
    2. パラメータ
  2. レスポンス
    1. パース
    2. エラー

上記の通り、APIクライアントはリクエストに対し対応するレスポンスがあるという点がジェネリクスとの相性がいいです。

リクエストから抽象化していきましょう

リクエス

protocol Request {
    var endpoint: URL { get }
    var parameters: Alamofire.Parameters { get }
    var responseFormat: ResponseFormat { get }
    var encoding: ParameterEncoding { get }
}

リクエストはこのようにエンドポイントとパラメータを抽象化します。

responseFormatenumで対象とするフォーマットを列挙しておきます。
今回はレスポンスの型にDecodableを使うので、期待しているレスポンスのフォーマットに応じてデコーダーを差し替える必要があるので、
予めリクエストに仕込むのがいいです。

まずはエンドポイントから

protocol Endpoint {
    var endpoint: URL { get }
    var root: URL { get }
    var path: String { get }
}

URLの構成としてはルートが有り、そちらにパスを付けてURLを完成する形式が汎用的でよいです。

例えば、GithubのSearch APIのレスポンス

// 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"
    }
}

GithubAPIroot+[機能]+[機能を絞ったパス]という設計なので、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: [
        {}
    ]
}

そこで、

  1. Result型でsuccess, failureを透過的に扱う
  2. 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クライアントにしたり、レスポンスをデコードする戦略を書き換えたりしても他の層には基本的に影響がない実装ができました。

ここまで、自分で読み返してもうまく説明できてる気がしないのでソースコードを見ることをおすすめします(笑)

github.com

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]
    }
}
  1. まずグループの配列があるかチェック
  2. あれば配列についか
  3. なければ配列を作って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
}

下記でも解説されてますね

Swift 4の魅力の一面を3行で表す - Qiita

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よくできてるな〜と感心した案件でした。