2014 年に Apple の新しい汎用的なプログラミング言語として Swift が発表されました。Swift は、それまで Apple プラットフォームのアプリ開発で主流だった言語 Objective-C に比べてモダンで安全性が高いというメリットがあります。それだけでなく、今後 Objective-C が deprecate されてアプリ開発に完全に必要となることも考えられます。
minne では 2015 年に導入し、機能開発と並列してコツコツと Swift 化を行ってきました。 2017 年には 38% のコードが Swift 化されていましたが、このままのペースで進んでいくと Swift 化を終えるのが 5 年後になってしまうので、改めて目標を「Swift の割合 90 % 達成」として定め、これを推進する体制を整え、 より Swift 化のペースを上げて作業してきました。
そして…
令和元年、2019 年 11 月に…
遂に!! 目標として定めていた Swift の割合 90% を達成することができました!!!
minne の iOSアプリは 2013 年に誕生し、6 年もの間運用され、コードの行数は 6 万行を超える大規模アプリにまで成長しています。この 90% なので約 54,000 行ものコードを置き換えたことになります。
今回は、長い年月を費やしてこれだけのコードを私たちがどのようにして Swift 化をしてきたのかを紹介していきます。
Objective-C と Swift の相互利用
minne における Swift 化のやり方を紹介する前に、まずは Objective-C と Swift の相互利用する方法を紹介していきます。
Swift in Objective-C
クラス・プロトコル
Swift で定義されたクラスやプロトコルを Objective-C コードで利用する方法は以下のエントリに書いているので、そちらをご参照ください。
Swift の Class, Protocol を Objective-C で使う - ドン・エンジニーア
メソッド & プロパティ
Objective-C から利用する Swift コードには @objc
アノテーションを付与します。Swift コードをコンパイルすると、自動で \(プロジェクト名)-Swift.h
というヘッダファイルが生成されます。
これを利用したい .m
ファイルで import することでコードを呼び出せるようになります。
class MinneClass: NSObject {
@objc func printMinne() {
print("minne")
}
}
// MinneClass.m
#import <UIKit/UIKit.h>
#import "minne-Swift.h"
@implementation ViewController: UIViewController
- (void)viewDidLoad
{
[super viewDidLoad];
[[MinneClass new] printMinne];
}
@objc
をクラスの全てのメソッドやプロパティに適用する @objcMembers
もありますが、不要なものに対してもつけてしまいやすいので個別に @objc
をつけるようにしています。
なお、Objective-C でサポートされていない struct や Bool の Optional 等をプロパティやメソッドの引数の型に指定することはできないので、その場合には別途メソッドを生やしたりする必要があります。
Objective-C in Swift
Objective-C のコードを Swift で利用するには、 Bridging Header を作成する方法と Module Map を作成する方法の2通りがあります。
Bridging Header
\(プロジェクト名)-Bridging-Header.h
というファイルを作り、そこに利用したいコードのある .h ファイルを import します。Bridging Header はグローバルに import されるため、それだけで Swift から利用できるようになります。
// MinneClass.h
- (void)printMinne;
// MinneClass.m
@implementation MinneClass
- (void)printMinne
{
NSLog(@"minne");
}
@end
// minne-Bridging-Header.h
#import <MinneClass.h>
// ViewController.swift
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
MinneClass().printAppName()
}
}
Module Map
Module Map は Bridging Header の上位互換と言え、Module Map により、.h ファイルをモジュール化し、そのモジュールを import することで Objective-C のコードを参照できるようになります。 Bridging Header とは異なってグローバルに import されず、必要な箇所でのみ import を記述することになるため、依存関係が分かりやすくなります。 ただ、アプリケーションターゲットでしか使えないため、 Framework 開発には利用できません。
module MinneObjC {
header "MinneClass.h"
}
// ViewController.swift
import UIKit
import MinneObjC
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
MinneClass().printAppName()
}
}
お約束
ここからは minne での取り組みについてご紹介します。
Swift 化を進めるにあたって、お約束(ルール)を定めていました。お約束を事前に定めておくことで、効率よく作業を進められたり、同じ議論の繰り返しを避けることができました。
新たに Objective-C のコードを書かない
minne に Swift を導入した当初から定めている、基本的な約束事です。
Objective-C のクラスに新しくメソッドを追加する必要が出てきた時には、Swift で Extension を生やしてそこにメソッドを定義していきます。
// NG
// MinneClass.m
@implementation MinneClass
- (void)printMinne
{
NSLog(@"minne");
}
@end
// OK
// MinneClass.swift
extension MinneClass {
func printMinne() {
NSLog("minne")
}
}
Objective-C のコードに手を加える必要が出てきた時には、 最初に関係するコードを Swift 化をしてから新たな処理を追加していきます。 ただ、特に Objective-C の割合が多い状況では、関係するコードを Swift 化すると芋づる式に Swift 化する箇所が出てきてしまい、影響範囲が広くなり過ぎてしまう場合には、 Objective-C のコードを記述することを妥協していました。
リファクタリングはしない
「新たに Objective-C のコードを書かない」ということもあり、 Objective-C のコードは昔に書かれたもの多く、設計も現在のプロダクトの状況に適していないことがほとんどです。こういうコードを見るとエンジニアである以上リファクタリングをしたくなりますよね。 ですが、ここはぐっと堪えます。
Swift 化において最も重要なのは、「等価に置き換えること」です。 リファクタリングをしてしまうと、元のコードと等価であるかという点がコードの差分を見た際に分かりづらくなってしまいます。これを避けるためにリファクタリングは行わないというお約束を設けています。 ただ、リファクタリングといっても種類も規模も様々なので、変数名のリネームであったり、一部の処理をメソッドに切り出したりといった細かいものに関しては許容しています。
また、Swift 化する人もレビュワーもこうした方がよさそうということは皆思うので、ここのコードをこうしたいとか、今後こうするともっと良いよねという議論も行わないということではなく、むしろ推奨しています。
Pull Request の作り方
Swift 化の手順を書くと、ざっくり以下のようなフローになります。 この手順を適切な単位でまとめて Pull Request として完成させます。
- Nullability の指定
- extension 用 Swift ファイルの作成
- 置き換えるメソッドのテストを書く
- Swift へ書き換え
- テストを通す
- 呼び出し箇所の置き換え
- Objectvie-C メソッドの削除
- 3〜7 を繰り返す
- プロパティを Swift クラスへ移動
- Objective-C クラスの削除
- クラスのリネーム
この中からいくつかピックアップして説明していきます。
1. Nullability の指定
Objective-C は Swift と異なって型安全ではありません。そのため、 どんな型の変数に対しても nil を代入できます。型安全な言語と型安全でない言語を相互に利用する場合には型の不一致によるバグが発生し得るため、可能な限り機械的に検知できるように、 Objective-C の段階で Nullable, Nonnull を指定しておきます。 非常に重要な作業なので、ここの指定に焦点を絞ることができるように、単一の Pull Request にするようにしています。
どのように Nullability を判断するかですが、これはコードを読むしかありません。 Xcode の Find Call Hierarchy によって対象のプロパティの利用箇所を調査して、nil の代入、初期化処理や nil のハンドリングの有無を確認していきます。
正しく指定できたら利用箇所を修正します。Nullable を指定したプロパティを Swift の文字列リテラル内で展開している場合には Optional(こんにちは)
のように表示されてしまうので、文字列リテラル内での参照の有無も要注意です。
3. 置き換えるメソッドのテストを書く
Swift 化は言語仕様が異なるので想定していない挙動が起こりやすく、これを防ぐためにテストが大活躍します。 Swift コードに書き換える前に Objective-C のコードを理解して仕様を把握し、それを担保するテストを書きます。これができればもう Swift化 できたようなものです。 なお、ビュー周り等の箇所で、テストを書くためにやらないといけないことが多すぎる場合はその限りではなく、書けるところだけ書きながら進めていました。
4. Swift へ書き換え
メインとなる置き換え作業です。ただひたすら Objective-C のコードを Swift に書き換えるだけですが、インターフェースについて注意する必要があります。インターフェースを Swift らしく修正すると、利用箇所を置き換える必要が出てきてしまうことがあります。
例えば、以下のメソッドの Swift へ提供されるインターフェースは、
- (void)updateWithProduct:(Product *)product;
以下のように、 前置詞 with が引数のラベルへと自動でコンバートされており、 Swift らしくなっています。
func update(with product: Product)
そのため、このメソッドを Swift する際にも上記と同様に定義してあげることで、 呼び出し箇所を修正する必要なく Swift らしさを保つことができます。
一方、以下のメソッドの Swift へ提供されるインターフェースは、
- (void)printProduct:(Product *)product;
以下のように Objective-C と同様のインターフェースとなるため、これを修正してしまうと利用箇所まで修正する必要が出てしまいます。
func printProduct(_ product: Product)
そうすると他の作業とコンフリクトが生じやすくなるので、これを避けたい場合には極力インターフェースを変更しないようにします。 他の作業との調整が取れている場合にはこのタイミングでインターフェースも Swift らしく変えて問題ありません。
また、Objective-C からしか呼ばれていないメソッドに対しては @obj アノテーションを指定する方法もオススメです。これを使うと Objective-C へのインターフェースを別途指定することができるので、 Swift のメソッド定義は Swift らしく行いつつも、 Objective-C は異なるインターフェースを提供し、呼び出し箇所の修正を防ぐことができます。 例えば先ほどのメソッドを以下のように Swift らしく定義したとしても、 @objc アノテーションを用いてインターフェースを別途指定すると、
@objc(printProduct:) func print(_ product: Product) { ... }
以下のように Objective-C 用のインターフェースが提供され、利用箇所の修正が不要となります。
- (void)printProduct:(Product * _Nonnull)product;
9. プロパティを Swift クラスへ移動
メソッドが全て Swift 化されたら残るはプロパティです。 まずは extension をクラスに書き換えて、
- extension MinneClass {
+ class MinneClass {
プロパティたちを全て Swift クラスに移動します。 Swift らしくない命名のプロパティはこの時に変えたくなりますが、 4. で書いたように、他の作業とのコンフリクトを調整しながら行います。
11. クラスのリネーム
この作業だけは必ず単一の PR で行います。 他の修正が含まれていると、作業ファイルや作業量によっては Git が元のファイルを削除して新しいファイルを作ったと判断する場合があり、そうなると実際に修正した部分の特定が難しくなり、レビューに余計なコストが生じます。 これを回避し、 Git がリネームのみと判断できるようにするために、リネームのみを単一の Pull Request として出します。
レビュー
Pull Request の見方
「PR の作り方」で書いた通りに作業を進めて Pull Request を出すと、1つの Pull Request に Objective-C のコードの削除と Swift のコードの追加が含まれます。 削除されたコードと追加されたコードが等価な処理をするかどうかをレビュワーは確認します。 それぞれコードを比較できると確認しやすいので、以下のようにディスプレイを左右に2分割して、削除したコードと追加したコードを並べて行なうのがオススメです。
レビュワー
私たちのチームは全ての Pull Request のレビュワーを2人で運用しており、Swift 化の Pull Request においても同様に2人としています。
Swift 化を始めた当初は、「書き換えるだけ」なので1人としていました。 しかし、その「書き換えるだけ」をするためには、両言語の仕様を把握している必要があり、加えて、単調な作業のため思わぬ見落としが発生しやすいです。主にこの2点を要因にバグを生じさせてしまったことがあり、それ以降はレビュワーを2人にして運用するようにしています。
ここまでが Swift 化のお作法です。
@josh が勉強会で発表した際の資料もあるので、こちらもよかったら見てみてください。
時間の確保
Swift 化は非常に多くの時間を要する作業です。そこで課題となるのが、この時間をどのようにして確保するかです。
以下では、 minne で時間確保のために行なった取り組みについてご紹介します。
Swift 化スプリント
minne の開発チームは、スクラムをベースに開発を進めており、スプリントの単位を2週間として定めています。
Swift 化スプリントとは、1つのスプリントを丸々 Swift 化にあてようというものです。当時 iOS アプリエンジニアは3名おり、 全員が Swift 化に取り組みます。
もちろん機能開発もあるため、 Swift 化スプリントは 3 回に1回の頻度として定めました。
初めのうちは上手く回っていたものの、機能開発とのリソースの取り合いとなってしまいました。
開発する機能の規模によっては、スプリントをまたぐことがあります。そういった場合には、コンテキストスイッチによるコストを少なくしたり、他の施策とコンフリクトしないために、着手したものは最後まで作りきることを優先していました。そうすると、Swift 化を進める人数が 2人、1人と減っていってしまうという事態が発生しました。しかも、頻度としては決して少なくありませんでした。
さらに、事業である以上、緊急度の高い施策が挙がることもあり、こちらも同様に Swift 化を行う人数が減ってしまうことがありました。
こういった場合には、減った分の代わりとして通常スプリントを Swift 化にあてることにしたのですが、管理が複雑になり、徐々に機能しなくなってしまいました。
Swift 化担当
そこで、定期的に Swift 化を全員でガッと進めるやり方ではなく、Swift 化担当を1人置き、一定のペースで常に Swift 化を行う方法に切り替えました。担当はもちろん固定ではなく、持ち回りで行なっていきます。
加えて、見積もりしたタスクをアサインする際には、事前に Swift 化分の時間を事前に確保しておくようにしました。
この方法は以前の Swift 化スプリントに比べて上手くいき、最後までこの方法で運用することができました。
合宿
Swift 化を推進していく中で、2回の合宿を行いました。合宿をすること、そして平日ではなくてあえて休日に行なったこと(もちろん代休有りで費用は会社負担)で、以下のような効果があったと感じています。
- 休日なので Swift 化のみに集中できる
- まとまった時間を確保ででるため影響範囲の広いところに取り掛かりやすい
- 普段と異なる環境でモチベーションアップ
それぞれの合宿の詳細については以下のエントリに書いていますので、興味のある方をそちらをご参照ください。
進捗確認
進捗の確認するためにツールを導入しました。これらのツールを導入して進捗が分かりやすくすることでモチベーションの維持にも効果があったと思います。
kachikachi
Swift 化の目標を割合を 90% と書いていますが、正確には、削除する Objective-C のコードの空行・コメントを除いた行数を目標として定めていました。 理由としては、以下の通りです。
- Swift コードを大量に追加するだけで割合が増える
- 既存の Objective-C のコードが削除することが目的
- 空行・コメントは削除しても意味がない(全くないわけじゃないけど)
これを計測する方法として kachikachi という CLI ツールを作りました。以下のようにして利用できます。
$ bundle exec kachikachi count —repo={REPO} —milestones={1.0.0 2.0.0} or pull-request-numbers={1 2 3}
path/to/file: deleted 1 lines
path/to/file: deleted 1 lines
path/to/file: deleted 6 lines
path/to/file: deleted 150 lines
path/to/file: deleted 1 lines
👋👋👋 total 159 lines 👋👋👋
これを申請毎に実行して issue へコメントしています。もちろん CI を用いて自動化しています。
LattnerBot
Swift の割合は @josh 作の LattnerBot というボットを用いて通知していました。こちらはリリースフローに合わせてリリースが完了すると Slack へ通知されるように自動化しています。 視覚的にわかりやすいので、 iOS アプリエンジニア以外からもリアクションが付きやすくてわいわいしやすいです。
90% を達成した際のめでたい通知がこちらです。
tokei
プロジェクト全体の Objectvie-C, Swift のコードの行数を確認する際には tokei という CLI ツールを使用していました。 実行すると下記のように、各言語ごとの空行・コメントを除いた行数が結果が得られます。
-------------------------------------------------------------------------------
Language Files Lines Code Comments Blanks
-------------------------------------------------------------------------------
C Header 78 2058 1094 558 406
JSON 5 5 5 0 0
Objective C 76 6800 4642 721 1437
Swift 718 82273 57451 7172 17650
Plain Text 1 227 227 0 0
YAML 1 7 7 0 0
-------------------------------------------------------------------------------
Total 879 91370 63426 8451 19493
-------------------------------------------------------------------------------
最後に
以上が私たちの Swift 化の道のりです。
Swift 化 90% を達成した今では、 Objective-C のコードを書くことも読むこともほとんどなくなっており、Swift の新機能もすぐに使うことができたり、言語間によるコンテキストスイッチが無くなったり、安全性を高めることができたりと、日々 Swift 最高だなと思いながら開発できています。
残りの 10% をどうするのかについてですが、Swift 化の目標を 90% として定めたのは、全体のコードのうち 10% のコードはほとんど手を加えない箇所であり、集中的に Swift へ書き換えても恩恵は少ないということが理由だったので、これらのコードを積極的に Swift へ書き換えるということはせずに、触る機会があれば都度書き換えていくことで 100% を目指していきたいと考えています。
Swift を書きたい、minne を開発したいという方がいらっしゃいましたら、お気軽にご連絡ください。