クックパッドのiOSアプリ開発を加速させるスクリプト群

こんにちは、技術部モバイル基盤グループの茂呂(@slightair)です。

今回は、ちょっと地味ではありますが、クックパッドのiOSアプリ開発を支えているスクリプト群について書きたいと思います。

日々iOSアプリ開発を行うとすれば、Xcodeまたはその他のお気に入りのエディタでコードを書き、ビルドと実行を繰り返して開発を進め、アプリが完成したらサブミット、めでたくリリースという流れになると思います。 場合によってはこうした開発の所々をサポートするツールを使うこともあるでしょう。クックパッドでもいくつかのツールを使っていますし、場合によっては自作することもあります。

ツールを導入することで解決できることであればそれでよいですが、もうちょっと気の効いたことをして欲しい、リリースフローなど自分たちのアプリ開発の進め方の都合で発生する繰り返しタスクを省力化できないか、というような比較的小さな問題を解決するために、僕たちは今回紹介するようなスクリプトを用意しています。

開発支援系

アプリ開発を支援するスクリプト群を紹介します。

利用ツールのバージョンチェック

クックパッドのiOSアプリ開発では CocoaPods, Carthage, clang-format, SwiftLint, SwiftFormat などのツールを使っています。 数が多い上にこれらのツールの更新頻度はバラバラで、バージョンによって動きが違ったりします。複数人でアプリを開発しているので、環境によって期待している効果が得られないと困ってしまいます。そのため、各開発者の環境に期待しているバージョンがインストールされているかチェックするスクリプトを用意しています。

ライブラリ群のインストール

クックパッドのアプリは CocoaPods と Carthage を併用しています。 CocoaPods は Objective-C で書かれた静的リンク可能なライブラリ群を、Carthage は Swift のライブラリ群をインポートするために使っています。 何故このように使い分けているかというと、CocoaPods で導入しているライブラリの一部が静的ライブラリであって use_frameworks! が使えないからです。 またフレームワークが増えるほどアプリ起動に時間がかかってしまう問題を防ぎたいというのも理由です。

CocoaPods と Carthage のコマンドを叩き、Carthage がチェックアウトしたライセンスファイル群を CocoaPods の acknowledgements.plist にマージするスクリプトを用意しています。

また、Carthage にはこの記事を書いている時点では更新のあったライブラリだけをビルドし直す仕組みがなかったので、ビルド時間を抑えるために更新が必要なライブラリだけをビルドするスクリプトを用意していました。 この問題への対応はすでに PR が出てマージされているので、新しいバージョンではこの処理は必要なくなりそうです。

前述のツール群のバージョンチェックとあわせて make でこの操作を実行できるようにしています。 各開発者は手元へ最新のコードを fetch した後に make コマンドを実行することで、チームで期待されている開発環境にそろえて開発に取り掛かることができるようになります。

コードフォーマッタ

コードフォーマッタに clang-format と SwiftFormat を使っています。 直接これらのツールを使ってもよいのですが、ちょっとした工夫をしています。

clang-format はオプションで format をかけたい範囲を指定できるので、開発者が変更を入れた箇所にだけフォーマッタをかけるスクリプトを用意しています。ファイル単位でも良いのですが、PRを出した時に変更とは関係のないフォーマッタによる修正が混ざるとレビューする側がつらくなってしまうので、こうしています。

また、フォーマッタにかけるのを git の commit hook などに仕込むこともできると思いますが、意図的にしていません。これは、フォーマッタによって自動的に変更されたコードを開発者に確認してもらいたいからです。

リリースフローに関係するタスク補助系

クックパッドのリリースフローに含まれるタスクのうちスクリプトで解決しているものを紹介します。

アプリのバージョンを繰り上げる

アプリのバージョンを変更するというただそれだけのスクリプトを用意しています。特に面白みのないものですが、Extension を複数持つアプリだとそれに対応する info.plist がいくつもあるので地味に面倒な作業であることがわかると思います。クックパッドではこの部分の変更作業を複数人で回しているので、スクリプトで行えるようにしているのは作業する人によって微妙に違う内容になってしまうのを避ける目的もあります。

開発に関わった人ごとのマージコミットをリストにする

リリースフローに、次にリリースしようとしているバージョンのコードに自分がいれた変更が正しく含まれているか各開発者が確認する手順があります。このチェックリストをつくるスクリプトがあります。 単純に git のコマンドを利用してマージコミットのリストを作るだけのものですが、このような単純な作業こそスクリプトにしやすく、単調で間違いやすいタスクなので、スクリプトで片付けるようにしています。

開発環境の計測系

開発に直接関わるものではなく、開発環境がどういう状態であるか計測するためのスクリプトもあります。

Swift 移行率の計測

毎日リポジトリの master ブランチに含まれる iOS アプリのコードのうち Swift のコードの割合がどれくらい変化しているか計測・記録し、グラフに描画するようにしています。 単純な興味もありますが、Objective-C のコードが残っていると Swift の便利な機能が使えない場面が多くでてきてしまうので、必要に応じて Swift への書き換えを進めています。この進行具合が可視化されると、少しだけやる気がでたり達成感が得られます。

ビルド時間の計測

アプリのビルドにどれくらい時間がかかっているのか、バージョンを重ねるたびに変に伸びたりしていないか計測しています。ビルドに時間がかかっているということは、それだけ開発者を待たせてしまい生産性を下げていることになるので、状況を把握するために記録しています。ここで計測している時間をもとに、ライブラリの選定や場合によっては直接的な対策を行います。特に Swift を採用してからは書き方によってビルド時間が変に長くなってしまうこともあるので xcprofiler などを使って具体的な場所を特定したりもします。


これらのスクリプトはだいたい Ruby で書かれています。 Makefile などから呼び出されているスクリプトもありますが、最近は fastlane をよく使っているので、fastlane action として実装し直そうという動きもあります。今回紹介したようなスクリプトの中で汎用的な action として分離できるものがあれば、公開していきたいと考えています。

クックパッドではiOS/Androidに詳しいモバイルアプリ開発エンジニアはもちろんのこと、このようなモバイルアプリ開発をより効率よくするために活躍できるエンジニアを募集しています。

『Swift実践入門』2月7日発売

 海外事業向けのiOSアプリケーション開発を担当している西山(@yuseinishiyama)です。より海外事業に注力するため、今年度から、海外事業の拠点であるイギリス、Bristolのオフィスに出向しています。クックパッドは現在、15言語、58カ国以上を対象にサービスを展開しています。

 先日、ヴァンサンが国内向けのアプリケーションのSwift 3化に関する記事を投稿しました。同じく、海外向けのアプリケーションも、昨年12月にSwift 3化した最初のバージョンをリリースしました。以前、Swift移行の記事で説明したとおり、このプロジェクトはほぼSwiftによって実装されているため、Swift 3化によってほぼ全てのコードが影響を受けました。幸いにも、大きなトラブルは起きませんでした。

 この度、こうした業務での経験を活かして、『Swift実践入門』という書籍を技術評論社のWEB+DB PRESS plusシリーズから2月7日に発売することになりましたので、この場を借りて宣伝させていただきます。APIKitなどで著名な@_ishkawaさんとの共著となります。

Swift実践入門 ── 直感的な文法と安全性を兼ね備えた言語 (WEB+DB PRESS plus)

Swift実践入門 ── 直感的な文法と安全性を兼ね備えた言語 (WEB+DB PRESS plus)

 また、刊行記念のイベントも開催することになりましたので、興味のある方はぜひご参加ください。

執筆の動機 〜Swiftのwhyとwhenの解消〜

 SwiftがAppleから発表されたのは2014年です。安全かつ簡潔な文法という触れ込みが印象的でした。iOS開発者としては当然居ても立ってもいられず、発表後すぐ、beta版の公式ドキュメントの全てに目を通しました。そこで強く感じたことは、「簡潔ではあるが簡単ではない」ということです。Objective-Cのユニークな記法と比較すると、フレンドリーな見た目にはなりましたが、その豊富な言語仕様を適切に使いこなすのは容易ではないと思いました。

 Appleの公式ドキュメントをはじめとして、どんな(what)言語仕様があり、それらをどのように(how)使うかに関しては早い段階から豊富な情報源がありましたが、それらがなぜ(why)存在し、いつ(when)使うべきかについてまとまった情報があるとは言えない状況でした。『Swift実践入門』はこうした状況を解消することを主眼としています。

Swiftの難しさ 〜Swiftらしいコードを書くために必要な知識〜

 単にSwiftでコードを書くことと、Swiftらしいコードを書くことは別のことです。ここで言うSwiftらしさとは、Swiftがその言語仕様を持って実現したい世界に倣ったコードのことです。Swiftは豊富な言語仕様を持っていますが、それは同時に、豊富な選択肢があるということを意味します。全てのケースに当てはめれるような解があるわけではなく、それぞれの仕様の存在意義を明確に把握し、都度適切な判断を下さなければなりません。

 初学者がSwiftの言語仕様をある程度理解した後に躓きやすい典型的な箇所として、次のようなものが挙げられます(「使い分け」という言葉が繰り返し登場することから、同じことをするにも複数の方法があることが見て取れるでしょう)。

  • Optional<Wrapped>の使い所
  • 高機能なパターンマッチ
  • モジュールのアクセスレベル
  • プロトコルと継承の使い分け
  • キャプチャリストの使い分け(weakunowned、何もつけない)
  • 値型と参照型使い分け
  • ifguardの使い分け
  • 定数と変数の使い分け
  • エラー処理の使い分け(Optional<Wrapped>do-catchResult<T, Error>)

 例えば、「Optional<Wrapped>の使い所」を説明するために、書籍内では次のようなコードが登場します。どちらにどのようなメリットがあるか適切に説明できるでしょうか。

// 全てのプロパティがOptional<Wrapped>型のケース
struct User {
    let id: Int?
    let name: String?
    let mailAddress: String?

    init(json: [String : Any]) {
        id = json["id"] as? Int
        name = json["name"] as? String
        mailAddress = json["mailAddress"] as? String
    }
}

// Failable Initializerを使うケース
struct User {
    let id: Int
    let name: String
    let mailAddress: String?

    init?(json: [String : Any]) {
        guard let id = json["age"] as? Int,
              let name = json["name"] as? String else {
            return nil
        }

        self.id = id
        self.name = name
        self.mailAddress = json["email"] as? String
    }
}

 『Swift実践入門』は、これらのこと1つ1つ言及し、読者のwhywhenを解消します。

おわりに

 『Swift実践入門』はその名の通り、単なる入門書や言語仕様の解説にとどまらない、実践的な内容を扱っています。著者2人で450ページ強というなかなかのボリュームですが、これからSwiftをはじめようという方から、よりその知識を深めたいという方にまで、ぜひ手にとっていただきたい一冊です。

 一方、『Swift実践入門』は実践的な書籍ではありますが、やはり本当の意味での「実践」からしか得られないこともたくさんあります。例えば、クックパッドでは長期間に渡って大量のユーザーを支えることができるアプリケーションを構築する必要がありますが、そのためにはさらに進んだトピックに取り組まなければいけません。例えば、チームに最適化されたStyle Guideを制定したり、肥大化するビルド時間に対処することがそれにあたります。

 クックパッドでは、こうしたAdvancedな課題に対処したいSwiftエンジニアも募集しています。

国内事業: https://recruit.cookpad.com/jobs/career_recruitment/ios-android/

海外事業: TokyoBristol

Swift 3 マイグレーション

技術部モバイル基盤グループの ヴァンサン です。

西山が 以前紹介したように 、クックパッドでは 2014 年から Swift を使っています。長い間、海外向けのアプリや みんなのお弁当 だけに使われていましたが、去年の5月から、 クックパッド iOS アプリ の開発にも Swift を使うようになりました。歴史のある iOS アプリなので Objective-C でのコードの方がまだ多いのですが、いまは既存の画面の変更を除いて新しいコードが Swift で書かれています。既存の画面を Swift で書き直すこともあります。

Xcode 8.0 がリリースされてから数ヶ月 Swift 2 を使っていましたが、去年の12月のリリース直後に Swift 3 へのマイグレーションをしてから、開発で Swift 3 を使っています。2017年2月1日にリリースされた 17.1.1.0 が Swift 3 を使ってビルドされた最初のバージョンです。

Swift 3 で変わったことを紹介するブログ記事は他に多くあるので、この記事では主にマイグレーションに焦点をあてて書こうと思います。

Swift 3 へのマイグレーションでは、殆どの Swift で書かれたコードが変わります。ですので、同じアプリでマイグレーションと並行して別の開発をすることは難しいです。そのため、年末年始辺りに開発のペースが落ちているのを利用して、2日間、他の開発を中断してマイグレーションを行いました。もちろん必要な期間はアプリのコードやその複雑さによりますし、早めに終わらせるために事前準備が必要ですね。

参考として、クックパッドiOSアプリのマイグレーションを行った時点では、ライブラリを含まなければ、 Swift のコードはファイル数264個(テストを含まなければ213個)、行数2.1万行(空行除けば1.7万)くらいでした。

事前準備

まず、アプリに使われる Swift でのフレームワークが Swift 3 に対応している必要があります。クックパッド iOS アプリでそれが問題になる可能性を見越していたのもあって、 Swift でのフレームワークの導入を抑えていました。マイグレーションの時点で社外 Swift フレームワークは HimotokiResult の2つだけだったので、社外フレームワークの Swift 3 対応に関して問題は特になかったです。

すべてのフレームワークが Swift 3 に対応していたら、試しに手元でマイグレーションをやってみることを強く推奨します。ビルド完成までいかなくても、何時間かやってみれば、色々見られると思います。特にマイグレーション中他の開発が進まないので、本番はできるだけスムーズに進みたいですよね。

僕が試しにマイグレーションをやって分かったことをいくつか紹介しましょう。

  • マイグレーションツールが全部やってくれると思わない方がいいですね。マイグレーションツールを掛けた後に修正がかなり必要になるでしょう。
  • マイグレーションツールが変な変更をすることがあります。例えば、クックパッドアプリでは setTitle(buttonTitle, forState: .Normal) がなぜかツールに setTitle(buttonTitle, forState: UIControlState()) に変換されていました。そういう時、いつでもマイグレーション前のコードと比較できる状態でいる必要があります。あと、それをメモしておいて、次回ツールを掛けた直後にプロジェクトのファイルにそれを検索してすぐ直した方が早いですね。
  • #if / #else / #endif の間に挟まれているコードはマイグレーションツールに無視されて、 Swift 2 のままです。なので、マイグレーションツールを掛ける前にできるだけ #if / #else / #endif をコメントアウトして、ツールを掛け終わったら戻しておきましょう。
  • Carthage とかで入れたフレームワークはマイグレーションがアプリのコードと同時に行われたわけではないので、ツールが何が変わったのか把握しきれません。なので、フレームワークの変更の一部を自分でコードに適用する必要があります。試しにやったマイグレーションでどういう変更が必要そうなのかメモして、本番当日に一部を「全て置換」とかで変えられるようになった方がいいかもしれません。

また、マイグレーションの間に他の開発者が読める Swift 3 の仕様変更のドキュメントを用意してもいいですね。

マイグレーション本番

マイグレーション済みのコードがマージされるまで同じアプリで他の開発を中断しているので、本番は早めに終わらせる必要がありますね。本番の流れは基本的に以下の通りです。

  • 上記に書いた通り、 #if はできるだけコメントアウトします。
  • マイグレーションツールを掛けます: Xcode で Edit → Convert → To Current Swift Syntax
  • 事前準備でメモしたものを利用しながらビルドできるまでコンパイラーが出しているエラーを修正します。

事前準備でやるべきことをだいたい分かったはずなので、早いペースで進められるはずです。

ビルドできて、テストが通って、アプリを実行してみても問題なさそうな状態にもっていくのが重要です。少し不自然なコードは軽く修正できるならすぐ直してもいいのですが、それ以外の修正やもっと Swift 3 っぽくするのも後日でいいはずです。他の開発者が開発をできるだけ早く再開できてほしいですからね。

コードレビューされてからマージするのですが、変更が多いし、開発者がまだ Swift 3 に慣れていないのもあるでしょうし、しっかりしたコードレビューは難しいですね。あとで不具合があってもリリースまで気づく可能性を高めるため、マイグレーションを新しいバージョンの開発の開始時点で行いました。

マイグレーション後

折角 Swift 3 にしたので、 Swift 3 っぽくしたくなりますね。弊社では少し前から Swift 3 の API Design Guidelines をある程度使っていたので、既存の Swift コードが割りと Swift 3 っぽかったです。とはいえ、できることがいくつかありました。

enum

Swift 3 では、 enum の頭文字が小文字になっていて、マイグレーションツールは自動的に多くのを変えてくれるのですが、 StringrawValue を持つ enum (enum MyEnum: String で定義されたもの)は変えてくれません。値名を変えると値変わりますからね(実際値が明確に指定されているとしてもツールが変えてくれませんが)。そういう enum を1つずつ確認して、変えても挙動が変わらなければ頭文字を小文字にした方がいいと思います。

クラスプロパティ

Swift 3 に伴って、 Objective-C でクラスプロパティを使えるようになりましたね。使うと Swift で余計な () が必要じゃなくなります。例えば

// 変更前
NS_ASSUME_NONNULL_BEGIN
@interface CKDActivityLogger : NSObject
+ (instancetype)defaultLogger;
// (省略)
@end
NS_ASSUME_NONNULL_END

// 変更後
NS_ASSUME_NONNULL_BEGIN
@interface CKDActivityLogger : NSObject
// クラスプロパティは instancetype を使えないので、型は明確に
@property (nonatomic, class, readonly) CKDActivityLogger *defaultLogger;
// (省略)
@end
NS_ASSUME_NONNULL_END

それに合わせて Swift 3 側で CKDActivityLogger.default()CKDActivityLogger.default 変える必要があります。そういうところで () がない方が Swift っぽくて読みやすいと思います。

新しい Swift Foundation クラスのブリッジング

Swift 3 では、 Swift から見る Objective-C メソッドの引数の一部の型が変わりました。 NSDateDate に、 NSURLURL に、 NSIndexPathIndexPath に、 NSErrorError に、など。

Objective-C 用のクラスに生やしていたヘルパーメソッドは一時的に as で元の型にキャストしないと使えません。一番ややこしいのは Error です。 Error には、 NSError と違って codedomainuserInfo というプロパティがないので、 as NSError がけっこう必要になります。

マイグレーション後、 Swift のコードに Objective-C Foundation の型(NSDate, NSURL, NSError, …)が残ることありますが、 Swift でできるだけ Swift Foundation の型(Date, URL, Error, …)に変えた方がコードがもっと Swift っぽくて読みやすい気がします。 Objective-C 用のクラスに生やしていたヘルパーメソッドを Swift 用の型にも生やした方が良いと思いますね(Objective-C を使わない場合、 Objective-C用のクラスにまだ生やす必要もないですね)。

Any

Swift 2 で AnyObject になっていたところの多くが Swift 3 では Any になります。マイグレーション後、それに合わせてアプリ内(ディクショナリの値とかで)残っている一部の AnyObjectAny に変えた方が、多くの as AnyObject が消せてコードがもっとシンプルになる場合が多い気がします(実はマイグレーションツールも多くの as AnyObject を入れたりします)。ただし、 AnyAnyObject と違って別の型にキャストしないとメソッドを呼べないので要注意です。

マイグレーション後に気づいた問題

現時点で Swift 3 にした影響で起きた問題は iOS 8 でしか起きない1つの問題だけです。

具体的に、 UITableViewDelegatetableView(_:heightForRowAt:) で起きる問題です。 iOS 8 では、テーブルビューを使う画面から別の画面に遷移する時、 tableView(_:heightForRowAt:) が呼ばれて、それに渡されている IndexPathrow が間違っています。4行あるテーブルの場合、row が 0, 1, 2, 3 ではなく、 0, 0, 1, 2 になってしまいます。

もう少し調べてみると、 iOS 8 では、遷移の時だけ、渡された index path のクラスが UIMutableIndexPath (NSIndexPath のサブクラス)になっています。 Swift のソースを見たら 分かりますが、 NSIndexPath を Swift 用の IndexPath への変換に getIndexes:range: というメソッドが使われています。 iOS 8 では -[UIMutableIndexPath getIndexes:range:] で取得される値が間違っているため、 IndexPath に変換されて間違っている値になります。因みに、 -[UIMutableIndexPath indexAtPosition:] は正しい値を返しているようなので、上記にリンクした IndexPathinit(nsIndexPath:) を以下のコードに変えたら動きそうですね(スピードとかに影響あるか分かりませんが)。

fileprivate init(nsIndexPath: ReferenceType) {
    let count = nsIndexPath.length
    if count == 0 {
        _indexes = []
    } else {
        _indexes = (0..<count).map { nsIndexPath.index(atPosition: $0) }
    }
}

結局その問題が起きたビューコントローラは同じセクションでは高さ2種類だけだったので、2つのセクションを分けて回避できました。でもそういったバグが他にないか心配なので iOS 8 対応をまだしているアプリは Swift 3 が推奨しづらいですね。

最後に

クックパッド iOS アプリで Swift 3 のマイグレーションはしっかり準備した結果 iOS 8 での問題を除いて大きな問題は無かったです。 Swift 3 は変更が多いのですが、言語は色々改善されていると思いますので、 Swift 3 のマイグレーションをしてよかったと思っています。

そもそも近いうちに出る Xcode 8.3 はもう Swift 2.3 に対応しないので、 Swift で iOS の新しい機能を使いたかったら Swift 3 にしない選択肢はないですね。 Xcode 8 の Swift 2 対応にコードエディター関連問題がいくつかありますし。

最後に、 iOS 8 対応をしていない Swift アプリは早めに Swift 3 へのマイグレーションをした方がいい気がします。 iOS 8 対応をしているアプリは少し悩ましいですね。 iOS 8 対応を切るまで Xcode 8.2.1 で我慢するか、 Swift 3 にするけど iOS 8 での動作確認がしっかりするか、ですかね。後者は一部のコードを Objective-C で書く必要が出てくるかもしれませんが。

クックパッドでは Swift 3 で開発したいモバイルエンジニアを募集しています