Swiftはじめました - ゼクシィiOSアプリの場合

保坂智之
87

はじめに

Swift はじめました。と聞くと、読者の方は「いまさら?遅すぎじゃない?」とか、「大切なのは何の言語で書くかよりも設計じゃないの?」などと思われるかもしれません。気持ちはわかります。

しかし実際、ゼクシィアプリのコードベースは今まで Objective-C 100% でした。そして、つい最近、はじめてプロダクションコードとして Swift のコードをリリースすることができました。本稿では、そこに至るまでに考えたことや、具体的なやり方を紹介できればと思います。

申し遅れましたが、この記事はゼクシィ iOS アプリの開発を担当している @tondol がお送りします。好きな結婚式ソングは lily white で「ふたりハピネス」です。1)ラブライブ!のキャラクターソングです。わざわざ脚注までお読みいただき、ありがとうございます。

背景

前述の通り、ゼクシィアプリはこれまで Objective-C で開発されてきました。

ざっくりとプロジェクト内を検索した中だと、最古の Copyright 表記は2012年でした。また、最初のリリースは2011年であるため、ゼクシィアプリは、8年以上も世の中のカップルの式場探しや結婚の準備を支えてきたということになります。

個人的には、これは iOS アプリとしては結構な歴史を感じる長さかなと思います。コードベースにも開発フローにも長年降り積もった多くの課題がありますが、その中でも目立ったのが「100% Objective-C で実装されている」という点でした。

Swift 化をする理由

(1) EOSL 問題

Objective-C ではなく Swift で iOS アプリを開発する理由は、もはや私がここで解説するまでもなく、たくさんあります。

まず、2020年現在、Objective-C は Apple や OSS 作者にとって積極的にサポートする言語ではなくなっており、Objective-C をサポートしたライブラリは年々減り続けています。いわゆる EOSL (End Of Service Life) の問題です。

(2) Objective-C エンジニアの確保が困難

また、現役の iOS エンジニアにとっても、Objective-C はおそらく積極的に勉強したり読み書きしたいと思える言語ではなくなってきているでしょう。

これは、新たにゼクシィアプリの開発に協力してくれるモバイルエンジニアを探そうと思ったときに、無視できないハードルになります。いや、既になっていました。

(3) NULL 安全性

Swift には、Objective-C には無かった Optional の概念が導入されています。

Swift では、nil が入りうるオブジェクトは、Optional の仕組みにより注意深く取り扱うことが強制されます。この機能により、Objective-C で nil がほぼ空気のように扱われていたがために潜在的に発生していた、様々なバグが見つけやすくなります。

// Objective-Cでは、nilに対するメッセージ(メソッド呼び出し)も素通りする。
NSString *foo = nil;
NSArray<NSString *> *result = [foo componentsSeparatedByString:@","];
NSLog(@"result: %@", result); // result: (null)
// Swiftでは、Optionalな型に直接メッセージを送る(メソッドを呼ぶ)ことはできない。
let foo: String? = nil
let result = foo.components(separatedBy: ",") // compile error!!
print("result: \(result)")

(1) (2) (3) で上げた以外にも、Objective-C と比較して Swift は、エンジニアに多彩な表現力をもたらしてくれます。

こういった理由を総合的に評価した結果、ゼクシィアプリも重い腰を上げて、コードベースの Swift 化へと歩みを進めることとなりました。

ターゲット選定

ゼクシィアプリには約18万行のコードベースが存在し、その規模は日々少しずつ増え続けています。また日々たくさんのユーザーがアプリを利用しており、新機能の開発を止めることはビジネス的にも許容できませんでした。

そうした状況の中で、まず私は Swift のコードを「小さく」アプリに取り込むことを考えました。

つまり、「アプリをまるごと Swift で実装し直してやる!」というビッグバンアプローチではなく、アプリのユーザーには気づかれないような方法で Swift のコードを導入することを考えました。また、Swift 化と同時にリファクタリングやリアーキテクチャリングをするような、同時に2つのことを目指すアプローチもやめました。

そうすることで、Swift コードを導入することによるリスクをなるべく減らそうとしたのです。

Objective-C のコードを Swift に置き換える範囲も、API リクエストやローカル DB への永続化のような要素は最低限持っているけれども、変更の影響範囲が明らかな一部の画面の ViewController に限定しました。

上レイヤーの Swift 化ならテスト範囲は小さくなる
上レイヤーの Swift 化ならテスト範囲は小さくなる

多くの部分で共通で使われているレイヤーを Swift 化しようとすると、リグレッションテストのために多くのリソースが必要になってしまうためです。2)リグレッションテストではなく自動ユニットテストで実装の正しさを担保すればいいじゃないか、と思われる方もいらっしゃるかもしれません。現状の実装は API レイヤーや DB レイヤーと密結合な部分が多く、テスト可能性の高い設計に変更する必要がありましたが、それをやるだけのリソースを確保するのが難しいという事情がありました (泣) 。

Swift 化のやり方

STEP1 : UnitTest のターゲットに導入

プロダクションコードに Swift を導入する前段として、UnitTest を Swift で書いてみることにしました。

モジュールに最初の .swift ファイルを追加しようとすると、Xcode が自動的に Bridging-Header を生成するか質問してきます。ここで「Create Bridging-Header」を選ぶことで、プロジェクトの Swift ビルド設定が有効になります。あとは Bridging-Header に必要な import 文を追加し、Swift で実装を始めるだけです。

Swift でユニットテストを書く過程で、Objective-C で実装されたメソッドであっても、Swift から見ると見た目が若干変化することが分かりました。最初に遭遇したときは戸惑いましたが、おかげで単純なリライトでも見た目が多少 Swift らしくなりました。3)Swift から Objective-C のメソッドを参照したときにどのように見えるか、厳密には Name Translation from C to Swift で解説されていますが、正直複雑で私は理解しきれていません。なお、Objective-C において Swift 向けにメソッドの別名を付けたい場合は NS_SWIFT_NAME というマクロを使うこともできます。

// Objective-Cの場合。WithQueryまでがメソッド名。
[self.presenter searchRequestedWithQuery:@"青山"];
// Swiftの場合。自動的にwithQueryが第1引数のキーワードとして扱われる。
presenter.searchRequested(withQuery: "青山")

この時点ではプロダクションコードは変更していませんが、Swifty に (Swift らしいコードで) Objective-C クラスのテストを書けるだけでもちょっと嬉しいですよね。

Objective-C と Swift が問題なく共存できることが分かったため、Swift で書いたテストコードはすぐにリポジトリに取り込まれ、Swift 化をさらに進めることとしました。

以下は、Swift で実装したテストコードの例です (モック実装は省略) 。

// MARK: - Unit Test for Presenter with Mocked Service
class ZEWeddingClientSearchResultPresenterTests: XCTestCase {
    
    var view: ZEWeddingClientSearchResultViewMock!
    var presenter: ZEWeddingClientSearchResultPresenter!
    
    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        view = ZEWeddingClientSearchResultViewMock()
        let transition = ZEWeddingClientSearchResultTransitionMock()
        let searchService = ZEWeddingClientSearchServiceMock()
        let usecase = ZEWeddingClientSearchResultUsecase(searchService: searchService)
        let clipService = ZEWeddingClientClipServiceMock()
        presenter = ZEWeddingClientSearchResultPresenter(view: view, transition: transition, usecase: usecase, clipService: clipService)
    }
    
    func testSearchRequestedWithQuery_success() {
        presenter.searchRequested(withQuery: "青山")
        
        // assert search results
        XCTAssertFalse(view.viewModel.shouldShowLoadingCell)
        let searchResults = view.viewModel.searchResults
        XCTAssertEqual(searchResults.count, 1)
        XCTAssertEqual(searchResults[0].clientName, "テスト用式場")
    }
}

STEP2 : extension による漸進的な Swift 化

一般に、ひとつの class が大きくなりすぎることは保守性の観点から言って好ましくありません。しかしながら、9年間成長し続けてきたコードベースの中には、1000行・2000行を超える ViewController も存在します。最初の Swift 化の対象にした「閲覧履歴」の画面もそうでした。

ゼクシィアプリのチームでは、エンジニアが書いたコードはマージの前に (当然ながら) レビューされます。しかし、1000行・2000行の ViewController を Swift 化したとして、そのコードを丸ごとレビューするのは、アプリ開発経験の長いエンジニアにとっても難しいことです。コードレビューの単位は、多くとも500行以下の diff に分割することが望ましいでしょう。

そのため、このプロジェクトでは Swift の extension 機能を使って、ViewController のメソッドを徐々に Swift 化することにしました。具体的には、既存の Objective-C クラスに対する extension を Swift で定義し、その中にメソッドを数個ずつ移植していきます。その途中途中で動くコードをレビューすることにしました。

クラスの定義自体をいきなり Swift 側に移行しようと思うと、中のメソッドをすべて Swift 化する必要がありますが、この方法なら漸進的にクラスの内容を Swift に移行していくことが可能です。メソッドの移植は、まず private なものから始め、次に public なものを、最後に viewDidLoad など、ライフサイクルに関わるものを移植していくのがスムーズだと思います。

Obj-C から Swift extension にメソッドを移行
Obj-C から Swift extension にメソッドを移行

これにより、レビュワーは無理なく Swift 化の過程をチェックすることができますし、バグが発生した場合もどのメソッドに問題があるのか、原因調査がしやすくなりました。

STEP3 : SwiftLint によるコードレビューコストの削減

Swift 化の指針としては、リクルートライフスタイルが公開しているコード規約 swift-style-guide を参考にしました。

しかし、細かいスタイルなどは手作業で書いていると漏れてしまうこともあり、レビュワーとしてもそういった細かい指摘ばかりするのは生産的ではありません。そのため、このプロジェクトでは SwiftLint を導入し、ビルド時に Lint が走るようにセットアップしました。

これにより、エンジニアが Swift コードを書きながら、Xcode 上ですぐに問題のある箇所を発見できるようになりました。

SwiftLint には多くのルールがありますが、今回の目的は既存コードのロジックをそのまま Swift 化することなので、守ることが難しいいくつかのルールはオプトアウトしました。

現在ゼクシィのプロジェクトで使っているルールを紹介します。4)単純な Swift コードへの置換を目指しているため、ルールは若干緩めに設定しています。

# .swiftlint.yml
disabled_rules:
  - todo
  - force_cast
  - line_length
  - function_body_length
  - file_length
  - cyclomatic_complexity

included:
  - Zexy/Swift
  - ZexyUnitTests/Swift
excluded:
  - Pods

identifier_name:
  min_length: 2
trailing_whitespace:
  ignores_empty_lines: true
vertical_whitespace:
  max_empty_lines: 2

また、変更されたファイルのみをターゲットに SwiftLint を実行するスクリプトも用意しました。

if git rev-parse --verify HEAD >/dev/null 2>&1; then
    against=HEAD
else
    # empty tree
    against=4b825dc642cb6eb9a060e54bf8d69288fbee4904
fi
​
if which "${PODS_ROOT}/SwiftLint/swiftlint" >/dev/null; then
    for FILE in `git diff-index $against --diff-filter=ACMR --name-only | grep -E '\.swift$'`; do
        "${PODS_ROOT}/SwiftLint/swiftlint" lint --path "${FILE}"
    done
else
    echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"
fi
SwiftLint の warning 例
SwiftLint の warning 例

これにより、コードレビュー時のコストをいくらか削減することに成功しました。

(おまけ) NSStringFromClass / NSClassFromString の落とし穴

Swift 化を進める過程で判明した問題を1つ紹介します。

それは、Swift 化対象の class を NSStringFromClass ないしは NSClassFromString の引数に指定したときに起こる問題です。

実は、Objective-C で実装した class を Swift でリライトした場合、名前は同じものにしていても、内部的な扱いは若干異なるものになります。Swift 化した class は、内部的にはアプリ本体と同じモジュール名の prefix を持つようになるからです。 (シングルモジュールの場合に発生する問題であり、元々マルチモジュール構成の場合は事情が異なるかもしれません。)

たとえば、Objective-C で実装された ZEHistoryBaseViewController を元に、ViewController を生成する次のようなコードがあったとします。

// UIStoryboard+Convenience.m
+ (id)instantiateViewControllerWithClass:(Class)classObject
{
    NSBundle *bundle = [NSBundle bundleForClass:[UIApplication sharedApplication].delegate.class];
    NSString* className = NSStringFromClass(classObject);
    UIStoryboard *storyboard = [UIStoryboard storyboardWithName:className bundle:bundle];
    return [storyboard instantiateViewControllerWithIdentifier:className];
}

// FooBarViewController.m
ZEHistoryBaseViewController *vc = [UIStoryboard instantiateViewControllerWithClass:ZEHistoryBaseViewController.class];

このコードは、該当 ViewController のクラス名と同じ名前の Storyboard を作成し、Storyboard の custom class パネルで ViewController にクラス名と同じ Storyboard ID を設定することで動作します。こういった便利メソッドを用意している Objective-C プロジェクトは多いのではないでしょうか?

しかしながら、ZEHistoryBaseViewController が Swift 化されると、NSStringFromClass が返す文字列は "ModuleName.ZEHistoryBaseViewController" となり、Storyboard ID を一緒に変更しない限り、ViewController のインスタンス化ができなくなってしまいます。

同様の問題が NSClassFromString を使っている場合にも発生します。

この問題に対しては、NSClassFromString を使わずにクラス名を直接文字列定数に置き換えることで対処しました。

今後の展望

ここまで紹介したやり方で、2019年12月にはじめての Swift コードをプロダクションリリースすることができました。

しかしながら、ViewController の手動リグレッションテストに多くの時間を費やしていたり、ViewController 以外のレイヤーの Swift 化戦略がまだ決まっていなかったりと、今後コードベース全体を Swift 化するには高いハードルがあります。単に Swift 化するだけで自動的にテスタビリティが上がるわけではないので、テスト可能性を高めるための設計改善を進める必要もあります。

ところで、Objective-C から Swift 化された class を参照する際には、ModuleName-Swift.h という、Xcode により自動生成される Objective-C 向けのヘッダーファイルを import する必要があります。

このヘッダーは同じモジュール内のどこかの Swift コードが変更されると毎回書き換わるため、Swift コードを変更する度に多くのファイルのリビルドが必要になってしまっています。今はまだ大きな問題になっていませんが、Swift コードが増えると徐々にビルド速度が低下してしまう恐れがあります。

これらの問題に対処するため、End-to-End の自動テスト導入や、Embedded Framework を利用したアプリのマルチモジュール化などを検討しています。

ViewController 以外のレイヤーの Swift 化も今後試していきたいと思っています。

まとめ

私がゼクシィ開発チームで取り組んでいる Swift 化プロジェクトと、いくつかのプラクティスを紹介しました

ここには書ききれないプラクティスや泥臭い部分もありますので、気になった方はぜひお問い合わせください (@tondol 宛でも大丈夫です!!) 。

User Experience をもっと高めるために、引き続き、Developer Experience の向上にも力を入れていきます。レガシーコードに戸惑うこともありますが、毎日少しずつでも前進していると信じ、やっていきましょう〜

脚注   [ + ]

1. ラブライブ!のキャラクターソングです。わざわざ脚注までお読みいただき、ありがとうございます。
2. リグレッションテストではなく自動ユニットテストで実装の正しさを担保すればいいじゃないか、と思われる方もいらっしゃるかもしれません。現状の実装は API レイヤーや DB レイヤーと密結合な部分が多く、テスト可能性の高い設計に変更する必要がありましたが、それをやるだけのリソースを確保するのが難しいという事情がありました (泣) 。
3. Swift から Objective-C のメソッドを参照したときにどのように見えるか、厳密には Name Translation from C to Swift で解説されていますが、正直複雑で私は理解しきれていません。なお、Objective-C において Swift 向けにメソッドの別名を付けたい場合は NS_SWIFT_NAME というマクロを使うこともできます。
4. 単純な Swift コードへの置換を目指しているため、ルールは若干緩めに設定しています。