大規模なマルチモジュール開発をSwiftPackageに移行して運用してみた

はじめまして、iOSエンジニアの阿久津 @sky_83325 です。

タイミーでは、機能ごとにEmbedded Frameworkに分割して開発するマルチモジュール開発に取り組んでいます。 現在では、本体AppやAppExtensionの他に7つの共通Framework、そして16個の機能Frameworkという規模になってきました。

今回は、そのマルチモジュール開発をEmbedded Frameworkではなく、Swift Packageを利用した方法に乗り換えてみたので、その成果や学びについて共有できればと思います。

取り組んだ経緯・背景

タイミーでは、技術顧問の@d_dateさんと隔週で「ツバメの会」という情報共有の場を設けています。そこでは、直近タイミーで取り組んでいることの共有や相談をしたり、SwiftやiOS、その他エンジニアリングの最近の話題について議論したりしています。

そのツバメの会で、pointfreeがOSSとして提供しているisowordsというアプリが取り上げられました。

www.notion.so

そのisowordsは86ものモジュールから構成されていますが、そのモジュールをSwift Packageとして管理しています。

Swift Packageとして管理することで、ファイルの追加や変更のたびに project.pbxproj が変更される辛みからも解放され、結果としてXcodeGenのようなツールを利用してプロジェクトファイルを生成しなくても、十分に管理することが可能となります。

この構成に大きな魅力と可能性を感じたため、タイミーでもSwift Packageを利用したプロジェクト構成への変更に舵を取りました。

この記事では、次の内容を共有します。

  • Swift Packageに移行したことで得られた成果
  • どのように移行していったのか
  • 実際に運用してみた感想

Swift Packageに移行したことで得られた成果

1. XcodeGenを取り除くことができた

タイミーでは次の2つの目的のために、XcodeGenを導入していました。

  1. project.pbxproj のコンフリクトを防ぐ
  2. フレームワーク追加の処理をテンプレート化する

特に前者は、多くのプロジェクトでXcodeGenが導入されている理由ではないでしょうか。

複数人で開発を行なっていると、ファイルやディレクトリの追加に伴い、プロジェクトファイルがコンフリクトしがちです。常にプロジェクトファイルを生成するようにし、プロジェクトファイルを.gitignoreに追加することでコンフリクトの発生を抑えることができます。そういった目的でタイミーではXcodeGenを利用していました。

また、Frameworkターゲットを追加するたびに、毎回同じ作業が必要でしたが、その作業を誰でも同じように行えるようにテンプレート化するためにXcodeGenを利用していました。

XcodeGenの利用に問題があったわけではありません。従来のプロジェクト構成においては、XcodeGenを利用してプロジェクトファイルは常に生成する運用で特別な問題は発生していませんでした。

しかし、Swift Package Manager(以下SwiftPM)を利用し、ソースコードをSwift Packageとして管理することで、Xcode Projectにはファイルやフォルダへの参照が発生しません。これにより、Build Settingsの変更やTargetへの依存の追加を除いては、 .pbxproj に変更差分は発生しません。

そして、新しくモジュールを追加する際の処理も、 Package.swift を少し編集するだけとなったので、テンプレートなども不要になりました。

こうして、XcodeGenを取り除くこととなりました。

project.pbxproj は16416行から2295行へなり、XcodeGenのymlも削除されました。

// Before
I'm working on timee-ios $ wc -l timee-ios.xcodeproj/project.pbxproj
   16416 timee-ios.xcodeproj/project.pbxproj

// After
I'm working on timee-ios $ wc -l App/timee-ios.xcodeproj/project.pbxproj 
    2295 App/taimee-ios.xcodeproj/project.pbxproj
// Before
I'm working on timee-ios $ wc -l XcodeGen/*
     102 XcodeGen/common-targets.yml
     189 XcodeGen/project.yml
      47 XcodeGen/sandbox-targets.yml
     179 XcodeGen/scene-targets.yml
      58 XcodeGen/setting-groups.yml
      73 XcodeGen/templates.yml
     178 XcodeGen/unit-test-targets.yml
     826 total

// After
I'm working on timee-ios $ wc -l Package.swift 
     272 Package.swift

2. 本体Appのビルド時間が90秒から10秒になった

Swift Packageに移行した結果、ビルド時間が大幅に改善されました。

これはSwift Package化したことによるものなのか、従来のプロジェクト構成に無駄があったのかはわかりませんが、思わぬ恩恵を受けることになりました。

ビルド時間のBefore/After
ビルド時間のBefore/After

追記(2021年9月1日) SwiftPM対応した際に、BuildScriptで実行していたSwiftLintが動かなくなっていました。 そのSwiftLintの実行時間が40秒ほどかかっていたため、それが外れたことによりビルド時間が大幅に削減されていました。

3. アプリのサイズが30.3MBから26.9MBになった

こちらも当初は期待してなかった成果でした。

アプリサイズのBefore/After
アプリサイズのBefore/After

4. アプリの起動時間

Embedded FrameworkはDynamic Linkされていたので、今回Static Frameworkとしてリンクされることで起動時間が早くなるかもと思っていました。

今回実測はしていませんが、肌感では初回起動含め特に変化は感じていません。

5. その他

プロジェクトのセットアップがかなり容易になりました。まだCocoaPodsを併用していたり、Swift製のコマンドラインツールの管理にMintを利用しているため、Git Clone後にXcodeを立ち上げそのまま開発を始められるという状況にはなっていません。

ですが、都度XcodeGenのscriptを走らせる必要がなくなったのは大きな開発体験の向上だと感じています。引き続き、MintのMintのSwiftPM移行などを進めていき、Xcodeのみで開発できる状況を目指していければと思います。

どのように移行していったのか

それでは、どのようにプロジェクト構成を移行していったのかを見ていきます。

既存のタイミーの構成は次の通りでした。

  • 外部ライブラリの管理は基本的にSwiftPMを使用して行っているが、一部CocoaPodsを利用している
  • 1つのプロジェクトで、Appや複数のEmbedded Frameworkを管理している
  • xcworkspaceを用いて、Podsとメインprojectを紐づけている。

移行後のゴールとしてはisowordsを参考に以下のように定めました。

  • XcodeGenを取り除く
  • ローカルライブラリ及びリモートライブラリはSwift Packageとして管理する(メインprojectではSwift Packageのビルド成果物であるFrameworkをリンクして利用する)
  • 一部SwiftPM未対応のものはCocoaPodsを利用する
  • xcworkspaceでメインproject、Pods、Swift Packageを編集可能にする

このゴールを達成するために、次の手順で移行を行いました。

  1. XcodeGenを取り除く
  2. xcworkspaceの構成を整える
  3. Embedded FrameworkをSwift Packageに切り出す
  4. CocoaPodsを再度導入する

1. XcodeGenを取り除く

まず、XcodeGenを取り除くということを大きな目的の1つにしていたので、この作業から始めました。具体的にはgitignoreの整理と、XcodeGenに関する記述の削除のみです。 .pbxproj はXcodeGenを利用し都度生成していたため、Gitでは管理していませんでしたが、管理するように変更しまし、差分を確認できるようにしました。

2. xcworkspaceの構成を整える

従来の構成は、次の画像のようになっていましたがisowordsによせるため、Rootにおいていた各種ファイルを /App 以下に配置しました。

f:id:takeshi__akutsu:20210819201308p:plain

それにより、xcworkspaceからxcodeprojに対する参照がなくなってしまうため、 ***.xcworkspace/contents.xcworkspacedata でpathを更新します。 ワークスペースのインスペクタに何を表示するかは、この contents.xcworkspacedata によって管理されています。

<?xml version="1.0" encoding="UTF-8"?>
<Workspace
   version = "1.0">
   <FileRef
      location = "group:App/taimee-ios.xcodeproj"> // ここのpathを更新する
   </FileRef>
   <FileRef
      location = "group:Pods/Pods.xcodeproj">
   </FileRef>
</Workspace>

ここまでの変更は、SwiftPMなどは一切関係なく単にディレクトリ構造を変更しただけです。 ここで、一度ビルドが通ることを確認し、前準備を完了とします。

その後、プロジェクトのRootで swift package init を実行し、Swift Package導入の準備を進めます。

I'm working on timee-ios $ swift package init
Creating library package: timee-ios
Creating Package.swift
Creating Sources/
Creating Sources/timee-ios/timee-ios.swift
Creating Tests/
Creating Tests/timee-iosTests/
Creating Tests/timee-iosTests/timee-iosTests.swift

同様に、こちらもworkspaceで管理したいため、 contents.xcworkspacedata を編集していきます。 今回は、rootディレクトリの内容を全て表示したいため、 group: を指定します。

<FileRef
   location = "group:">
</FileRef>

ここまでで、Swift Packageを導入する準備が整いました。 この時点での構成は次の通りです。

f:id:takeshi__akutsu:20210819195412p:plain

補足

  • group: を指定しただけでは App/ の内容もSwift Package側に表示されてしまいます。非表示にするには、非表示にしたいディレクトリの中に空の Package.swift を作成することで防げます 参考
  • App/ 以下に本体projectを移行したことで、各種ツールのPathが壊れるかもしれないので適宜修正します。
  • 以降の作業では一時的にCocoaPodsで導入しているライブラリの参照を外して作業を進めています。CocoaPodsがプロジェクトにリンクされている状態で移行を進めると、ある不具合が発生した場合にSwiftPM依存の不具合なのかCocoaPodsによる不具合なのか判断しにくいと考えました。

3. Embedded FrameworkをSwift Packageに切り出す

ここまで来れば、あと一息です。 Swift Packageに対応する準備は整ったので、Embedded FrameworkをひとつひとつSwift Packageに移行していきます。

具体的には以下の作業を順に繰り返していくことになります。

  1. Sources/ Tests/ にFrameworkのコードを移行する
  2. Package.swift に移行したFrameworkを定義する
    • Package.targets にtargetとtestTargetを追加する
    • 外部ライブラリが必要であれば Package.dependencies を定義し、 Package.targetdependencyに追加する
    • Package.products.library を定義する。これによりアプリターゲットにFrameworkをリンクすることが可能となる。
  3. Xcode ProjectのTargetから不要なものを取り除く
  4. Xcode ProjectのTargetに2で定義したFrameworkをリンクする

例として、共通コードをまとめたFrameworkである Common の場合を次に示します。 各種dependencyを指定しtargetを作成したあとに、productsでlibraryを提供することで、本体projectで Common というモジュールとFrameworkを使えるようになります。

// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "Frameworks",
    platforms: [
      .iOS(.v13)
    ],
    products: [
        .library(name: "Common", targets: ["Common"])
    ],
    dependencies: [
        .package(name: "Lottie", url: "https://github.com/airbnb/lottie-ios", from: "3.0.0"),
        .package(name: "RxSwift", url: "https://github.com/ReactiveX/RxSwift.git", from: "6.0.0"),
        .package(name: "Kingfisher", url: "https://github.com/onevcat/Kingfisher", from: "6.0.0"),
        .package(name: "ImageViewer", url: "https://github.com/Taimee/ImageViewer", from: "0.2.0"),
    ],
    targets: [
        .target(
            name: "Common",
            dependencies: [
                "Umbrella"
            ]),
        .target(
            name: "Umbrella",
            dependencies: [
                .product(name: "ImageViewer", package: "ImageViewer"),
                .product(name: "Lottie", package: "Lottie"),
                .product(name: "Kingfisher", package: "Kingfisher"),
                .product(name: "RxSwift", package: "RxSwift"),
                .product(name: "RxRelay", package: "RxSwift"),
                .product(name: "RxCocoa", package: "RxSwift")
            ]),
        .testTarget(
            name: "CommonTests",
            dependencies: [
                "Common"
            ])
        .testTarget(
            name: "UmbrellaTests",
            dependencies: ["Umbrella"]),
        
    ]
)

同様に、全てのFrameworkを Package.swift を編集しながら移行していくことになります。 単調な作業が進みますが、 .pbxproj がどんどん軽量になっていくことを実感できます。

補足

  • import UIKitimport Foundation などの記述が漏れているとビルドに失敗するので適宜修正しましょう
  • BundleはPackageごとに作成されるので Bundle.module を利用しましょう

4. CocoaPodsを再度導入する

最後にCocoaPodsを再導入していきます。既にxcworkspaceは作成済みであるため、既存のxcworkspaceを使うようにPodfileで指定します。それ以外は、特に気をつけることはありません。

workspace 'timee-ios'
project './App/timee-ios.xcodeproj'

これで無事以降が完了しました🎉

実際に運用してみた感想

実際にSwiftPMを中心としたプロジェクト構成にして、1ヶ月弱運用してみてメリット/デメリットが分かってきました。 メリットに関しては、この記事の前半で述べたように、基本的にXcodeを開くだけで開発を始められる体制になったということです。ビルド時間の高速化もかなり大きなメリットでした。

他方、良い面ばかりではなく、比較的新しい技術であるため、いくつかの不具合なども観測されました。具体的には以下の通りです。

不具合だと思われるもの

  1. Swift Packageにファイルを新規追加するのが少し辛い

    CocoaTouchClass を新規追加する際、 UIViewController などを指定することが出来ません。通常は Next を押した後の画面で、親Classなどを指定できると思うのですが、 Next を押すとすぐにObjective-C用のファイルが作成されます😇 一時的な不具合だと思うので、次のXcodeに期待です。

  2. AppExtensionを利用している場合、binaryTargetを利用することが出来ない

    AppExtensionを利用しているアプリで、SwiftPMを利用してbinaryFrameworkを利用する場合にAppStoreConnectにアップロード出来ない不具合があります。AppとAppExtensionの両方に対してFrameworkがコピーされてしまうため、AppStoreConnectへアップロードする際に、Bundleの重複でTransporterErrorが出ます。手動で片方を削除するなど対応可能かもしれませんが、CIなどでリリースプロセスを組んでることが多いと思うので、現時点では利用不可と考えるのが無難かもしれません。タイミーでも、これらはCocoaPodsで導入することで対応しました。 forums.swift.org

  3. InterfaceBuilderからソースコードにIBOutlet/IBActionを接続しようとするとクラッシュする

    これは少し開発体験を損ねています😢ソースコード側からIB側に接続することは可能なので、そのようにして対応しています。次のXcodeで修正されると期待します。

これらの不具合はSwiftUIを使えば解決出来ることでもあるので、早めに乗り換えていきたい気持ちが高まりました。

その他

  1. Swift Package内でCustomBuildConfigurationを利用することが出来ない

    Swift Packageでは、標準の RELEASE DEBUG 以外は利用できません。そのため、なんらかの対応が必要です。環境ごとにプロジェクトを分けてしまうのが良いかもしれません。 forums.swift.org

最後に

Swift Package中心のプロジェクトに移行しましたが、まだまだやりたいことが沢山あります。 タイミーでは、技術的挑戦を一緒にしていけるメンバーを常に募集していますので、少しでも興味がある方はぜひご連絡ください。

その他参考文献