🗃️

Swift Package Managerでアプリ内に同梱されたファイルを読み込むには

2023/11/19に公開

最近はプレイングマネージャーのような立ち位置で、4つのプロダクト(iOS/Android)を担当している開発チームのリーディングや雑務、たまにコーディングをしているといった状況です。
特に最近、ガッツリとコーディングできていなかったので、Swift言語を使って何か簡単な便利ツール製作を行い、Swiftを忘れないように、また、新しいこともついでに覚えていこうと思いました。

そのツールがある程度完成しユニットテストを実装している中で、

「あれ、Swift Package Manager(以下、SwiftPM)でのファイルの読み込み方法が分からないぞ!?」

となったので、調べてみました。

前提

  • xcodeprojではなく、SwiftPMを使ったプロジェクト構成のお話です
  • iOS用のアプリではなく、macOS用のCLIツールのプロジェクトとなっています
    • ただし、構成はiOS向けのプロジェクトでも同じではないかなと思います
  • 出てくるサンプルコードは全て「Xcode 15.0」で確認しています

各ターゲット配下にあるファイル読み込み方法が分からず...

ユニットテストを実装するため、Tests配下にサーバーから取得したJSON形式のレスポンスデータをファイルとして保存し、それを読み込んでJSON文字列 → クラスへの変換をテストするコードを書こうと思っていました。
色々と試行錯誤をしていましたが、なかなか読み込む方法が分からず、少し行き詰まっていました。

そこでググっていたら以下の記事を見つけ、無事に解決することができました。

https://zenn.dev/usk2000/articles/62dab200d0de94

今回はそこで得た知見をもとに、ファイル読み込み方法を紹介します。

アプリに同梱されたファイルを読み込む

①Package.swiftにファイルを登録する

リソースファイルをそれぞれのターゲット配下に置いただけでは自動で読み込んでくれません。
xxxx.xcodeproj で管理されているプロジェクトでは、プロジェクト設定内に Copy Resources みたいな項目があり、そこに入っていれば Bundle 経由で読み込むことができました。
SwiftPMでは Package.swift で依存するライブラリなどを管理しているのですが、アプリに予め同梱して読み込みたいファイルについてもこの Package.swift で管理しているようです。

まず必要なファイルを配置したら、 Package.swift にリソースファイルとして登録します。

Package.swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftPMResourceSample",
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .executableTarget(
            name: "SwiftPMResourceSample",
            dependencies: [],
            resources: [
                .copy("Data/hello_world.txt"),  // ← `Sources/SwiftPMResourceSample/Data/hello_world.txt` を読み込む
            ]
        ),
        .testTarget(
            name: "SwiftPMResourceSampleTests",
            dependencies: ["SwiftPMResourceSample"]),
    ]
)

Packagetargets.executableTarget()resources を追加し、その中にコピーしたいファイルを .copy() メソッドを使って登録します。

②プログラムから読み込む

その後、プログラムからファイルを呼び出します。

Sources/SwiftPMResourceSample/Data/hello_world.txt
Hello Swift World!!!
SwiftPMResourceSample.swift
import Foundation

@main
public struct SwiftPMResourceSample {
    public static func main() throws {
        // `Bundle.module.url()` を使ってファイルのURLを取得し、それをString(contentOf:)イニシャライザを使って文字列として取得
        guard let fileUrl = Bundle.module.url(forResource: "Data/hello_world", withExtension: "txt"),
              let data = try? String(contentsOf: fileUrl) else {
            print("Error: file not found")
            exit(1)
        }
        
        print(data)
    }
}
出力
Hello Swift World!!!

Program ended with exit code: 0

注意点として、 .xcodeproj 形式のプロジェクトでは Bundle.main を使っていましたが、SwiftPMでは Bundle.module を使います。
Bundle.modulePackage.swift にてファイルが正しく登録されたら出てくるようですので、一向にこのプロパティが見えてこない場合は Package.swift の内容を確認してみてください。

リソースファイルが読み込めない場合

ファイルパスの誤字などにより正しく登録できていない場合、XcodeのIssue Navigatorに以下のようなWarningが表示されます。

え、賢いすぎる...

resources にリソースファイルを登録したら、Issue NavigatorにWarningが出ていないかぜひ確認してみて下さい。

なお、このファイル読み込みについてはターゲットが .testTarget() の場合も同様で、その際は TestsSwiftPMResourceSampleTests (テストのパッケージ名) 配下にファイルを置き、 .testTarget()resources.copy() メソッドを使ってファイルを登録してください。

ディレクトリ内に大量のファイルがある場合

1つ1つファイルパスを記載するのはめんどくさいですよね?
その時はディレクトリを登録しておくとその配下のファイルをまとめて登録してくれるようです。

ディレクトリ構造を維持したままファイルを登録

ファイル登録時に使った .copy() をそのまま使います。

Package.swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftPMResourceSample",
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .executableTarget(
            name: "SwiftPMResourceSample",
            dependencies: [],
            resources: [
                .copy("Data"),  // ← ここをディレクトリに変更!
            ]
        ),
        .testTarget(
            name: "SwiftPMResourceSampleTests",
            dependencies: ["SwiftPMResourceSample"]),
    ]
)

ファイルパスが変わっていなければ、読み込み時のパスはそのままでファイルURLを取得できます。

SwiftPMResourceSample.swift
import Foundation

@main
public struct SwiftPMResourceSample {
    public static func main() throws {
        // `Data/hello_world.txt` を読み込む
        guard let fileUrl = Bundle.module.url(forResource: "Data/hello_world", withExtension: "txt"),
              let data = try? String(contentsOf: fileUrl) else {
            print("Error: file not found")
            exit(1)
        }
        
        print(data)
    }
}

なお、さらに Data ディレクトリの下にもう一つディレクトリがあっても、 Package.swift の表記は変えず、そのままファイルパスを記載してあげれば読み込んでくれます。

SwiftPMResourceSample.swift
import Foundation

@main
public struct SwiftPMResourceSample {
    public static func main() throws {
        // さらにもう一つディレクトリの下にあるファイルを読み込む
        guard let fileUrl = Bundle.module.url(forResource: "Data/SubDirectory/hello_world2", withExtension: "txt"),
              let data = try? String(contentsOf: fileUrl) else {
            print("Error: file not found")
            exit(1)
        }
        
        print(data)
    }
}

全てをroot直下に展開して登録

ディレクトリに関係なく全てroot直下にファイルを展開したい場合は、.copy() ではなく .process() を使います。

Package.swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftPMResourceSample",
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .executableTarget(
            name: "SwiftPMResourceSample",
            dependencies: [],
            resources: [
//                .copy("Data"),
                .process("Data"),  // ← Dataディレクトリ内のファイル全てをroot直下に展開する
            ]
        ),
        .testTarget(
            name: "SwiftPMResourceSampleTests",
            dependencies: ["SwiftPMResourceSample"]),
    ]
)

こうすると、どんなに深いディレクトリの下にファイルがあったとしても、ファイル名のみを指定するだけでファイルURLを取得できます。

SwiftPMResourceSample.swift
import Foundation

@main
public struct SwiftPMResourceSample {
    public static func main() throws {
        // ファイル名のみを指定する
        guard let fileUrl = Bundle.module.url(forResource: "hello_world", withExtension: "txt"),
              let data = try? String(contentsOf: fileUrl) else {
            print("Error: file not found")
            exit(1)
        }
        
        print(data)
    }
}

ただし、指定したディレクトリの下にある複数のディレクトリにそれぞれ同じ名前のファイルが存在すると、ビルド時にエラーとなります。

Multiple commands produce '/Users/<user_name>/Library/Developer/Xcode/DerivedData/SwiftPMResourceSample-bcbsrlteoxrjemcuwgjzahjuqjan/Build/Products/Debug/SwiftPMResourceSample_SwiftPMResourceSample.bundle/Contents/Resources/hello_world.txt'
duplicate output file '/Users/<user_name>/Library/Developer/Xcode/DerivedData/SwiftPMResourceSample-bcbsrlteoxrjemcuwgjzahjuqjan/Build/Products/Debug/SwiftPMResourceSample_SwiftPMResourceSample.bundle/Contents/Resources/hello_world.txt' on task: CpResource /Users/<user_name>/Library/Developer/Xcode/DerivedData/SwiftPMResourceSample-bcbsrlteoxrjemcuwgjzahjuqjan/Build/Products/Debug/SwiftPMResourceSample_SwiftPMResourceSample.bundle/Contents/Resources/hello_world.txt /Users/<user_name>/Repo/SwiftPMResourceSample/Sources/SwiftPMResourceSample/Data/hello_world.txt

この場合は、ファイル名を変更するか .copy() を使うようにしましょう。

SwiftPM、いいぞー!!

従来からある xxxx.xcodeproj の配下にあるプロジェクト設定は、とても長く、とても複雑で、並行して開発をしているとコンフリクトが起こりやすくて辛かったのですが、SwiftPMで管理されていると設定自体がSwift言語で書かれているので読みやすく、シンプルでいいですね!

流石に、いきなり会社のプロダクトコードをこれに変更するとみんながびっくりするだろうし、移行してみんなに正しく使ってもらうまでのコストもかかるし、もっと大きくて複雑なプロダクトコードだとSwiftPMで辛いパターンが出てきそうです。
ですので、今回みたいな簡単な便利ツールとか、そういったものでどんどん活用していこうかなと思います。

サンプルコード

今回の記事の為に作成したサンプルコードは以下に格納されています。

https://github.com/mltokky/SwiftPMResourceSample

Discussion