壁紙アプリに「お手持ちの画像も壁紙にできます」という一文を足したくなったのは、あるユーザーから「自分で撮った写真を加工して使いたい」という要望をいただいたときでした。写真ライブラリから選ぶだけなら expo-image-picker で十分です。ところが、ユーザーが使いたい画像はカメラロールにあるとは限りません。クラウドに置いたデザイン素材、AirDropで受け取った1枚、iCloud Driveに整理したフォルダ。これらは「写真」ではなく「ファイル」アプリの領域にあります。
そこで expo-document-picker を入れてみると、選択まではあっさり動くのに、取り込んだ画像が翌日には開けなくなる、という現象に遭遇しました。原因は、iOSがファイルアプリの実体を直接渡しているわけではない、という一点に尽きます。以下では、私自身が個人開発の現場でつまずいた順番のまま、ドキュメントピッカーの最小実装から、URLが消える理由、サンドボックスへの確実なコピー、そしてRork Maxでネイティブに踏み込む場合の作法までを順に追います。
写真ピッカーで届く範囲、ファイルピッカーが必要な範囲
expo-image-picker が扱うのは Photos フレームワークの世界、つまりカメラロールとアルバムです。一方で「ファイル」アプリは、オンマイiPhone・iCloud Drive・サードパーティのクラウド(Dropbox や Google Drive のプロバイダ拡張)まで横断する別の入口です。
両者は技術的にも権限モデルが違います。写真ピッカーは「ユーザーが選んだ1枚だけをコピーして渡す」設計で、アプリにフォトライブラリ権限は不要です。ドキュメントピッカーも「選んだ瞬間だけ」のアクセスを渡しますが、その渡し方が曲者で、対象がアプリのサンドボックスの外にある以上、URLには寿命があります。ここを理解しないまま uri を保存すると、後から必ず壊れます。
expo-document-picker の最小実装と、最初の壁
まずは素直に書いてみます。
import * as DocumentPicker from 'expo-document-picker' ;
async function pickImageFromFiles () {
const result = await DocumentPicker. getDocumentAsync ({
type: [ 'image/jpeg' , 'image/png' , 'image/heic' ],
copyToCacheDirectory: true ,
multiple: false ,
});
if (result.canceled) return null ;
const asset = result.assets[ 0 ];
// asset.uri / asset.name / asset.size / asset.mimeType
return asset.uri;
}
copyToCacheDirectory: true を指定すると、Expoが裏でファイルをアプリのキャッシュ領域へコピーし、そのfile://のURIを返してくれます。ここまでは問題なく表示できます。落とし穴は次です。キャッシュディレクトリ(FileSystem.cacheDirectory)はOSがいつでも空にできる一時領域です。ストレージ逼迫時やアプリ更新時に消えても文句は言えません。つまり、このuriを AsyncStorage に保存して「次回も同じ壁紙」を再現しようとすると、ある日突然読めなくなります。
copyToCacheDirectory: false にすると、コピーを挟まないぶん高速ですが、返ってくるのはセキュリティスコープ付きの一時URLです。これは選択直後のコールバックの中でしか有効でなく、外に持ち出した瞬間に無効化されます。Androidでは content:// が返り、こちらも長期保存には向きません。
なぜ取り込んだURLは消えるのか
iOSは、サンドボックス外のファイルへのアクセスを「セキュリティスコープ付きURL」という形で一時的に貸し出します。ドキュメントピッカーが返すURLはまさにこれで、貸出期間は実質的に「今この処理の間だけ」です。長く保持したい場合は、本来ならブックマークデータ(bookmarkData)に変換して保存し、使うたびに解決し直す必要があります。
ただ、画像の取り込みという用途では、ブックマークで毎回元ファイルを参照しに行くより、最初に自分のサンドボックスへコピーしてしまうほうが圧倒的に堅牢です。元ファイルが移動・削除・クラウドから消えても、アプリ内のコピーは生き残ります。私の壁紙アプリでも、取り込み画像はすべて documentDirectory 配下に複製し、参照は自分のコピーだけに限定しています。
アプリのサンドボックスへ確実にコピーする
expo-file-system で、キャッシュではなく永続領域(documentDirectory)へ退避します。ファイル名は衝突を避けるため一意にします。
import * as DocumentPicker from 'expo-document-picker' ;
import * as FileSystem from 'expo-file-system' ;
const IMPORT_DIR = FileSystem.documentDirectory + 'imported/' ;
async function ensureDir () {
const info = await FileSystem. getInfoAsync ( IMPORT_DIR );
if ( ! info.exists) {
await FileSystem. makeDirectoryAsync ( IMPORT_DIR , { intermediates: true });
}
}
function extFromMime ( mime ?: string ) {
if (mime === 'image/png' ) return 'png' ;
if (mime === 'image/heic' ) return 'heic' ;
return 'jpg' ;
}
export async function importImageFromFiles () {
const result = await DocumentPicker. getDocumentAsync ({
type: [ 'image/jpeg' , 'image/png' , 'image/heic' ],
copyToCacheDirectory: true , // 一旦キャッシュへ。直後に永続領域へ移す
multiple: false ,
});
if (result.canceled) return null ;
const asset = result.assets[ 0 ];
// 上限チェック(例: 40MB)。巨大ファイルは早めに弾く
if (asset.size && asset.size > 40 * 1024 * 1024 ) {
throw new Error ( 'FILE_TOO_LARGE' );
}
await ensureDir ();
const dest = `${ IMPORT_DIR }${ Date . now () }-${ Math . random (). toString ( 36 ). slice ( 2 ) }.${ extFromMime ( asset . mimeType ) }` ;
await FileSystem. copyAsync ({ from: asset.uri, to: dest });
// 以後、参照するのは dest だけ。元URIは破棄する
return dest;
}
ポイントは、copyToCacheDirectory: true で一度Expoにコピーさせ、その有効なうちに copyAsync で documentDirectory へ二段目のコピーを行うことです。これで参照先はすべてアプリの永続サンドボックス内に揃い、再起動後もアプリ更新後も同じパスで読めます。保存するのはdestの相対パス(imported/xxxxx.jpg)が安全です。documentDirectoryの絶対パスはアプリ更新でコンテナのUUIDが変わると変動するため、絶対パスをそのままAsyncStorageに入れると壊れます。
iCloud Driveの「まだ落ちてきていない」ファイルに備える
「ファイル」アプリで選ばれた画像がiCloud Drive上にあり、まだ端末に実体がダウンロードされていないケースがあります。copyToCacheDirectory: true の場合はExpo側がダウンロードを待ってくれることが多いのですが、回線が細いと体感で数秒固まり、ユーザーは「フリーズした」と誤解します。
実務上は、取り込み処理を必ずローディング状態で包み、サイズが大きい・時間がかかる前提でUIを設計しておくことが効きます。
const [ importing , setImporting ] = useState ( false );
async function onPressImport () {
setImporting ( true );
try {
const path = await importImageFromFiles ();
if (path) setWallpaperSource (path);
} catch ( e : any ) {
if (e?.message === 'FILE_TOO_LARGE' ) {
Alert. alert ( '画像が大きすぎます' , '40MB以下の画像を選んでください。' );
} else {
Alert. alert ( '取り込みに失敗しました' , '時間をおいて、もう一度お試しください。' );
}
} finally {
setImporting ( false );
}
}
地味ですが、クラウド前提のファイルピッカーでは「待たされること」自体が仕様だと割り切り、スピナーと明確なエラーメッセージを最初から用意しておくと、レビューでの低評価をかなり減らせます。
Rork Maxのネイティブ実装 — セキュリティスコープ付きURLを正しく扱う
React Native(Expo)版で足りる場面がほとんどですが、Rork MaxでネイティブSwiftアプリを書く場合は、ドキュメントピッカーを直接触ることになります。ここでこそ、先ほど概念だけ触れた「セキュリティスコープ付きURL」を明示的に開閉する必要があります。
import UIKit
import UniformTypeIdentifiers
final class ImageImporter : NSObject , UIDocumentPickerDelegate {
var onImported: ((URL) -> Void ) ?
func present ( from vc: UIViewController) {
let picker = UIDocumentPickerViewController (
forOpeningContentTypes : [UTType. image ], asCopy : false
)
picker.delegate = self
picker.allowsMultipleSelection = false
vc. present (picker, animated : true )
}
func documentPicker ( _ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]) {
guard let src = urls. first else { return }
// 1. スコープを開く。失敗したら触れない
guard src. startAccessingSecurityScopedResource () else { return }
defer { src. stopAccessingSecurityScopedResource () }
// 2. iCloud等の遅延読み込みに備え、コーディネート読み取りで複製
var coordError: NSError ?
NSFileCoordinator (). coordinate (
readingItemAt : src, options : [.withoutChanges], error : & coordError
) { safeURL in
let dir = FileManager.default
. urls ( for : .documentDirectory, in : .userDomainMask)[ 0 ]
. appendingPathComponent ( "imported" , isDirectory : true )
try? FileManager.default. createDirectory (
at : dir, withIntermediateDirectories : true )
let dest = dir. appendingPathComponent (
" \( Int ( Date (). timeIntervalSince1970 ) ) - \( UUID (). uuidString ) . \( safeURL. pathExtension ) " )
do {
try FileManager.default. copyItem ( at : safeURL, to : dest)
DispatchQueue.main. async { self .onImported ? (dest) }
} catch {
// コピー失敗。UI側でリトライを促す
}
}
}
}
asCopy: false で開いた場合、URLはサンドボックス外を指すため、startAccessingSecurityScopedResource() を呼ばずに FileManager で読もうとすると権限エラーで失敗します。defer で必ず stop を対にするのが鉄則です。さらに、対象がiCloud上で未ダウンロードのことがあるため、素の copyItem ではなく NSFileCoordinator のコーディネート読み取り経由で複製すると、ダウンロードと整合性を取りながら安全に取り込めます。asCopy: true を選べばOSが先にコピーを作ってくれるので開閉は不要になりますが、巨大ファイルでは余分な一時コピーが増えます。用途で選び分けるのがよいと考えています。
取り込み後の実務 — HEIC・巨大ファイル・複数選択
最後に、取り込んだあとに効いてくる細部をまとめます。私の場合は、用途が端末内で完結しないなら正規化を必ず挟むことを推奨します。
HEICは共有・アップロードで詰まりやすい
HEICは、Appleの端末同士では軽量で扱いやすい一方、後段で別サービスへアップロードしたり、Android側と共有したりすると非対応で弾かれることがあります。壁紙として端末内で完結するなら無変換で構いませんが、共有・アップロードが絡むなら、取り込み直後にexpo-image-manipulatorでJPEG/PNGへ正規化しておくと事故が減ります。
import * as ImageManipulator from 'expo-image-manipulator' ;
async function normalizeForUpload ( uri : string ) {
const out = await ImageManipulator. manipulateAsync (
uri, [{ resize: { width: 2048 } }],
{ compress: 0.9 , format: ImageManipulator.SaveFormat. JPEG }
);
return out.uri; // EXIF回転も解消され、向きが安定する
}
巨大ファイルはサイズで先に弾く
巨大ファイルは、取り込み前にasset.sizeで上限を設けるのが第一防衛線です。デザイン用の高解像度PSD書き出しなどは数十MBに達することがあり、無条件にコピー→デコードするとメモリ警告でクラッシュします。リサイズ前提で扱うのが安全です。
複数選択は進捗を見せる
複数選択(multiple: true / allowsMultipleSelection = true)を許可する場合は、コピーを直列ではなく順次awaitで回し、1枚ごとに進捗を見せます。10枚を一気に無言で処理するより、「3/10枚目」と出すほうが、待ち時間の体感が大きく変わります。Doliceで運用している複数の壁紙アプリでも、取り込みは必ず進捗付きにしています。
次の一歩
まずは copyToCacheDirectory: true → documentDirectory への二段コピーと、保存は相対パスで、という骨格を1本のユーティリティに固めてみてください。ここさえ堅くしておけば、写真ライブラリ取り込み・カメラ撮影・ファイル取り込みの三系統を、同じ「サンドボックス内の自前コピー」へ収束させられます。参照の入口がひとつになると、後段の加工・キャッシュ・削除のロジックが一気に単純になります。
同じように「ユーザーの持ち込み画像」を扱おうとしている方の、最初のつまずきを一つ減らせたなら幸いです。