SwiftPMで画像リソースを読み込みたい、けど諸々の事情でXCAssetsが使えないときは? - アイリッジ開発者ブログ

アイリッジ開発者ブログ

アイリッジに所属するエンジニアが技術情報を発信していきます。

SwiftPMで画像リソースを読み込みたい、けど諸々の事情でXCAssetsが使えないときは?

こんにちは、開発部 iOSチームの西岡です。
今回は「Swift Package Manager」について、やや主流から外れたOddな使い方をご紹介します。
よろしくお願いします。

SwiftPMでリソースを扱う

現在iOS開発(※注)でのSwiftPMとは、CocoaPodsやCathageを代替する単なるライブラリや外部パッケージを管理するライブラリ管理ツールとして欠かせないものとなっていますが、近年はTCAをはじめとしたアーキテクチャーを支える屋台骨として、モジュールを複合的に組み合わせて機能を提供する役割を担うなど、その強力な拡張性と柔軟性が活かされています。
(※確認環境 Xcode15.2, Swift5.9)

そんな背景からか、モジュールでもリソースを扱う場面が増えてきた気がします。
一方では、プロジェクトファイルと同じやり方で何となく実装を進めていると、何故かパッケージ読み込みに失敗したり、実行時にリソース先が見当たらなくて表示されない、なんてことが起こります。
無勉強だった私も、そんな洗礼を何度か経験しました…( ; ; )
そんなこんなで、SwiftPMでリソースを扱う際の初歩的なポイントを改めて復習していきたいと思います。

明示的なバンドル指定が必要

SwiftPMは、各ターゲットごとにリソースを参照する仕組みを提供しています。
そして、その重要なインターフェースの要となるのが Bundle.module です。

まずは画像ファイルhydrangea.jpgを表示するSwiftUIの実装例で考えてみます。
iOS開発では、PNGやJPEGのような画像ファイルを扱う場合にはXCAssetsと呼ばれる、Xcodeプロジェクト上で編集管理をしやすくしてくれるバンドルファイルを利用するのが一般的です。

この画像リソースをソースコード上から参照するには、 Image(bundle:)の引数には画像リソース名とそのバンドルを指定する必要があります。(Xcode15であればリソース名をImageResourceで指定することも可能です)

仮に第2引数のバンドル先を指定していなかったら、どうでしょうか?
そんな場合、バンドル未指定だとデフォルト値 nil が設定されます。するとデフォルトバンドルである Bundle.main (アプリケーション本体側のリソースバンドル)内の画像ファイルhydrangea.jpgを読み込もうとするため、結果的にリソース取得に失敗します。
画面には何も表示できませんでした↓

FeatureAモジュール内のリソースを扱う場合には、明示的にバンドル先( Bundle.module )を指定してあげる必要があります。

リソースは同一モジュール内から

では、この Bundle.module とは一体何でしょうか?
そもそも他のターゲットモジュールからでも参照できるのでしょうか?
例えば、FeatureBからFeatureAの画像リソースを参照するような状況を考えてみたいと思います。

Bundle.module は、ターゲット内に Asset Catalog を配置するとXcodeが自動で提供してくれるモジュール専用のバンドルです。そのため、ターゲットからAssets catalog を取り除くと「モジュール?そんなバンドルないよ?」というエラーが発生します。

別ターゲットであるFeatureBに移動させた場合もみてみましょう。

こちらの場合もエラーになりました↑
他ターゲットに配置された画像リソースを(※)直接参照することができません。
(※注 一度メモリ上に展開し、FeatureBをインポートしてUIImageデータとして取得することは可能です)

では、FeatureAターゲット内にXCAssetsが配置されている状況だったとき、そこで自動生成される Bundle.module の中身はどうなっているのでしょうか?
一度覗いてみましょう。
FeatureAターゲットの場合だとこんな感じでした↓

import class Foundation.Bundle
import class Foundation.ProcessInfo
import struct Foundation.URL

private class BundleFinder {}

extension Foundation.Bundle {
    /// Returns the resource bundle associated with the current Swift module.
    static let module: Bundle = {
        let bundleName = "MyLibrary_FeatureA"

        let overrides: [URL]
        #if DEBUG
        // The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The
        // check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over.
        // This removal is tracked by rdar://107766372.
        if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"]
                       ?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
            overrides = [URL(fileURLWithPath: override)]
        } else {
            overrides = []
        }
        #else
        overrides = []
        #endif

        let candidates = overrides + [
            // Bundle should be present here when the package is linked into an App.
            Bundle.main.resourceURL,

            // Bundle should be present here when the package is linked into a framework.
            Bundle(for: BundleFinder.self).resourceURL,

            // For command-line tools.
            Bundle.main.bundleURL,
        ]

        for candidate in candidates {
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        fatalError("unable to find bundle named MyLibrary_FeatureA")
    }()
}

Foundation.Bundle.module のアクセスコントロールはデフォルト(internal)になっているのがわかります。internalは、同一ターゲットからの参照のみを許可しますので、FeatureAの画像リソース hydrangea をFeatureA以外のターゲット(もしくはモジュール)から参照することができません。

さらに Bundle(for: BundleFinder.self).resourceURL という行にも注目です。Bundle(for:) を介して取得できるBundleオブジェクトは、引数で指定したクラスが属するターゲットと同じものとなります。つまり、この場合だと FeatureAで生成されるBundleFinderクラスはFeatureAターゲットに属するため、Bundle(for: BundleFinder.self).resourceURLではFeatureAターゲットに対するバンドルのリソースURLが返ってくるはずです。

あるモジュールのソースコードから Bundle.module を参照する場合、同一モジュール(もしくはターゲット)のBundleオブジェクトを参照することを保証していることから、リソースにアクセスできるのは同一モジュール内のソースコードに限られるというのが、上記のソースからも読み取れます。

Bundle.module が生成される条件

お待たせしました、ここからがいよいよ本題です。
これまではモジュール内にXCAssetsを作成することが大前提でした。
では、XCAssetsを利用せずに画像リソースを直接配置し、SwiftPMに読み込ませる場合はどうでしょうか。

先に結論を述べると、XCAssetsを使わずに直接画像リソースを配置してもBundle.bundle自動では生成されません
なぜなら、自動生成してくれる検出対象ファイル が決まっているからです。

  • XIB files または storyboards
  • xcdatamodeld(CoreData)
  • Asset catalogs (画像・カラー)
  • .lproj (ローカライズファイル xcstrings)

hydrangea.jpg のようなリソースファイルを単に配置しただけでは、Xcodeは自動検出してくれない、SwiftPMの仕様上、この点は要注意です。

では、どうするか?

Appleのドキュメントには、次のような説明があります。

To add a resource that Xcode can’t handle automatically, explicitly declare it as a resource in your package manifest.

つまり、自動検出対象外のリソースファイルを読み込ませたい場合、Package.swift(package manifest)にリソースのパス先を明確に記述しなければなりません。
対象となるターゲットに対しててリソースのパス先を指定してはじめて取り込みが実行されて、そこから Bundle.module も生成されるようです。

早速、FeatureAターゲットの resources:に対して画像リソースのパス先を指定してみます。
この時、パス先にはターゲットディレクトリ(FeatureAフォルダ)からの相対パスを指定していきます。

    targets: [
        .target(
            name: "FeatureA",
            resources: [
                .process("./hydrangea.jpg") // リソースまでのパスを追記する!
            ]
        ),
   ]

これでFeatureAターゲット内のソースコードから画像リソース hydrangea.jpg に参照できるようになりました。

また、 Bundle.module にアクセスできるということで、
ソースコードからリソースのパス先を直で取得することも可能です。

hydrangea.jpg までの絶対パスも取得できるのがわかります。

このように特定のファイルまでのパス先を取得できることから、今回のような画像ファイルに限らず、サウンドやビデオ、アニメーション用のファイル、さらにJSONや独自のカスタムバンドルなどのファイル等までも読み込むことが可能です。

.process vs .copy

ちなみにPackage.swift(package manifest)に対してリソースファイルのパス指定する際、2タイプの設定ルールがあるのにお気づきかと思います。
基本的にパス先が分かればリソース読み込みが可能なため、リソースの属性に応じて選択することができます。

.process platformに応じて最適化される 特に何もなければ、こちらでOK👍
.copy 最適化なし(そのままパス先からコピーされる) もしリソースを原本のままで、もしくは特定のフォルダ構造を保持したいとき(Bundle系のファイルなど)はこちらを選択する

特殊なリソースを扱っているなどの事情がなければ、 .process(_:) をAppleは推奨しています。

developer.apple.com developer.apple.com

まとめ

  • SwiftPMのモジュール内のリソースを参照するには、バンドル先として自身のターゲットバンドル Bundle.module を明示的に指定する必要がある
  • Bundle.module が提供できるリソースは、同一モジュール内のリソースに限られる(基本的にほぼフレームワークと考え方は同じ)
  • Xcodeが自動検出してくれるのは、特定のファイル(xib, storyboard, xcdatamodeld, xcassets, lproj, xcstrings)に限られる
  • 検出対象外のリソースファイルを読み込みたい場合、Package manifestのtargets設定ごとに相対パスで明記することが必要
  • 検出対象外のリソースファイルまでのパス先を指定する際は、特別な構造をもつファイルでない限りは .process(_:) を指定すればOK

日々の開発を進めていると地味だけど頭が痛い…. なんて課題にも対応を迫られますが、今回のTipsも一役となることがあれば大変幸いです。

最後までお読みいただき、ありがとうございました。

References

developer.apple.com developer.apple.com