コンテンツへスキップ

2,000個のimport:アプリのSwiftPMモジュールを整理する

隠れた(非公式の)Swift機能を使って、アプリのSwiftモジュールを明確かつ便利に整理する方法。中小規模のアプリ向けの実践的なソリューションです。

2,000個のimport:アプリのSwiftPMモジュールを整理する

問題

最近、RemafoXの過去最大の機能に取り組むことを決めたのですが、どこから始めるか考えているうちに、まだ1年も経っていないプロジェクトに70以上のターゲットがあることに溺れそうになりました。Point-Freeがこの無料エピソードで紹介したバニラのSwiftPMベースの方法を使って、コードの明確な分離とビルド時間の高速化(= SwiftUIプレビュー、テストの高速化など)のためにアプリをモジュール化しています。このアプリには何年も取り組む予定なので(オンラインにある27の機能リクエストは氷山の一角で、まだまだアイデアがあります)、まずこの混乱を整理することにしました。機能の間にリファクタリングを挟むことで、コードベースがきれいに保たれ、開発者も幸せになりますからね!😇

Swift Evolutionのスレッドを読んでいるときに@_exported属性を発見したことを思い出しました。これは関連のニュースレターの号を準備しているときでした。アンダースコア付きのAPIは動作が変わったり完全に削除される可能性があるため使用は推奨されていませんが、多くの未整理なターゲットを整理するという目標に対して他に代替手段がありませんでした。まさにこの理由から、この属性が完全に削除される可能性は比較的低いと考えました。むしろ、関連のpitchがいつか採用されて公式のSwiftに入り、@_exportedをその時の正式な名前で置き換えられるようになると信じています。また、Point-FreeもThe Composable Architectureフレームワークでこの属性に依存していることを知り、私のアプリもそれに大きく依存しているので、全面的に使ってみることにしました。

簡単に言うと、この属性が助けてくれるのはこういうことです。10個のフィーチャーモジュールと5個のヘルパーモジュールがあるとします。10個のフィーチャーの各ファイルで、5個のヘルパーモジュールのすべて(またはほとんど)をインポートする傾向があり、こうなります。

import Assets
import Analytics
import ComposableArchitecture
import Constants
import Defaults
import HandySwift
import HelpfulErrorUI
import ReusableUI
import SFSafeSymbols
import SwiftUI
import Utility

これが何度も繰り返されます。確かに、ターゲットのすべてのファイルでこれらすべてが必要なわけではありませんが、実際にはXcodeはモジュール内の1つのファイルだけがインポートしていてもモジュール全体をリンクするので、すべてのファイルですべてをインポートしてもビルド時間には影響しません(私の理解では)。@_exported importを使えば、新しいターゲットを作成し、例えばCoreDependenciesと名付けて、その中にSwiftファイルを作成してこの内容を書くことで、すべてのインポートをまとめられます。

@_exported import Assets
@_exported import Analytics
@_exported import ComposableArchitecture
@_exported import Constants
@_exported import Defaults
@_exported import HandySwift
@_exported import HelpfulErrorUI
@_exported import ReusableUI
@_exported import SFSafeSymbols
@_exported import SwiftUI
@_exported import Utility

これで、import CoreDependenciesするだけで、他のすべてのモジュールもインポートされます!

しかし、すべてをCoreDependenciesという1つのグループにまとめるのが本当に正しい解決策でしょうか?インポートを何度も繰り返さなければならないという問題に加えて、他の種類のグループ分けや構造がないため、現在70以上のモジュールをアルファベット順に並べているという問題もあります。グループ分けの欠如は、大まかに何を探しているかは分かっているがモジュールの正確な名前を覚えていないときに適切なモジュールを見つけることを難しくするだけではありません。機能を開発中にできるだけコードを再利用しようとすると、循環依存につながる可能性もあります。コードを再利用しつつ循環依存(コンパイラエラーの原因)を防ぐには、何をどこに配置するかの戦略的な計画が必要です。

解決策

✨ UPDATE:新しい/小さいアプリでは、以下で詳しく説明する方法を簡略化したソリューションを使っています。詳しくはFlineDevKitリポジトリをご覧ください。

問題に対する実践的な解決策を見つける最善の方法は、実際の例を見ることです。以下は、RemafoXで実際に使用しているモジュールの一部です。

Analytics
Assets
BetterCodable
CommandLineSetup
ComposableArchitecture
Constants
FilesSearch
Foundation
HandySwift
HelpfulErrorUI
MachineTranslation
Paywall
ProjectsBrowser
ReusableUI
SFSafeSymbols
Settings
SwiftUI
Utility

はい、上のリストにはFoundationSwiftUIも含めています。なぜでしょうか?結局のところ、これらも他の依存関係と同様にインポートが必要な依存関係だからです。外部でも内部でも同じです。私はこれらを「ビルトインの外部依存関係」と見なしています。少なくともimport FoundationはどのSwiftファイルでも書くことに慣れているかもしれませんが、実際にはFoundationなしでもSwiftコードは書けます。その場合、Swift Standard Libraryに含まれる素のSwift機能だけが使えます。ちゃんと動きます!

実際、私たちが頻繁に行うこの2つのインポートは、Apple内部での機能/ヘルパーのグループ分けを表しています。Appleは多くの機能をFoundationの背後にまとめ、SwiftUIUIKit/AppKitでも同様のことをしています。決定要因は、UIを表すものやUIに直接関連するものは1つのグループに、UIを表さないものやUIに直接関連しないものは別のグループに、という分け方のようです。最も自然な方法は彼らに倣うことで、Non-UIグループにはFoundation、UIグループにはUISwiftUIUIKitの両方に含まれる)という命名をそのまま使うこともできます。私たちのグループはアプリのドメインに固有なので、結果としてAppFoundationAppUIという名前になります。

上のモジュールリストに適用してみましょう。

// AppFoundation
Analytics
BetterCodable
CommandLineSetup
Constants
FilesSearch
Foundation
HandySwift
MachineTranslation
Utility

// AppUI
Assets
ComposableArchitecture
HelpfulErrorUI
Paywall
ProjectsBrowser
SFSafeSymbols
ReusableUI
Settings
SwiftUI

だいぶ良くなりましたね。しかし、Appleがフレームワークを構造化する方法からもう一つ学べることがあります。AppleはすべてのNon-UI機能をFoundationの一部としてリンクしているわけではなく、SwiftUI関連のコードをすべてSwiftUIの一部として出荷しているわけでもありません。CombineChartsは別途インポートが必要なフレームワークです。なぜFoundationSwiftUIの一部にしないのでしょうか?特定のドメインでのみ有用で、よりグローバルなスコープでは必要ない場合があるからです。

最初の問題を思い出してください。特定のドメインでのみ有用なのではなく、グローバルに有用なヘルパーだったために、多くの場所で何度もインポートしていたモジュールのセットがありました。それらを統一されたグループ名でインポートするのは理にかなっています。しかし、実際に「ヘルパー」とは何でしょうか?ドメイン固有の機能とどう区別されるのでしょうか?

私は個人的に、グローバルに利用可能であることが開発プロセスを妨げるよりもはるかに有用な場合に、機能を「ヘルパー」や「ユーティリティ」と呼んでいます。もちろんこれはやや主観的ですが、経験則として:すでにアプリの複数の異なる部分でその機能を使用しており、かつ将来追加する可能性のある2~3の新機能を考えたときに少なくとも1つがそれを活用できそうなら、おそらくグローバルに非常に有用です。

より具体的には、上のモジュールリストを以下のように分けます。

// (グローバルに有用な)ヘルパー
Analytics
Assets
BetterCodable
ComposableArchitecture
Constants
Foundation
HandySwift
HelpfulErrorUI
ReusableUI
SFSafeSymbols
SwiftUI
Utility

// (ドメイン固有の)フィーチャー
CommandLineSetup
FilesSearch
MachineTranslation
Paywall
ProjectsBrowser
Settings

この2つの分離軸を組み合わせると、4つの象限と相互の依存関係を持つ以下のようなグラフになります。

11個のインポートが@_exported属性のおかげでたった2つに削減された。

ここでいくつかの重要なポイントがあります。

  1. 上半分の「フィーチャー」は下半分の「ヘルパー」の上に構築されるため:

  2. 左上の緑の「Non-UIフィーチャー」モジュールはimport AppFoundationできます。

  3. 右上の赤の「UIフィーチャー」モジュールはAppFoundationAppUIの両方をインポートできます。

  4. グループ(象限)内では、モジュール同士が依存し合えます(循環に注意!)。

  5. 「UI」モジュールは「Non-UI」モジュールやAppFoundationに依存できます。

  6. 下半分の「ヘルパー」は上の「フィーチャー」からインポートすることは決して許されません!

  7. 外部モジュールも「フィーチャー」になり得ます(左上を参照、現時点では右上にはありません)。

この構造を適用するため、AppFoundationという新しいモジュールを作成し、その中にAppFoundation.swiftという新しいSwiftファイルを以下の内容で作成しました。

// System
@_exported import Foundation

// Internal
@_exported import Analytics
@_exported import Constants
@_exported import Utility

// External
@_exported import BetterCodable
@_exported import HandySwift

また、AppUIモジュールも作成し、以下の内容のAppUI.swiftを配置しました。

// System
@_exported import SwiftUI

// Internal
@_exported import Assets
@_exported import HelpfulErrorUI
@_exported import ReusableUI

// External
@_exported import ComposableArchitecture
@_exported import SFSafeSymbols

これで、この記事の冒頭でUIフィーチャーモジュール内のファイルから取った11個のインポートを、たった2行で置き換えることができます。

import AppFoundation
import AppUI

11個のインポートが@_exported属性のおかげでたった2つに削減されました。

FoundationSwiftUIをインポートする必要すらなかったことに注目してください。そしてNon-UIフィーチャーではimport AppFoundationの1行だけで済みます!

もちろん、これで他に何もインポートしなくなるわけではありません。縦軸ではまだインポートが発生します。グループ内で特定のモジュールが別の特定のモジュールをインポートする場合です。例えば、ConfigFileのUIフィーチャーがConfigFileLinterConfigFileNormalizerなどの子コンポーネントをインポートするケースです。しかし、これらはドメイン固有のインポートであり、多くの繰り返しインポートにはつながりません。

最後に行ったのは、Package.swiftファイル内のproducts、dependencies、targetsをこの4つの象限でグループ分けすることでした。// MARK: - Non-UI Featuresのようなプラグママークを全セクションに追加し、関連する記述をアルファベット順に配置しました。結果として、マニフェストはこのようになりました。

import PackageDescription

let package = Package(
   name: "RemafoX",
   platforms: [.macOS(.v12)],

   // MARK: - Products
   products: [
      // MARK: - Grouping Products
      .library(name: "AppFoundation", targets: ["AppFoundation"]),
      .library(name: "AppUI", targets: ["AppUI"]),
      .library(name: "AppTest", targets: ["AppTest"]),

      // MARK: - Non-UI Helper Products (AppFoundation)
      .library(name: "Analytics", targets: ["Analytics"]),
      .library(name: "Constants", targets: ["Constants"]),
      .library(name: "Utility", targets: ["Utility"]),

      // MARK: - UI Helper Products (AppUI)
      .library(name: "Assets", targets: ["Assets"]),
      .library(name: "HelpfulErrorUI", targets: ["HelpfulErrorUI"]),
      .library(name: "ReusableUI", targets: ["ReusableUI"]),

      // MARK: - Test Helper Products (AppTest)
      .library(name: "TestResources", targets: ["TestResources"]),

      // MARK: - Non-UI Feature Products
      .library(name: "CommandLineSetup", targets: ["CommandLineSetup"]),
      .library(name: "FilesSearch", targets: ["FilesSearch"]),
      .library(name: "MachineTranslation", targets: ["MachineTranslation"]),

      // MARK: - UI Feature Products
      .library(name: "Paywall", targets: ["Paywall"]),
      .library(name: "ProjectsBrowser", targets: ["ProjectsBrowser"]),
      .library(name: "Settings", targets: ["Settings"]),
   ],

   // MARK: - Dependencies
   dependencies: [
      // MARK: - Non-UI Helper Dependencies (AppFoundation)
      .package(url: "https://github.com/marksands/BetterCodable.git", from: "0.4.0"),
      .package(url: "https://github.com/sindresorhus/Defaults", from: "6.3.0"),
      .package(url: "https://github.com/FlineDev/HandySwift", branch: "main"),

      // MARK: - UI Helper Dependencies (AppUI)
      .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "3.3.0"),
      .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.40.2"),

      // MARK: - Test Helper Dependencies (AppTest)
      .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.3.0"),

      // MARK: - Non-UI Feature Dependencies
      .package(url: "https://github.com/FlineDev/Microya", branch: "main"),
      .package(url: "https://github.com/JohnSundell/Splash.git", from: "0.16.0"),
      .package(url: "https://github.com/jakeheis/SwiftCLI.git", from: "6.0.3"),
      .package(url: "https://github.com/TelemetryDeck/SwiftClient", branch: "main"),

      // MARK: - UI Feature Dependencies
   ],

   // MARK: - Targets
   targets: [
      // MARK: - Grouping Targets
      .target(
         name: "AppFoundation",
         dependencies: [
            // Internal
            "Analytics",
            "Constants",
            "Utility",

            // External
            .product(name: "BetterCodable", package: "BetterCodable"),
            .product(name: "HandySwift", package: "HandySwift"),
         ]
      ),
      .target(
         name: "AppUI",
         dependencies: [
            // Internal
            "Assets",
            "HelpfulErrorUI",
            "ReusableUI",

            // External
            .product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
            .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
         ]
      ),
      .target(
         name: "AppTest",
         dependencies: [
            // Internal
            "TestResources",

            // External
            .product(name: "CustomDump", package: "swift-custom-dump"),
         ]
      ),

      // MARK: - Non-UI Helper Targets (AppFoundation)
      .target(
         name: "Analytics",
         dependencies: [
            // Internal
            "Constants",

            // External
            .product(name: "HandySwift", package: "HandySwift"),
            .product(name: "TelemetryClient", package: "SwiftClient"),
         ]
      ),
      .testTarget(name: "AnalyticsTests", dependencies: ["AppTest", "Analytics"]),
      .target(
         name: "Constants",
         dependencies: [
            .product(name: "Defaults", package: "Defaults"),
            .product(name: "HandySwift", package: "HandySwift"),
         ]
      ),
      .target(
         name: "Utility",
         dependencies: [
            // Internal
            "Analytics",
            "Constants",

            // External
            .product(name: "HandySwift", package: "HandySwift"),
            .product(name: "Defaults", package: "Defaults"),
         ]
      ),
      .testTarget(name: "UtilityTests", dependencies: ["AppTest", "Utility"]),

      // MARK: - UI Helper Targets (AppUI)
      .target(
         name: "Assets",
         dependencies: [.product(name: "Defaults", package: "Defaults")],
         resources: [
            .process("Colors.xcassets"),
            .process("Images.xcassets"),
            .copy("Sounds"),
         ]
      ),
      .target(
         name: "HelpfulErrorUI",
         dependencies: [
            // Internal
            "AppFoundation",
            "Assets",
            "ReusableUI",
         ]
      ),
      .target(
         name: "ReusableUI",
         dependencies: [
            // Internal
            "AppFoundation",
            "Assets",

            // External
            .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
            .product(name: "Splash", package: "Splash"),
            .product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
         ]
      ),

      // MARK: - Test Helper Targets (AppTest)
      .target(
         name: "TestResources",
         dependencies: [],
         path: "TestResources",
         exclude: ["Package.swift"],
         sources: ["TestResources.swift"],
         resources: [
            .copy("CustomSample"),
            .copy("EmptyFileStructureSamples"),
            .copy("GitHubSampleProjects"),
         ]
      ),

      // MARK: - Non-UI Feature Targets
      .target(name: "CommandLineSetup", dependencies: ["AppFoundation"]),
      .target(
         name: "MachineTranslation",
         dependencies: [
            // Internal
            "AppFoundation",

            // External
            .product(name: "Microya", package: "Microya"),
         ]
      ),
      .testTarget(
         name: "MachineTranslationTests",
         dependencies: ["AppFoundation", "AppTest", "MachineTranslation"],
         exclude: ["Resources/secrets.json.sample"],
         resources: [.copy("Resources/secrets.json")]
      ),

      // MARK: - UI Feature Targets
      .target(name: "Paywall", dependencies: ["AppFoundation", "AppUI"]),
      .target(
         name: "ProjectSetup",
         dependencies: [
            "AppFoundation",
            "AppUI",
            "ProjectDragAndDrop",
            "ProjectAnalyzer",
         ]
      ),
      .target(
         name: "Settings",
         dependencies: [
            "AppFoundation",
            "AppUI",
            "SettingsTabCurrentPlan",
            "SettingsTabGeneral",
            "SettingsTabMachineTranslation",
         ]
      ),
      // ... many more features related to Project, Settings etc.
   ]
)

☑️ AppFoundationAppUIと同様に、AppTestグループ化ターゲットも導入しました。XCTestやのCustomDump(強くお勧めします!)などの依存関係/ヘルパーのインポートを統一するために使っています。

すべての関連インポートをAppFoundation/AppUIで置き換えるために、次のトリックを使いました。

  1. まず、XcodeのFind & Replace@_exportedの各ライブラリに対して使い、すべてのインポートをAppFoundationに置き換えました。結果として多くのファイルでAppFoundationの複数インポートが発生しました。

  2. 次に、SwiftLintduplicate_importsルール(自動修正対応)を使いました。brew install swiftlintでインストールし、以下の3行を実行します。

echo "only_rules: [duplicate_imports]" > temp_swiftlint.yml
swiftlint lint --config temp_swiftlint.yml --path Sources --autocorrect
rm temp_swiftlint.yml

–pathに渡すパラメータがSourcesと異なる場合は調整してください。

  1. 最後に、AppFoundation自体の一部であるモジュール内のファイルについて、Gitを使って変更を元に戻しました。AppFoundation.swift自体も同様です。

  2. そしてAppUIについても同じ手順を繰り返しました。全部で10分もかかりませんでした!

以上です!ご覧の通り、整理前はプロジェクトに約2,000個のインポートがありました。

ソリューション

整理後は1,200個のインポートだけになり、以前より約40%減少しました!

ソリューション2

また、Package.swiftマニフェストファイルも大幅に短くなり、827行から575行へと約3分の1削減されました。そして構造がとても整理されて、とても満足しています!😍

まとめ

@_exported importと、(A)「UI関連」か「Non-UI関連」か、(B)「グローバルに有用」か「ドメイン固有」かを問うことでモジュールを4つのグループに分離することで、無限の数の「ヘルパー」モジュールをたった1行か2行のimportで「フィーチャー」モジュールにインポートできるようになりました!それだけでなく、これらのグループとそのインポートルールは、循環依存を防ぐためにコードを適切なモジュールに配置するガイドとしても機能します。

結果:書くコードが減り、ビルドエラーの可能性も減る。まさにWin-Winです!

この記事が参考になりましたか?BlueskyMastodonでフォローして、Swiftのヒントやインディー開発の最新情報をチェックしてください。