Objective-CからSwiftへ、4つの移行ポイント~メルカリの実践例から最適な手法を学ぶ

多くの企業でObjective-CからSwiftへの移行が行われていますが、どのような戦略、手順が必要になるのでしょうか。実践に基づくノウハウを、メルカリの小林晋士さんが解説します。

Objective-CからSwiftへ、4つの移行ポイント~メルカリの実践例から最適な手法を学ぶ

2014年にSwiftが登場して以来、その利便性の高さから多くのiOSエンジニアがこの言語を用いるようになりました。それに伴い、Objective-Cで書かれたアプリケーションをSwiftに移行する企業も増えています。フリマアプリ「メルカリ」の開発・運営で知られる株式会社メルカリも、そのひとつです。本稿では、TOPLOG株式会社と株式会社メルカリの2社でObjective-CからSwiftへの移行を経験した小林晋士さんに、移行にあたり策定すべき戦略とポイントについて解説していただきました。

なぜSwiftで作るのか

iOSがリリースされた当初、ほとんどのアプリは当然Objective-Cで作られていました。オフィシャルにサポートしている言語としてはObjective-C++もありましたが、これはあくまでObjective-CとC++のブリッジング以上のものではなく、あまり積極的に用いられるものではありませんでした。

筆者個人としては長らくC++でプログラミングしており、同言語が持つ機能を好んでいたため、UI以外をすべてC++で実装したアプリケーションを作ったこともあります。その時作成していたアプリで扱うデータを管理するには、C++の強力な機能である多重継承とtemplateが必要だったからです。

2014年のWWDCでSwiftが発表された際はSwiftという言語自体を学ぶことが話題の中心でした。当時、議論されていたことの大きなトピックの一つにOptionalがありました。Optionalな型とはnilを代入できる型のことで、逆に言うとOptionalでない型にはnilを代入できません。

Objective-CのクラスはすべてOptionalでnilを代入できていました。それどころか、あるクラスの変数からメソッドを呼び出した際、その変数にnilが代入されている場合は何もしないという、nilかどうかの判定を実行時に毎回行う非常に動的な言語でした。

逆にSwiftではそもそもOptional型でないとnilを代入できません。最近のモダンな言語では一般的となった機能ですが、Swiftが世に出た当時はこの概念に慣れていないことから、多くの人がどのようにOptionalを扱うべきか議論を交わしていました。

Swift1.0の頃は、まだ実際にプロダクトをSwiftで開発する、という話はあまりありませんでした。その後、2015年にSwift2.0が発表され、多数の強力な文法が追加されました。特にProtocol Extensionは非常に優れた機能で、Appleがこの頃に提唱したProtocol Oriented Programmingという名称はあまり流行りませんでしたが、Swiftらしい実装をするためには欠かせない考え方といえます。

Protocol ExtensionとGenericsの組み合わせこそが、多重継承とtemplateのエッセンスを取り込んで発展させた機能といえるかもしれません。そして、この頃からObjective-CからSwiftへプロダクトを移行するケースが増えてきたと記憶しております。

なぜObjective-CではなくSwiftでアプリを作るのかというと、ひとえに最近のアプリケーションが重厚・複雑化してきているということに他なりません。

昔のiOSアプリケーションはシンプル・単機能であることが評価されましたが、スクリーンの大画面化・ユーザーの熟達に伴って、徐々にアプリケーションは複雑になっていきました。また、開発者が増加したことにより、シンプルで有用なアプリケーションはすぐに競合がひしめき合う状況となったのです。そもそもApple自体が優れたサードパーティの競合アプリと同じ用途のアプリを標準アプリとして追加するケースもありました。

こうした背景から、差別化のために容易には真似できない独自の機能を持たなければ、アプリが生き残れない時代になったのです。

アプリの機能と実装が複雑になっていくことで、プログラミング言語としては

  • シンプルで短く記述できる
  • 処理が高速である
  • 記述の誤りが実行時に分かる動的な言語ではなく、コンパイル時に判別できる静的な言語である
  • 共通の処理をクラスの継承関係に依存せず、目的の機能の単位で記述できる

ことが望まれるようになりました。

前述したOptionalやProtocol Extensionはこれらの特徴を強力にサポートしています。これが近年多くのアプリがSwiftで実装されている理由だと考えています。

Objective-CからSwiftへの移行戦略

Objective-CからSwiftへの移行と一言でいっても、アプリやサービスの状況、会社のリソース配分などによりさまざまな方法が検討されます。大まかには下記のように分類できるでしょう。

  • 一括リニューアル
  • 部分リニューアル
    • 共通処理を移行する
    • 共通処理を移行しない

一括リニューアルは既存のコードベースを全て捨てて完全に作り直す方法です。逆に部分リニューアルは既存のコードベースを残しながら画面をひとつずつ作り直す方法です。さらに、部分リニューアルについては、アプリの共通処理を作り直すかそうでないかで分類できます。これは2者択一になるものではなく、「一部の処理は残すが一部の処理は作り直す」といった決定がとられるケースが多いかと思います。

JP版メルカリでは部分リニューアルで徐々にリニューアルを進めており(社内ではre-architectureと呼んでいます)、現状は共通処理もほとんどは既存のコードベースのものを使用しています。

一般的なアプリの共通処理としては、APIクライアントやログ出力機能、ローカルDataBase、画像やAPIレスポンスのキャッシュ、初期データ用のマスタ、A/Bテストなどサーバーサイドからアプリの動作を制御する機能などが挙げられます。

私が以前勤務していたTOPLOGでも同様に部分リニューアルでObjective-CからSwiftへの移行を行いましたが、共通処理は移行しながらすべてSwiftで書き直していきました。

これはアプリの複雑性や、ログやA/Bテストの制御機構・初期データなどアプリエンジニアだけで決定できない要素が、JP版メルカリに比べてはるかに少なかったことが、共通処理を含めてリニューアルできた理由になります。

一括リニューアルのメリット&デメリット

一括リニューアルについて具体的に説明します。こちらは同じ機能を持った新しいアプリを作成することと同じです。メリットは既存のコードベースを全て破棄するため、技術的負債を一掃できるということです。また、この場合アプリのデザインやバックエンド側のコードも一新するケースが多いかと思います。

逆にデメリットとしては、一括リニューアルの期間中、既存のアプリの改善が完全にストップすることです。アプリの規模にもよりますが、数ヶ月間既存アプリの開発がストップするというのは経営的観点では大きな決断になるでしょう。

リニューアルチームと既存アプリの改善チームの2チームを並走させることがデメリットの解決策にも思えますが、これは非常に難しいと思います。改善チームの修正により、リニューアルチームの開発仕様が安定せず、非常にストレスの高い状態になりかねないからです。

また、一括リニューアルの段階では機能自体の絞り込みを行い、あまり使われていない機能をクローズするなどの決定が必要になるでしょう。それができない環境ならば、部分リニューアルを選択したほうが良いかと思います。

部分リニューアルで知っておきたいコツ

次に部分リニューアルについて説明します。こちらは画面を一つずつ中のコードを入れ替え、一つの画面が完成した段階でリリースしていく方法です。リニューアルをしながらでも別の画面では機能開発を継続できること、新しい画面を作る際にはリニューアルした構成で作成するなど、柔軟にプランを設定できることがメリットです。

逆にデメリットとしては、すべての画面がリニューアルされるまでに長い時間を要し、多くの場合古いコードすべてが置き換えられる時は永遠に訪れないことが挙げられます。つまり技術的負債が常に残り続けることを意味します。

部分リニューアルにおいて肝要なのは、デザインと機能はひとまず変更しないでおく、ということです。デザインと機能変更をリニューアルと同時に進行する問題点は、リニューアルした画面のリリースタイミングがそれに縛られることと、問題があった場合の切り戻しが容易にできなくなることです。

リニューアル中は同一機能のコードが2重になっているため、できるだけ小さく、できるだけ早くリリースしていくことが重要です。しかし、新機能やデザインの修正が入るとリニューアルした画面をすぐにリリースできず、同一機能のコードが2重管理となってしまいます。

また、リニューアルした画面に問題があった際にすぐに古い画面に戻せるようにしておくと良いでしょう。JP版メルカリではA/Bテストの機能を使い、サーバー側の設定変更で旧画面と新画面の入れ替えが可能な状態で開発を進めています。

一括リニューアルはObjective-CからSwiftへの移行というよりは、原則新しいアプリを作り直すことと同じ意味合いですので、本記事では以降、部分リニューアルについて解説していきます。

Objective-CからSwiftへの移行の4つのポイント

さて、Objective-CからSwiftへの移行する場合のポイントとして下記が大事だと考えています。

  1. 移行は画面単位で、ひとつの画面にObjective-CとSwiftを混在させない
  2. Objective-Cの機能を呼び出す場合はSwiftでWrapperを作成し、ビジネスロジックから直接Objective-Cに触れない
  3. BaseViewControllerを作成しない
  4. SwiftからObjective-Cを呼び出すことは問題ないが、Objective-CからSwiftを呼び出す場合には制限があることを理解する

1, 2のポイントはそれぞれObjective-CとSwiftをきちんと切り分けることが目的です。リニューアルの場合、基本的には言語のみならずアプリケーションのアーキテクチャ自体も最適なものを採用することが非常に重要で、Swiftの言語にマッチしたアーキテクチャを採用するのが良いでしょう。その場合、Objective-CとSwiftが混在する環境だとSwiftで最適化されたアーキテクチャが実装できません。

Objective-Cの機能呼び出しにSwiftでWrapperを作成する理由

2つ目のポイントについてもう少し詳細に説明します。例として、Objective-Cにおいてよく使用されているAFNetworkingという通信ライブラリを挙げます。

AFNetworkingは優れたライブラリであり、もちろんSwiftからも使用できますが、これをビジネスロジック内で直接使用してはいけません。AFNetworkingでAPIをコールする場合、通常レスポンスの型はJSONがデコードされてNSArrayとNSDictionayにマッピングしたid型(Swiftの場合はAny型)になります。

Objective-C時代のビジネスロジックでは、UIViewController内でこの中から個別に値を取得し展開する実装がよく見られました。これはObjective-Cが比較的動的な言語で、各要素の型を実行時に判定する書き方が容易なため頻繁に採用されていた手法です。

しかし、Swiftは実行時でなくコンパイル時に型を判定する静的な言語のため、こうした書き方が難しい上、コンパイル時に型を保証するSwiftの利点を潰してしまいます。

APIクライアントのロジックがシンプルならばAPIクライアントごとSwiftで書き直せばいいのですが、ロジックが複雑で書き直しが難しい場合があります。

その場合、まずSwiftで当該ロジックのWrapperを書くことをおすすめします。APIレスポンスの型をStructで定義し、Wapper部分でAPIリクエストの実行とレスポンスのオブジェクトからレスポンス型への変換を行います。オブジェクトからレスポンス型への変換はSwift4.0から追加されたCodableを使用しましょう。レスポンスの型をDecodableプロトコルに準拠させると、

let responce = try JSONDecoder().decode(Response.self, from: data)

と1行でJSONをデコードできます。また、APIクライアントの中で実際にAPIをコールしている部分は、APIKitを使用して書き直しても良いでしょう。これは、APIリクエストの型とレスポンスの型をマッピングしてくれるネットワーククライアントのライブラリです。

JP版メルカリの場合、APIレスポンスをキャッシュする機能などを実装している部分は現時点ではまだ一部Objective-Cのコードを利用していますが、APIをコールしている部分はSwiftでAPIKitを使用するように書き直しています。

つまりAPIクライアントの共通ライブラリとしてSwiftのコードがあり、Swiftへの移行がまだされていない処理が必要な場合はObjective-CのAPIクライアントを呼び出し、Objective-CからAPIKitを使用しているSwiftのコードを呼び出す形になっています。

BaseViewControllerを回避する理由

3つ目のBaseViewControllerとは、Objective-C時代によく作られた、ViewControllerの共通処理を集めた巨大なViewControllerです。複数のViewControllerで使われる共通的な処理をすべて記述したBaseViewControllerを作成し、個別の画面のViewControllerはBaseViewControllerを継承するという構造になります。

このような構造になったのは、Objective-Cが多重継承に対応していなかったことが要因にあると考えています。しかし、この構造は多くの問題をはらんでおり、今日Swiftで記載するコードでは避けるべきアンチパターンの代表と言えます。問題点としては下記が挙げられます。

  • ViewControllerに複雑な処理が記載され、ViewControllerが肥大化する
  • 必要のない共通処理まで強制的に含まれており、その画面に本当に必要なコードが解読できない
  • BaseViewControllerが肥大化すると、共通処理の中で状態により条件分岐が多数入り込み、特定の画面から処理自体を追うことが著しく困難になる
  • UITableViewController, UICollectionViewControllerなど標準のUIViewController拡張クラスが使えなくなる

またBaseViewControllerの類似として、複数の画面で同一のViewControllerを使いまわし、ViewControllerの中で状態により処理を変えるというパターンも存在しています。こちらも、ViewController自体の処理の肥大化と、後からその中の1つの画面だけ処理を変更したい場合のメンテナンス性を著しく下げるアンチパターンといえます。

Swiftではこれらの古いアーキテクチャをすべて破棄し、共通処理を分類して機能単位にまとめ、Protocol Extensionを使用して記述することをおすすめします。

共通機能のサンプルとして、ViewControllerと同名のStoryboardのInitial ViewControllerをインスタンス化するメソッドを実装したProtocol Extensionを紹介します。

extension NSObject {
    static var className: String {
        return NSStringFromClass(self).components(separatedBy: ".").last!
    }
}

protocol Instantiatable {}

extension Instantiatable where Self: UIViewController {
    static func instantiateFromStoryboard() -> Self {
        return UIStoryboard(name: className, bundle: Bundle.main).instantiateInitialViewController() as! Self
    }
}

このようにInstantiatable Protocolを定義して、そのExtension内に実際の処理を記載しています。このProtocol ExtensionはProtocolに準拠しているのがUIViewControllerの時のみ適用されます。個別のViewControllerをこのProtocolに準拠させれば上記メソッドが利用可能になります。

ViewControllerがStoryboard上initialのViewControllerではなく、上記メソッドが使えない場合、このProtocolに準拠させなければ誤ってメソッドが呼び出されてアプリがクラッシュすることはありません。このように、必要な共通機能をProtocolとして切り出し、個別のViewControllerは必要な機能のみProtocolに準拠させることで、必要最低限の機能のみが含まれた見通しの良い実装になります。

Objective-CからSwiftを呼び出す場合の注意点

4つ目のポイントはSwiftとObjective-Cの相互変換を前提とした実装だと、Swiftの実装が制限されてしまうことです。SwiftからObjective-Cのコードを参照する場合、Bridging-HeaderのファイルにObjective-Cのヘッダファイルをimportすると、問題なく参照が可能です。逆に、Objective-CからSwiftを呼び出す場合は、各Objective-Cのファイル内で<Project名>-swift.hというファイルをimportします。

これはBuild SettingのSwift CompilerのセクションにあるObjective-C Generated Interface Header Nameという項目で指定されています。このファイルはビルド時にXcodeが自動的に作成するヘッダファイルのため、Objective-Cのコード記述中にSwiftに関わる部分でXcodeの補完機能などがうまく動作しないことがあります。

その場合は一度プロジェクトをビルドします。このファイルには@objcを付与したクラス, メソッド, プロパティなどがObjective-C用に書き出されるため、これをimportしたObjective-Cのファイルから参照することが可能です。

一方、Objective-CからSwiftを呼び出す場合、SwiftにはObjective-Cにない機能が多数ありますので、参照できる機能に制約があります。代表的なところとしては、structやInt型以外のenumのようなSwiftで積極的に使っていきたい機能が参照できません。そのため、ポイント2で解説したように、Objective-CとSwiftの呼び出しにはWapparを挟み、機能的な制限で冗長に書かざるを得ない部分をWapparで吸収すると良いでしょう。

Swiftらしいコードを書くために

Swiftらしいコードを書こうとすると、iOS SDK標準の実装方法ではうまくいかないケースがあります。UIKitはObjective-Cベースで作成されているため、Swiftから実装するとミスマッチなことがあります。

特に顕著なのが画面遷移です。iOS標準の画面遷移にはUIStoryboardSegueがありますが、これはあまり評判が良くなく、Storyboardは使っていてもSegueは使っていないというケースを多く耳にします。実際、JP版メルカリでもSegueは採用していません。

Segueの一般的な使い方を見てみましょう。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "showDetail" {
            if let indexPath = tableView.indexPathForSelectedRow {
                let object = objects[indexPath.row] as! NSDate
                let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController
                controller.detailItem = object
                controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
                controller.navigationItem.leftItemsSupplementBackButton = true
            }
        }
    }

これはiOSの新規ProjectでMastar-Detail Appを選択した際の画面遷移部分の初期コードですが、プロダクトのコードとしては下記のような多数の問題を含んでいます。

  • identifierを文字列リテラルと比較している
  • ForceDownCastを多用しており、実装を誤るとアプリがクラッシュする実装方法になっている
  • MasterViewControllerが遷移先のDetailViewControllerを直接参照し、DetailViewControllerの詳細な実装に依存している
  • DetailViewControllerの入力がこれで必要十分なのか分かりにくい

筆者個人としてはSegue自体の考え方はよくできていて、画面遷移においてSegueを仲介することで画面の呼び出し元と呼び出し先の依存関係を分割できるはずだと考えています。

しかし、Segueの標準実装ではそれが実現できません、これをGenericsとProtocol Extensionを利用して改善していきます。

まず、ViewControllerに入力値を挿入可能にするProtocolを定義します。

protocol Injectable {
    associatedtype Input
    func input(_ input: Input)
}

Inputの型が入力データになります。これをSegueを通じて渡せるようにするProtocol Extensionを実装します。

protocol SegueInjectable: Injectable {}

extension SegueInjectable {
    static func input(segue: UIStoryboardSegue, input: Input) -> Self? {
        guard let destination = segue.destination as? Self else { return nil }
        destination.input(input)
        return destination
    }
}

SegueInjectableに準拠したViewControllerでは、上記staticメソッドからsegueを通じてInputデータを渡すことができます。ただ、まだこの状態では遷移元から遷移先のViewControllerの型を参照してstaticメソッドを呼び出すことになります。そこで、UIStoryboardSegueを拡張します。

extension UIStoryboardSegue {
    @discardableResult
    func input(_ input: DetailViewController.Input) -> DetailViewController? {
        return DetailViewController.input(segue: self, input: input)
    }
}

これでSegueを通じてDetailViewControllerに入力値を渡せます。続いてDetailViewControllerをSegueInjectableに準拠させます。

class DetailViewController: UIViewController, SegueInjectable {
    struct Input {
        var text: String = ""
    }
    var input = Input()

    func input(_ input: Input) {
        self.input = input
    }
   ...

これでinputにデータを受け取る準備はできました。viewDidLoadメソッドなどでinputのデータを画面に反映させるロジックを書きます。 そして、画面遷移のコードは下記のように書き換えることができます。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "showDetail" {
            if let indexPath = tableView.indexPathForSelectedRow {
                let object = objects[indexPath.row] as! NSDate
                segue.input(DetailViewController.Input(text: object.description))
            }
        }
    }

なお、こちらのコードは話を単純にするために、UINavigationContorllerに関する部分は除いています。

さて、まだidentifierを文字列リテラルと比較している問題が残っています。文字列リテラルとの比較は文字列のタイプミスがコンパイラでチェックできず、補完機能のサポートもありません。また、identifierはStoryboard上で定義するため、こちらでもタイプミスの危険性があります。

そもそも上記のコードでidentifierを判定するif文は必要なのでしょうか。 segue.destinationがDetailViewControllerでなければsegue.inputは何もしないので、このif文はなくても動作はします。しかし、その場合他のSegueのアクションがあった場合でもobjectを取り出す処理が実行され、ムダな処理が実行されることになります。

ということで、この部分の処理を他のSegueの場合には実行させないようにしていきましょう。まず、SegueInjectableに下記のメソッドを追加します。

extension SegueInjectable {
    ...
    static func input(segue: UIStoryboardSegue, input: () -> Input?) -> Self? {
        guard let destination = segue.topDestination as? Self,
            let input = input() else { return nil }
        destination.input(input)
        return destination
    }
}

入力値であるInputの値そのものではなく、Inputを返すClosureのメソッドを追加しました。UIStoryboardSegueのExtensionを以下のようにClosureのメソッドを使用するように変更します。

extension UIStoryboardSegue {
    @discardableResult
    func input(_ input: () -> DetailViewController.Input?) -> DetailViewController? {
        return DetailViewController.input(segue: self, input: input)
    }
}

すると、画面遷移のコードを以下のように変更できます。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        segue.input { () -> DetailViewController.Input? in
            guard let indexPath = tableView.indexPathForSelectedRow,
                let object = objects[indexPath.row] as? NSDate else { return nil }
            return .init(text: object.description)
        }
    }

SegueがDetailViewControllerへの遷移でない場合は、segue.destinationがDetailViewContollerへの型変換に失敗し、segue.inputメソッドの引数であるClosureは実行されません。また、このClosureがnilを返した場合は、DetailViewControllerへの値の引き渡しが行われません。

上記のコードは

  • Segueの条件判定が型によって行われているため、タイプミスの危険性がなく、IDEの補完機能の助けを得られる
  • ForceDownCastを使用していないので、実行時のクラッシュが起きない
  • 入力値がInputにまとめられているため、入力値の全体が一目でわかる
  • DetailViewControllerを参照していない(参照しているのはDetailViewController.Input)ため、DetailViewControllerの実装から切り離されている

といった改善ができていることがわかります。

このように、Protocol Extensionで容易にUIKitの既存クラスを拡張し、Swiftらしいコードが書けるよう、フレームワークを構築することができます。

まとめ

Objective-CとSwiftでは言語思想が大きく違うため、アプリを移行するためには基本的なアーキテクチャを大きく変更しなくてはなりません。

近年iOSプログラミングのアーキテクチャは単純なMVCモデルを使うことが減ってきています。今回は言及していませんが、MVPやMVVM、VIPER、Flux、Redux, Clean Architectureなど多くのアーキテクチャが提唱され、さまざまなアプリで導入されています。また、RxSwiftやReactiveSwiftを導入したReactive Programingも多くのアプリで導入されています。

JP版メルカリでは現状はMVVMとReactiveSwiftを採用しています。さらに、MicroViewControllerを提案し、JP版メルカリの極めて複雑な画面をできる限りシンプルに保つように取り組んでいます。

実際にSwiftへの移行を取り組む前にこれらのアーキテクチャやプログラミングスタイルについて調べ、自分の環境とアプリケーションに合ったものを採用することが、移行を成功させ、長期的なメンテナンスを継続する秘訣だと思います。本記事がそのきっかけになれば幸いです。

小林晋士(こばやし・しんじ) 1 @gentlejkov

2
SIerに8年間勤務後、メドピア株式会社でWebエンジニアとして勤務。その後、TOPLOG株式会社にてiOS兼Webエンジニアとして従事したのち、2018年2月から株式会社メルカリにてiOSエンジニアとして勤務。現在はJP版メルカリの出品系のサービス改善に携わっている。
若手ハイキャリアのスカウト転職