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 で開発したいモバイルエンジニアを募集しています