Skip to content

Lazy Library Artwork

log5r edited this page Oct 3, 2026 · 3 revisions

ライブラリのアートワーク分離とメモリ使用量

2026-10-02、Issue #10への対応として、PR #14でアートワークを一覧用モデルから分離した。3000曲規模のライブラリ性能レビューで確認した、画像付きモデルの全件取得によるメモリ増加を対象とする。実モデルを使った独立プログラムでは、3000件取得後のRSSが修正前の424.14MiBから修正後の22.98MiBへ減った。

画像を別entityへ分離する理由

修正前の MediaItem は、一覧に必要なタイトルなどの属性と、画像本体の artworkData を同じentityに保存していた。永続ストアから全件取得すると、タイトルしか参照しない場合でも画像容量に応じてRSSが増えた。画像の有無判定にも artworkData を参照していたため、画面で描画する画像だけを読み込む構成になっていなかった。

修正後は画像本体を MediaArtwork へ移し、MediaItem には画像のUUIDである artworkID とrelationshipを保存する。hasArtwork はUUIDの有無から判定する。一覧取得と画像の有無判定では、子entityの画像データを参照しない。画像を表示するときに LibraryArtworkLoader がUUIDで取得し、指定サイズへデコードする。

既存データは旧画像列を legacyArtworkData として読み出し、1件ずつ子entityへ移す。子entityの作成と旧列のクリアを同じ保存処理で確定させ、途中で中断しても残りを再開できるようにする。移行完了までは一覧のQueryを作らず、旧画像列を含む全件取得を避ける。

externalStorage指定だけの比較

Appleの externalStorage の説明は、プロパティ値をモデルストレージに隣接するバイナリとして保存する指定を示している。この資料には、属性へアクセスするまでメモリへ読み込まないことや、一覧取得時のメモリ上限についての保証はない。そのため、この指定だけで今回の問題を解消できるかを別途測定した。

比較には最小限のモデルを使い、1件100KiBの擬似乱数Dataを3000件保存した。保存プロセスを終了したあと、別プロセスで親entityを全件取得し、タイトルだけを参照した。表は3回の中央値で、MiBは1,048,576bytesである。

保存方法 取得後RSS ストアを開いた直後からのRSS増加
親entityに通常のData属性 417.95MiB 407.33MiB
親entityにexternalStorage付きData属性 741.20MiB 730.53MiB
別entityに画像、親にUUIDキーのみ 15.47MiB 4.80MiB
別entityに画像、親にrelationshipとUUIDキー 16.92MiB 6.25MiB

この入力では、externalStorageを指定した100KiBのDataもSQLite内のBLOBとして保存されていた。3000行のBLOB長は各102401bytesで、画像の外部ファイルはなかった。externalStorageのRSSが通常属性より大きくなった内部要因は調査していない。保存方法がOS・データ容量・入力内容にかかわらず同じになるとは判断せず、今回の一覧取得を改善するにはentity分離が必要だと判断した。

採用したモデルでも子entityのDataにはexternalStorageを指定している。画像の保存方法をSwiftDataに任せつつ、一覧が取得するentityから画像本体を外すためである。

実モデルによる修正前後の比較

実モデルの比較には、修正前のコミット 56e39dc320ba72b0c53208d9618464055df42e9c の MediaItem とL10n、および修正中のモデル・画像ローダーのソースを使った。環境はmacOS 27.0(26A428)、Xcode 27.0(27A266a)。Swift 6モード、最適化指定 -O でコンパイルした。

一覧の比較では、1件100KiBの擬似乱数Dataを持つ3000件を永続化し、毎回新しいプロセスでタイトル順に取得した。修正前はタイトルだけを読み、修正後は全件のタイトル・artworkID・hasArtwork を読んだ。画像本体を返すプロパティにはアクセスしていない。

測定対象 修正前 修正後
全件参照後RSS(3回中央値) 424.14MiB 22.98MiB
fetch開始から全件参照まで(3回中央値) 982ms 62ms
同プロセスで3000件取り込んだあと、親配列を保持したRSS(各1回) 279.48MiB 69.98MiB

全試行で件数3000、タイトルのバイト数合計28890が一致した。修正後は画像UUIDと画像あり判定も各3000件となった。SQLDebugの診断実行では、モデル取得のSELECTは ZMEDIAITEM のみで、ZMEDIAARTWORK のSELECTは記録されなかった。新ストアの旧画像列は全件NULL、子entityの画像件数は3000だった。

取り込み後の測定では、mainContextへ画像付きモデルを追加して100件ごとに保存し、3000件の親モデルを配列で保持した。各画像のDataは個別に確保した。この試験でも修正後のRSSは抑えられたが、実アプリのファイル読み込み・メタデータ解析・描画は含まない。

実JPEGの走査とキャッシュ上限

画像ローダーの測定では、600×600pxの擬似乱数画素をJPEGへ符号化し、1件305656bytesの画像を持つ3000件を保存した。別プロセスで一覧と画像UUIDを取得し、実際の LibraryArtworkLoader.image を使って全件を順番に読み込んだ。60pxと600pxは別プロセスで測定した。

最大デコードサイズ 成功した画像数 最終キャッシュ容量 最終保持画像数 最終RSS プロセスの最大RSS
60px 3000 33,552,000bytes 2330 58.16MiB 60.00MiB
600px 3000 33,120,000bytes 23 61.48MiB 75.20MiB

ローダーはデコード後の bytesPerRow × height を画像ごとの占有量として合計し、32MiBを超える前に最終参照が古い画像から削除する。この走査では毎回 cachedByteCount <= 32 * 1024 * 1024 を検査し、3000回すべて成功した。元画像のDataはキャッシュに保持しない。

この32MiBはローダーが保持するデコード済み画像の合計に対する上限である。読み込み中のData、ImageIOの一時領域、表示中のViewが保持する画像などを含むプロセス全体の上限ではない。RSSはOSによるメモリ圧縮やページの解放の影響も受けるため、キャッシュの計算上の容量とは分けて記録した。

再現方法と保存資料

測定時のコードと結果を、このWikiリポジトリの benchmarks/lazy-artwork-2026-10-02/ に保存した。before/ と after/ は実モデルのスナップショット、Benchmark.swift は測定用プログラム、results.jsonl は各プロセスの生結果、fetch-sql-trace.log は一覧取得時のSQL記録である。source-sha256.json には測定時のSwiftソースのハッシュを記録した。最小モデルによる保存方法の比較は design-options/ に置いた。

測定用の after/ は、最終提出コードと同一であるとは限らない。今回の一覧測定後、製品モデルでは新設した3つのOptional属性の = nil が削除され、移行処理には開始時のキャンセル検査が追加された。測定用プログラムは画像移行を呼ばず、新規ストアを作る。画像ローダーは測定時点の実装をそのまま保存した。後続の変更を評価するときは、保存済み結果を流用せずソースを再取得して比較する。

Wikiをcloneしたディレクトリで、次を実行する。ビルド生成物とデータベースをWikiへ混在させないよう、資料を一時ディレクトリへコピーしてから実行する。実モデルと保存方法比較の全ストアを作る場合、数GiBの空き容量が必要になる。

artwork_probe_dir=$(mktemp -d)
cp -R benchmarks/lazy-artwork-2026-10-02 "$artwork_probe_dir/sources"
zsh "$artwork_probe_dir/sources/run.sh" "$artwork_probe_dir/stores" \
  > "$artwork_probe_dir/results.jsonl" 2> "$artwork_probe_dir/run.log"
zsh "$artwork_probe_dir/sources/design-options/run.sh" "$artwork_probe_dir/design-stores" \
  > "$artwork_probe_dir/design-results.jsonl" 2> "$artwork_probe_dir/design-run.log"

一覧取得のSQLは、作成済みの新ストアに対して次のように確認できる。

"$artwork_probe_dir/sources/after-benchmark" fetch \
  "$artwork_probe_dir/stores/after-random" random \
  -com.apple.CoreData.SQLDebug 1 \
  > "$artwork_probe_dir/fetch-sql-trace.log" 2>&1

既存ストアを指定するとスクリプトは作成処理を省略する。同プロセス取り込み試験も既存ストアでは再実行しないため、取り込みを含む全測定には新しい一時ディレクトリを使う。

iOS起動時のフレームワーク検索パス

iOS Simulator向けビルドは成功したが、初回の起動確認では @rpath/wavpack.framework/wavpack が見つからずdyldが終了した。フレームワーク本体はアプリ内の Frameworks/ に存在し、検索パスにはmacOS用の @executable_path/../Frameworks しかなかった。mainの設定にも同じ不足があったため、Debug/Release両方にiOS用の @executable_path/Frameworks を追加した。ビルド成功だけでは起動可能であると判断できない事例として記録する。

確認済みの範囲と未測定条件

Xcode 27.0でmacOS単体テスト全355件、iOS Simulator向けビルド、SwiftLintの実行が成功した。画像読み込み中の置換・削除・キャンセル、変換中の画像変更と元項目削除も単体テストに含む。macOSの限定UIテストでは、旧形式と新形式の画像を持つ2件を用い、起動後の一覧表示、アルバムへの移動、一覧への復帰で2枚とも表示されることを確認した。上記の性能測定は、実モデルと実ローダーを使う独立プログラムで確認した結果である。

一覧測定の100KiB DataはJPEGではなく、モデルと同じレコードに保存したバイナリの取得を調べる合成入力である。JPEG走査は実際にデコード可能な画像を使うが、全件で同じ生成画像を保存している。いずれも一般的なアルバム画像の容量分布を再現する試験ではない。

実際のSwiftUI画面で3000曲をスクロールした際のフレーム時間、画面の生成・破棄と画像読み込みの競合、iPhone/iPad実機のRSSや電池消費は、この測定では確認していない。60pxの走査は小さいデコードサイズでのキャッシュ試験であり、製品の一覧表示サイズすべてを再現したものではない。ディスクキャッシュを消していないため、取得時間はコールド起動時間として扱わない。移行中断、画像編集、削除、再読み込みの正しさは、この性能測定とは別の機能検証で扱う。

iPhoneとiPadのSimulator表示確認

フレームワーク検索パスの修正後、iOS 27.0のiPhone 18 ProとiPad Pro 11-inch(M5)でアプリの起動を確認した。起動引数は --ui-testing-multiple-selection --ui-testing-media-count=2 --ui-testing-artwork とし、通常のライブラリを使わず、in-memoryストアに旧画像列の1件と新entityの1件を作成した。

iPadでは初期の「すべての曲」一覧に2行があり、両方にテスト用の青いアートワークが表示された。スクリーンショットで目視確認しており、SimulatorのAX取得はタイムアウトした。iPadでのアルバム画面への移動は確認していない。

iPhoneではライブラリのサイドバーまで表示されたが、「すべての曲」を押しても詳細の一覧へ移らず、画像2枚の表示確認には至らなかった。この操作上の問題は今回変更していない。起動成功をiPhoneの画像表示成功として扱わず、一覧とアルバム表示は未検証として残す。スクリーンショットはローカルにのみ保存し、このWikiへは掲載していない。

2026-10-03のレビュー対応:取り込みの保存と未保存画像

検証した最終ソースは 29968fa に記録した。

PR #14のレビューで、画像をworker側のModelContextから取得可能にするため、取り込んだ曲を1件ずつMainActor上で保存していた点が指摘された。Task.yield() で処理を譲っても、その直後の同期 ModelContext.save() が非同期になるわけではない。ファイル数に応じて明示的なストア保存が増えるため、取り込み終了時にまとめて保存する従来の構成へ戻した。

保存前の画像は、画面のModelContextにある insertedModelsArray から該当する MediaArtwork のDataを取得する。この参照は未保存の追加モデルだけを対象とし、保存済みのrelationshipから画像を読み込まない。取得したDataを画像ローダーのactorへ渡し、保存済み画像と同じサイズ制限、デコード処理、32MiBのキャッシュ上限を適用する。置換時はUUIDが変わるため、以前の画像と区別される。保存前後でUUIDが変わらない画像は、同じサイズのキャッシュを再利用できる。

mainへのrebaseでは、iPhoneの新しい曲一覧と、書き出し元のメタデータを固定するスナップショットが追加されていた。iPhoneの一覧行も非同期画像表示へそろえ、書き出しではメタデータと歌詞を画像読み込み前に固定し、読み込み結果を同じスナップショットへ設定する。画像読み込みも書き出しのエラー処理内に置き、読み込み中のキャンセル、削除、失敗でレンダリングを開始しない。

回帰確認には、次の処理条件を使う。

  • MediaImportDeduplicationTests.artworkImportsPersistTogetherWithOneBatchSave:異なるPCMを持つ画像付きWAVを3件、永続ストアへ取り込む。autosaveを無効にしてwillSaveとdidSaveの通知を数え、各1回であること、別のModelContextから3件の画像を復元・デコードできることを確認する。
  • LibraryArtworkLoaderTests.pendingImagesDecodeBeforeBatchSaveAndReuseTheCacheAfterSave:保存前の追加と置換を画面用の読み込み経路でデコードし、workerの読み込みが発生しないことを確認する。保存後の同サイズ要求はキャッシュを使い、別サイズ要求ではMainActor外のreaderが呼ばれることを確認する。このテストのreaderは注入したDataを返し、実ストアからの画像の復元は取り込みテストで確認する。
  • TransformedExportArtworkReadTests:画像読み込みをセマフォで保留した状態で、元項目のメタデータと歌詞を編集し、書き出しファイルと登録モデルが開始時の値を持つことを確認する。キャンセル、元項目削除、読み込み失敗ではレンダリングと登録が行われないことも確認する。
  • LibraryArtworkUITests:旧列から移行した画像と新しい画像entityの2件を使用する。macOSは一覧とアルバム間の往復、iPhoneはライブラリと曲一覧間の往復、iPadは初期の曲一覧で画像2枚を確認する。

大量ファイルを取り込む間のフレーム時間、終了時のバッチ保存によるUI停止時間、未保存の画像Dataを保持するピークRSSは、この修正では測定していない。バッチ保存もMainActor上の同期処理であり、今回確認する保存回数の削減だけから、すべての取り込み規模で応答性を保証するとは判断しない。

保存回数のテストは修正前の実装でも実行した。3件の取り込みで willSave が4回、実際に変更を確定した didSave が3回となり、各1回を期待するテストが失敗した。修正後は両方とも1回で成功した。最後の変更なし保存でも willSave は通知されるため、呼び出し回数と変更の確定回数を分けて数えている。

Xcode 27.0でmacOS単体テスト376件が成功した。新しい書き出しテストではTaskの結果にUUIDまたはVoidを使い、SwiftDataモデルをTask間の戻り値として受け渡さない。修正後のこのスイートも再実行して成功した。macOSとiOS Simulator向けビルド、macOSの画像付き一覧とアルバムの往復、iPhone 18 Pro(iOS 27)のライブラリと曲一覧の往復、iPad Pro 11-inch(M5、iOS 27)の曲一覧表示も成功し、各画面で画像2枚を確認した。SwiftLintは45 warnings、0 errorsで、新しい違反は追加していない。ベースラインの項目数108を維持し、既存の長さ診断と移動したテスト用rendererの保存先だけを更新した。

iPadの最初のUIテストでは、画像の識別子を全種類のAX要素から数えたため、画像2枚に加えて親の行ボタン2件も該当し、期待件数の判定が失敗した。スクリーンショットとAX階層で画像の表示を確認し、iOSでは app.images で画像だけを数えるよう修正した。macOSのアルバム画面では画像がボタンと一体のAX要素になるため、従来の全種類を対象とする検索を維持する。表示列もiOSの起動引数で固定し、Simulatorに残る列設定に依存しないようにした。iOSのアルバム画面への移動と実機での性能は引き続き未検証である。

2026-10-03の追加レビュー:情報表示のキャンセルと例外経路

検証した最終ソースは e118145 に記録した。

情報表示用の editableMetadataDraft(for:) は、画像取得の CancellationError を try? でnilへ変換し、元ファイルのメタデータ解析を続けていた。項目の切り替えや画面を閉じる操作では呼び出し元のTaskがキャンセルされる。画像の置換や項目削除では、Taskがキャンセルされていなくても、画像取得時の項目・ModelContext・画像UUIDの組み合わせが無効になり、同じ CancellationError が発生する。両方を呼び出し元へ伝えるため、draft取得を async throws に変更した。通常の画像読み込みエラーに対して元ファイルから読み込む従来の動作は維持する。

追加調査では、画像ローダーが失敗した場合に項目と画像UUIDを再確認していなかったことも分かった。待機中に画像レコードが置換・削除されると、workerが旧レコードを取得できず、通常のI/Oエラーを返す場合がある。成功時と例外時の両方で要求の有効性を確認し、無効な要求とキャンセル済みのTaskは CancellationError として扱う。現在の項目・画像に対する通常エラーはそのまま保持する。

情報パネルはキャンセルされた結果を適用せず終了する。情報編集画面は、同じ読み込み要求が有効で、画像UUIDだけが変わった場合に新しい世代を読み直す。Taskのキャンセルや項目削除では終了する。一括編集はdraft取得を try await し、無効になった画像要求のdraftを使ってファイルやモデルを書き換えない。元ファイルの解析経路にも段階間のTaskキャンセル確認を追加した。長いdraft処理を補助メソッドへ分け、メタデータの優先順位は維持している。

回帰確認には次の条件を使う。

  • EditableMetadataDraftCancellationTests:保存済み画像のreaderをセマフォで保留し、Taskのキャンセル、画像置換、項目削除を行ってから、Dataまたは通常のI/Oエラーを返す。キャンセルを呼び出し元へ伝え、後続のMP4画像処理カウンターが0であることを確認する。開始前からキャンセル済みの要求も検証する。カウンターはMP4画像処理の開始を観測するもので、すべてのファイルI/Oを個別に記録するものではない。
  • 同じスイートの一括編集:画像取得中のキャンセル・置換・項目削除と、取得成功・通常エラーを組み合わせた6ケースで、更新件数0、元ファイルのバイト列が変わらないことを確認する。キャンセル・置換では別のModelContextから取得したタイトルが変わらず、削除では保存済みの項目が0件であることも確認する。失敗情報には元のファイル名が保持される。
  • 通常エラーのfallback:画像readerが通常エラーを返しても、有効な画像付きWAVからタイトル・アーティスト・アルバム・歌詞・画像を取得できることを確認する。
  • LibraryArtworkReadInvalidationTests:workerが待機後に fileReadNoSuchFile を返す条件で、Taskキャンセル・画像置換・項目削除を CancellationError として扱うこと、現行要求の通常エラーは保持することを確認する。

修正前の2スイートでは17件の期待条件違反を再現した。キャンセル時にdraftを返し、後続のMP4画像処理が始まるケースと、無効な画像要求で通常のI/Oエラーを返すケースが確認できた。修正後は、一括編集の6ケースを追加した計18ケースが成功した。

最終確認ではXcode 27.0でmacOS単体テスト383件、macOSとiOS Simulator向けビルドが成功した。iPhone 18 Pro(iOS 27)では、最大アクセシビリティ文字サイズで英語の情報画面を表示する既存UIテストが成功した。書き出しのキャンセルテストに残っていたSwiftDataモデルのTask間の受け渡しもUUIDまたはVoidへ変更し、同じ単体テスト実行で確認した。SwiftLintは43 warnings、0 errorsとなった。baselineは既存の長さ診断のみ更新し、項目数108を維持している。

既に実行中の同期readerやメタデータ解析を、その途中で必ず中断できることは確認していない。読み込みの完了・失敗後にキャンセルを伝え、後続の解析やdraftの適用を止める条件を検証している。ファイルへの書き込み開始後のキャンセル・削除への対応は、この修正の検証範囲に含めていない。

Clone this wiki locally