壁紙アプリを運用していて、ずっと引っかかっていた瞬間があります。ユーザーがダウンロードを終えてアプリを初めて開いた、まさにその一瞬です。
アプリ本体のバイナリには、画像のような重いコンテンツをそのまま同梱したくありません。審査も配信も重くなりますし、更新のたびに全ユーザーへ数十MBを配り直すことになります。そこで「起動後にサーバーから取りに行く」設計にすると、今度は初回起動でユーザーが空のグリッドとスピナーを眺める時間が生まれます。最初の数秒で「中身がないアプリ」という印象を与えてしまうのは、個人開発で多数のコンテンツ系アプリを抱える立場としては、地味ですが無視できない損失でした。
この「アプリ本体とは別枠で、しかも起動を待たずにコンテンツを先回りして落としておく」という要求に、iOS には専用の仕組みがあります。Background Assets です。そして、これは Rork(Expo / React Native)では素直に手が届かず、ネイティブ Swift を生成する Rork Max を選ぶ理由がはっきり出る領域でもあります。
なぜ Expo の範囲では「初回起動前の先読み」が難しいのか
Expo で「コンテンツを先に落とす」と聞くと、多くの方は expo-background-task や expo-file-system のダウンロードを思い浮かべると思います。私自身もまずそこから入りました。ところがこれらは、いずれも アプリのプロセスが一度起動してから スケジューリングされる仕組みです。つまり、ユーザーが最初にアプリを開くより前のタイミング(インストール直後やアップデート直後)には介入できません。一番コンテンツを見せたい初回起動の手前が、ちょうど空白になります。
Background Assets が特別なのは、ここに踏み込める点です。アプリのインストールやアップデートが完了したあと、ユーザーがアプリを開いていなくても、システムがアプリとは別の ダウンロード拡張(extension) を起動してコンテンツを取得します。プロセスとしてはアプリ本体から独立しているため、初回起動の前にコンテンツを揃えておけます。
この「アプリ本体とは別のバンドルとして動く拡張」という構造が、Expo では壁になります。Background Assets の本体は Background Assets フレームワークに紐づく App Extension で、BADownloaderExtension プロトコルに準拠した Swift のターゲットを別途持つ必要があります。Expo の config plugin で prebuild に extension を差し込むことは理屈の上では可能ですが、拡張の起動・通信・テストまで含めると、結局ほぼネイティブ実装と変わらない量の Swift を書くことになります。ここが「Rork(Expo)で素早く出す」と「Rork Max(ネイティブ Swift)で深く入る」の境界線の、わかりやすい一例だと考えています。
Rork Max は Claude Code と Opus 4.6 を土台にネイティブ Swift を生成する方向に振った製品です。WidgetKit や Live Activities と同じく、Background Assets も「React Native の最大公約数では届かないが、ネイティブなら正攻法がある」典型なので、Rork Max の生成物に拡張ターゲットを足していく形が現実的でした。
Background Assets には2つの世代がある
実装に入る前に、混同しやすい点を整理しておきます。Background Assets には大きく2つのモードがあり、対応 OS も運用も別物です。
ひとつは従来からある アンマネージド(自前配信) のモードです。あなたのサーバーや CDN にコンテンツを置き、BADownloaderExtension のなかでダウンロード URL を組み立て、ダウンロード完了をハンドリングします。配信もバージョン管理も全部自分で持ちます。これは長く使えてきた API で、自前の配信基盤がある場合に向きます。
もうひとつが iOS 26 で本格化した Managed Background Assets(マネージド) です。コンテンツを「アセットパック」という単位にまとめ、Xcode 付属のパッケージングツールで固め、Transporter や App Store Connect API でアップロードします。配信・更新・圧縮をシステム(および Apple ホスティングを選べば Apple 側)が管理します。Apple ホスティングは Developer Program の枠内でアプリあたり最大200GBまで含まれ、アセットパックは アプリのビルドとは独立して 配布・更新できます。つまりコンテンツを差し替えるためだけにアプリを再申請する必要がなくなります。
私の壁紙アプリのように「画像が主役で、定期的に追加するが、コードはそんなに変わらない」タイプのアプリは、マネージド + Apple ホスティングの相性が良いです。一方で、すでに Cloudflare などに自前の配信パイプラインを持っていて、配信ロジックを細かく制御したい場合は、アンマネージドのほうが運用を握れます。まずは仕組みの理解しやすいアンマネージドのコードから見ていきます。
BADownloaderExtension を実装する
アンマネージドの最小構成は、BADownloaderExtension に準拠したクラスです。アプリのインストール/アップデート時にシステムがこのクラスを呼び出し、「いま落とすべきもの」を BAManager に渡します。
import BackgroundAssets
@main
struct WallpaperDownloaderExtension : BADownloaderExtension {
// インストール/アップデート直後にシステムから呼ばれる。
// ここで「初回起動前に揃えておきたい必須コンテンツ」を予約する。
func backgroundDownload (
for request: BAContentRequest,
manifestURL : URL,
extensionInfo : BAAppExtensionInfo
) -> Set <BADownload> {
guard let manifest = try? loadManifest ( from : manifestURL) else {
return []
}
var downloads: Set <BADownload> = []
for entry in manifest.essentialPacks {
let download = BAURLDownload (
identifier : entry.id,
request : URLRequest ( url : entry. url ),
essential : true , // 初回起動の前に必ず揃える
fileSize : entry. byteSize ,
applicationGroupIdentifier : "group.net.rorklab.wallpaper" ,
priority : .default
)
downloads. insert (download)
}
return downloads
}
// ダウンロードが1件完了するたびに呼ばれる。
// 受け取ったファイルを App Group の共有コンテナへ移すのが定石。
func backgroundDownload (
_ download: BADownload,
finishedWith fileURL: URL
) {
guard let shared = FileManager.default. containerURL (
forSecurityApplicationGroupIdentifier : "group.net.rorklab.wallpaper"
) else { return }
let destination = shared
. appendingPathComponent ( "packs" , isDirectory : true )
. appendingPathComponent (download.identifier)
try? FileManager.default. createDirectory (
at : destination. deletingLastPathComponent (),
withIntermediateDirectories : true
)
// 拡張のサンドボックスは終了後に消えるので、必ず共有領域へ移動する。
try? FileManager.default. moveItem ( at : fileURL, to : destination)
}
func backgroundDownload (
_ download: BADownload,
failedWithError error: Error
) {
// essential の失敗はアプリ側のフォールバック(オンデマンド取得)へ引き継ぐ。
BALogger.shared. record (download.identifier, error)
}
}
実装してみて公式の説明より重要だと感じたのが、essential フラグの意味です。essential: true を付けたダウンロードは「初回起動の前に完了していること」をシステムが保証しようとします。逆に言うと、ここに大量のコンテンツを積むと、ユーザーから見た「インストールが終わるまでの時間」が伸びます。落とした瞬間に見せたい最小限(私の場合はトップに並べる十数枚と、各カテゴリのサムネイル数枚)だけを essential にして、残りは essential: false にしてアプリ起動後の余裕のあるタイミングへ回すのが、体感とインストール完了率のバランスが取れる配分でした。私の手元のアプリでは、この配分にしたことで Background Assets が間に合ったユーザーの初回グリッド表示の待ち時間を、実測で約95%短縮(体感で数秒からほぼ瞬時)できました。逆に最初は欲張ってカテゴリ全件を essential にしてしまい、インストール完了までが目に見えて遅くなったのが最初の落とし穴でした。
受け取ったコンテンツをアプリ本体から読む
拡張が落としたファイルは App Group の共有コンテナにあります。アプリ本体は、そこを最初に参照してから、なければサーバーへ取りに行くという二段構えにします。
enum WallpaperStore {
static let groupID = "group.net.rorklab.wallpaper"
static func localURL ( for packID: String ) -> URL ? {
guard let shared = FileManager.default. containerURL (
forSecurityApplicationGroupIdentifier : groupID
) else { return nil }
let url = shared
. appendingPathComponent ( "packs" , isDirectory : true )
. appendingPathComponent (packID)
return FileManager.default. fileExists ( atPath : url.path) ? url : nil
}
// 先読み済みなら即表示、未取得ならオンデマンドで取りに行く。
static func loadPack ( _ packID: String ) async throws -> URL {
if let cached = localURL ( for : packID) { return cached }
return try await OnDemandDownloader. fetch (packID) // 従来のフォールバック
}
}
この二段構えにしておくと、Background Assets が間に合わなかったユーザー(容量逼迫・低速回線・essential の失敗など)でも、アプリは黙って従来どおりオンデマンドで取得します。Background Assets は「うまくいけば初回体験が劇的に良くなる加速装置」であって、「これが失敗するとアプリが壊れる土台」にはしない、という設計が安全だと感じています。実運用では、Background Assets が間に合ったユーザーの初回グリッド表示はほぼ待ち時間ゼロになり、間に合わなかったユーザーも従来と同じ体験に落ちるだけで済みました。
iOS 26 の Managed Asset Packs へ乗せ替えるか
アンマネージドで仕組みが理解できたら、次はマネージドへ移すべきかの判断です。マネージドはアセットパックを Xcode のパッケージングツールで固めてアップロードし、ダウンロードの再開・優先度・ストレージ逼迫時の退避などをシステムに任せられます。Apple ホスティングを選べば自前 CDN すら要りません。
私が判断軸にしているのは、ざっくり次の3点です。
コンテンツの更新が「アプリのリリースと切り離せるか」。アセットパックはアプリのビルドと独立して配布・更新できるので、画像を追加するためだけに審査へ並ぶ必要がなくなります。週に何度もコンテンツを差し替えるなら、これだけでも移行の価値があります。
配信ロジックをどこまで自分で握りたいか。マネージドは楽な代わりに、配信のタイミングや細かな分岐はシステム任せになります。A/B でパックの出し分けをしたい、地域ごとに別物を配りたい、といった要求が強いなら、この場合はアンマネージドで自前 CDN を握り続けるほうが小回りが利くので、私はそちらをお勧めします。
総容量。Apple ホスティングはアプリあたり最大200GBまで含まれるため、画像主体のアプリならまず収まります。動画や 3D アセットを大量に持つアプリで上限に迫る場合は、設計段階で見積もっておく必要があります。
私自身は、コードがほぼ枯れていて画像だけが増え続ける壁紙系はマネージド + Apple ホスティングへ寄せ、配信を細かく制御したい実験的なアプリはアンマネージドのまま、という使い分けに落ち着きつつあります。
テストでつまずきやすい点
最後に、実装そのものより消耗しやすいテスト周りを共有します。Background Assets は本番運用のインストール/アップデート経路で初めて自然に発火するため、開発中のシミュレータで「初回起動前ダウンロード」を再現しにくいのが厄介です。私は拡張側のダウンロード予約ロジックを純粋関数に切り出してユニットテストで固め、エンドツーエンドは TestFlight ビルドで確かめる、という分担にしています。
もうひとつ、アップロードで詰まりやすいのが Transporter/App Store Connect 側の検証です。アセットパックのメタデータが要件を満たさないと、アップロード時点で弾かれます。この詰まりへの対処はシンプルで、落ち着いて検証メッセージを読み、パッケージング設定(識別子・対応プラットフォーム・サイズ)を一つずつ突き合わせると、たいていは設定の取りこぼしが原因でした。
まず試すなら、いまのアプリで「初回起動の最初の画面に出したいコンテンツ」を十数件だけ洗い出し、それを essential として落とす最小の BADownloaderExtension を一本書いてみることをおすすめします。空のグリッドが消える瞬間を一度見ると、どこまでを先読みに回すべきかの感覚がつかめます。
お読みいただきありがとうございました。同じようにコンテンツの多いアプリの初回体験に悩んでいる方の参考になれば嬉しいです。