ユーザーが選んだ数枚の写真をコラージュに使うだけの機能を、Rork Max が生成したアプリへ足したときのことです。PHPickerViewController で選んだ写真はその場では問題なく表示され、加工も効きます。ところがアプリを再起動すると、直前に選んだはずの写真が真っ黒、あるいは読み込みに失敗する。選び直せば直りますが、保存したはずの状態が壊れている、という報告に近い挙動でした。エラーらしいエラーは出ません。
原因は、PHPicker が返す一時的な参照を「あとで読めばいい」と握り続けていたことです。限定アクセスが当たり前になった今の iOS では、選択時点で有効だった URL や PHAsset の識別子が、次回には読めなくなる前提で設計する必要があります。個人開発で写真アプリを長く触ってきましたが、この「選んだ瞬間だけ有効」という限定アクセスの性質を取りこぼすと、本番で静かにデータが欠けます。ここでは Rork Max のネイティブアプリに写真取り込みを最小差分で足す前提で、握り続ける罠の避け方と、そのまま使える取り込みレイヤーを順に見ていきます。
PHPicker が「権限を求めない」代わりに渡すものの正体
PHPickerViewController の大きな利点は、フォトライブラリの権限ダイアログを出さずに使えることです。ユーザーがシステム UI の中で選んだ写真だけがアプリに渡るので、アプリはライブラリ全体へのアクセス権を持ちません。ここが従来の UIImagePickerController との決定的な違いです。
渡ってくるのは実体ではなく「取り出し口」
PHPicker が返す NSItemProvider は、写真の実体そのものではなく「今なら取り出せる口」です。loadFileRepresentation や loadDataRepresentation でその場で読み出すことはできますが、返ってくる一時ファイルの URL はコールバックを抜けた後には失効します。ここを「URL を保存しておいて後で開けばいい」と考えると、次回起動時に読めない状態に落ちます。
PHAsset の識別子も永続の保証ではない
PHPickerConfiguration で preferredAssetRepresentationMode を調整すれば PHAsset に到達する構成も取れますが、限定アクセス下では、そのときアプリに見えていた asset が次回も見えるとは限りません。ユーザーが後で選択範囲を変えれば、識別子は残っていても実体へ辿れなくなります。私は写真アプリの運用で、asset 識別子の永続化に頼る設計は避けるようになりました。
解決の芯は「選んだ瞬間に実体をコピーする」こと
握り続ける発想をやめて、選択のコールバックの中で実体を自分の永続領域(Application Support など)へコピーしきってしまう。以後はそのローカルファイルだけを参照する。これが限定アクセス時代の素直な設計です。取り込み時に一度だけコストを払い、あとは自前のファイルとして扱うことで、URL 失効も選択範囲変更も無関係になります。
import PhotosUI
import UniformTypeIdentifiers
import os
let picLog = Logger(subsystem: "net.rorklab.sample", category: "photo")
enum PhotoImportError: Error { case noImageRep, copyFailed }
final class PhotoImporter {
private let dir: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask)[0]
let d = base.appendingPathComponent("imported", isDirectory: true)
try? FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
return d
}()
// PHPicker の結果を受け取り、永続パスの配列を返す
func importItems(_ results: [PHPickerResult]) async -> [URL] {
var saved: [URL] = []
for result in results {
do {
let url = try await copyToLocal(result.itemProvider)
saved.append(url)
picLog.info("imported: \(url.lastPathComponent)")
} catch {
picLog.error("import failed: \(error.localizedDescription)")
}
}
return saved
}
}