Repository navigation
Bulk Metadata Edit Performance
Issue: #65(元は #27)、PR: #56、#63。確認日: 2026-10-11(JST)。
- 修正前は #63 のマージ直前の c77a105、修正後は main の 88f559c。修正後には #64・#67・#68・#69 も含まれる。一括編集の経路に関係するのは #64(ラウドネスのキャッシュの引き継ぎ)と #68(代替値の読み込み)である。
- 曲は 500 曲で、内訳は MP3 167・M4A 167・FLAC 166。どれも 30 秒、44.1 kHz ステレオのサイン波で、タイトル・アーティスト・アルバム・コメントを付けた。
- MP3 は lame 3.100、ID3v2.3、余白なし。
- M4A は ffmpeg の AAC で、
moovが先頭にある。 - FLAC は flac 1.5.0 で作り、8 KiB の PADDING がある。
- 書き込みの経路は前後で同じだった。MP3 と M4A は全体の書き直し、FLAC はその場での上書きになった。
- 入口は、
BulkMetadataEditView.save()が呼ぶLibraryService.updateEmbeddedMetadata(for:patch:in:progress:)。パッチはfields: [.comment]。画面と同じく、MainActor の Task から進捗のクロージャ付きで呼んだ。 - ストアは、アプリと同じスキーマのディスク上の SQLite。曲の登録は
BulkMetadataPatchTestsと同じ方法で、ブックマークの代わりにパスを使った。 - 時間は、Debug と Release で各 3 回の中央値。毎回、曲のコピーから始めた。
- 読み込み回数と
context.save()の回数は、コミットしない一時的なカウンターで数えた。context.save()の回数はModelContext.didSaveの通知の数とも一致した。
| 版 | 構成 | 所要時間 | context.save() |
1 回あたり | 保存の合計(割合) |
|---|---|---|---|---|---|
| 修正前 | Debug | 2.217 秒 | 500 回 | 0.60 ms | 0.340 秒(15%) |
| 修正後 | Debug | 1.484 秒 | 500 回 | 0.56 ms | 0.317 秒(21%) |
| 修正前 | Release | 2.106 秒 | 500 回 | 0.58 ms | 0.336 秒(16%) |
| 修正後 | Release | 1.470 秒 | 500 回 | 0.56 ms | 0.323 秒(22%) |
曲あたりのファイル読み込み回数は次のとおり。
| 読み込み | 修正前 | 修正後 |
|---|---|---|
AVAsset の全メタデータ(allMetadata) |
1 | 0 |
| 形式ごとのリーダー(ID3/MP4/Xiph) | 1 | 0 |
FLAC の先頭の読み込み(canWrite) |
2 | 1(編集可否の確認だけ) |
| ライターが書き込み前に既存タグを読む | 1 | 1 |
下位の MediaFileRewriter.read
|
平均 10.3 | 平均 7.7 |
すべての実行で、更新 500 件・失敗 0 件だった。500 ファイルとも新しいコメントを読み戻せた。
アーティストやアルバムが「Unknown Artist」「Unknown Album」になっている曲では、修正後も itemDraftResolvingPlaceholders が editableMetadataDraft を呼ぶ。このため、読み込み回数と時間は修正前と同じになる(Release で 2.089 秒 → 2.157 秒)。
修正後の Release で、テストから差し込んだ保存処理によって、N 曲ごとにだけ保存した場合を試算した。
| 保存の間隔 | 回数 | 保存の合計 | 所要時間 |
|---|---|---|---|
| 1 曲ごと(現行) | 500 | 0.323 秒 | 1.470 秒 |
| 10 曲ごと | 50 | 0.109 秒 | 1.245 秒 |
| 50 曲ごと | 10 | 0.069 秒 | 1.060 秒 |
| 最後に 1 回 | 1 | 0.060 秒 | 1.053 秒 |
- 3 つの間隔は同じプロセスで続けて測ったため、2 つ目以降は温まった状態の値である。
- 曲ごとの保存のコストは、1 曲あたり約 0.6 ms、500 曲で約 0.3 秒だった。まとめた場合に縮む時間は最大で約 0.4 秒である。
- 判断: 曲ごとの保存(#56)を続ける。中断やクラッシュでストアとファイルの内容がずれない利点に比べ、まとめて得られる短縮は小さい。
- ただし、この計測には、実際のアプリで保存のたびに起きる
@Queryと一覧の更新の負荷が含まれていない。画面の応答に問題が見つかった場合は、次の方針でまとめる。- 10〜50 曲ごとに保存し、キャンセル時と終了時には必ず保存する。
- 保存に失敗しても
ModelContext.rollback()は使わない。mainContext の、そのバッチ以外の未保存の変更まで捨てるためである。メモリ上の変更を残し、次の区切りで保存し直す。
| プラットフォーム | テスト | 結果 |
|---|---|---|
| macOS |
BulkMetadataEditabilityUITests、SimpleMediaPlayerUITests/testDraggingAcrossRowsSelectsTheEntireRangeForEditing
|
成功 |
| iPhone 18 Pro(iOS 27.0 Simulator) |
BulkMetadataEditabilityUITests、IPhoneLibraryWorkflowUITests/testSettingsAndBulkEditing
|
成功 |
| iPad Pro 11-inch (M5)(iOS 27.0 Simulator) | BulkMetadataEditabilityUITests |
失敗(3 回とも) |
-
iPad の失敗: 110 行目の
waitForValue("2", of: editSelectedMediaButton)がタイムアウトする。iPad のツールバーのボタンには、accessibilityValueが公開されていない。 -
macOS の UI テストの実行: 自動化モードを有効にするとき、利用者の認証を求められる。認証できない状態では「Timed out while enabling automation mode」で失敗する。署名は ad-hoc(
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO)にする。 -
iOS Simulator のビルド: 通常のスキームで build-for-testing すると、単体テストのターゲットが型検査の時間切れでコンパイルできない(
VideoSpectrumAnalysisTests.swift:92、VisualizationSuppressionTests.swift:127)。今回は、別の worktree でスキームから単体テストのターゲットを外してビルドした。 -
進捗バーとキャンセル: 保存中の進捗バー(
bulkEditProgress)とキャンセルボタン(bulkEditStopButton)を扱う UI テストはない。
- 実際のアプリで保存のたびに起きる画面の更新のコストと、画面の応答性
- セキュリティスコープ付きブックマークの解決コスト(修正前は曲ごとに 2 回、修正後は 1 回)
- 余白のある MP3、
moovが末尾にある M4A、キャッシュが冷えた状態、キャンセル時 - Music・Finder・ほかのタグエディターでの読み込み
確認日: 2026-10-11(JST)。対象は #74。前掲の「代替値を表示している曲」は 88f559c 時点の測定であり、この追加修正の前の結果として残す。
未編集の曲に Unknown Artist または Unknown Album が表示されている場合、一括編集は、その文字列が原本のタグにもあるかを editableMetadataDraft で確かめていた。コメントだけの更新でも、曲ごとに AVAsset の全メタデータと形式リーダーを読み、不要な文字列・画像・歌詞を扱っていた。
追加修正では、変更対象に含まれない artist/album の代替値だけを既存の形式リーダーで読む。textFields を指定した読取では、関係のないタグ値と画像・歌詞の復号を省く。ブックマーク解決と同期読取は BlockingWorkExecutor 上で実行する。原本に文字どおりの代替値があれば保持し、真の空値と保存済みの空値は復元しない。今回変更する項目も原本から補わない。永続モデルは変えていない。
| 形式・条件 | 読取と優先順位 |
|---|---|
| MP3 | 指定した ID3v2 の textFields を読む。未解決の項目があり、末尾に ID3v1 の TAG がある場合だけ commonMetadata を補完に使い、ID3v2 を優先する。通常のタグなし MP3 では AVAsset に戻らない。 |
| M4A/MP4/M4V/MOV の iTunes タグ | 指定項目と対応する sort fallback を読む。通常のタグなしファイルでは AVAsset に戻らない。 |
| QuickTime keyed metadata/user-data |
keys や udta の文字タグをボックスヘッダで検出した場合、commonMetadata の artist/album を読む。native reader で得られた値を優先する。 |
| FLAC/WAV/Ogg/Opus | 指定項目を既存リーダーで読み、画像・歌詞の抽出を省く。 |
| AIFF・その他の AVFoundation 対応形式 | commonMetadata の artist/album を読む。 |
| WMA/WavPack/Monkey’s Audio/Musepack | ExtendedAudioSource の元情報を使う。この経路は今回の高速化対象外。 |
| native reader の失敗 | commonMetadata の artist/album に限って補完する。AVAsset の全 metadata formats を走査しない。 |
読み終わった時点で、要求時の項目 ID・ブックマーク・ファイル名と現在の削除状態を照合する。キャンセル後やファイルを差し替えた後の結果は適用しない。読取中に編集された項目は現在のモデル値から作った draft に従う。一括更新と加工コピーの書き出しが同じ判定を使う。
ID3v1 だけに literal artist/album がある入力では、AVFoundation が両方を読む。一方、ID3v2 に artist があり ID3v1 に album がある入力では、macOS と iOS の従来 draft も album が空だった。AVFoundation はこの入力の ID3v1 album を合成していない。新経路も従来の値と一致させる。
QuickTime keyed metadata は .m4a の拡張子でも有効な入力となる。AVAssetExportSession が作った mdta/com.apple.quicktime.artist と mdta/com.apple.quicktime.album を持つ 4,438 bytes の固定 fixture では、従来 draft が literal 値を読んだ。新経路の最初の実装は、meta が ISO full box の version/flags を持つと仮定して keys を見落とした。この退行を避けるため、version/flags がない legacy QuickTime の配置も同じ境界チェックで検出する。通常の無タグ AAC と iTunes タグ付き AAC は fallback 不要であることを parser probe で確認した。
ImportOverridePlaceholderTests に、MP3/M4A/FLAC/WAV/Ogg/Opus/AIFF/M4V/MOV の原本が literal 値・無タグ・保存済み空値の3条件、変更対象の除外、限定読取で不要な値が含まれないことを追加した。ブックマーク解決を意図的に停止するテストでは、MainActor から操作できることと、ID/ブックマーク/ファイル名の変更・削除・キャンセル後の適用防止を扱う。読取中の artist や保存済み編集の変更も対象とする。
修正後の iPhone 17e(iOS 27.0 Simulator/Xcode 27.1 RC)全単体テストは797件・130 suite が54.632秒で成功した。QuickTime と ID3v1/v2 の修正後ケースを含む。macOS の最終全単体804件・129 suite も39.156秒ですべて成功した。証跡は .build/IssuesFinalIPhoneUnits.xcresult と /private/tmp/smp-issues-ui-validation/final-iphone-units.log。Release 500 曲比較は次項に記録する。実画面の @Query 更新、外部ボリューム、冷えたファイルシステムキャッシュはサービス経路の測定には含めない。単項目の書込処理には既存の MainActor 上の同期ブックマーク解決が残るため、一括編集全体の I/O が MainActor 外にあるとは扱わない。
前後3回ずつの実行では、各回とも Swift Testing の対象1 test・1 suite が成功した。未選択で0件成功した最初の3ログは測定に含めず not-selected-before-*.log に保存した。再現用スクリプトも1 test/1 suiteと新規JSON1件を必須として、0件成功を防ぐ。
| 版 | 1回目 | 2回目 | 3回目 | 中央値 |
|---|---|---|---|---|
| 修正前 | 2.522520667 秒 | 2.122585875 秒 | 2.152280250 秒 | 2.152280250 秒 |
| 修正後 | 1.398556958 秒 | 1.397510958 秒 | 1.465908792 秒 | 1.398556958 秒 |
中央値は2.152280250秒から1.398556958秒になり、35.02%短縮した。6回とも更新500件・失敗0件・未処理0件で、500ファイルの新コメントと別 ModelContext からの永続化500件を確認した。context.save() と didSave 通知は各回500件。進捗クロージャと曲ごとの保存を含む。
| 読取 | 修正前(各回) | 修正後(各回) |
|---|---|---|
| AVAsset の全metadata(allMetadata) | 500 | 0 |
| 限定 commonMetadata fallback | 対象カウンターなし | 0 |
| ID3 reader | 167 | 167 |
| MP4 reader | 167 | 167 |
| FLAC reader | 166 | 166 |
修正後も原本の代替値を判定する形式リーダーは残る。短縮したのは全metadataと不要な値を読む経路であり、原本読取をなくした結果ではない。曲ごとの保存時間の合計は、修正前0.388528086/0.330664999/0.345315954秒、修正後0.303131141/0.306848666/0.316056503秒だった。
実行時はほかのbuild/test/エンコード/UI操作を停止した専用枠を使った。修正前3回の後に修正後3回を実行した。元ファイルのキャッシュを強制破棄する操作は行っていないため、冷えたキャッシュの比較ではない。
基準 HEAD は 88f559c81a294505c6a5663beb551ad3a3ab58a1。実行 source は作業中snapshotで、results/source-sha256-before.json と source-sha256-after.json に各ソース・project・resourceのSHA-256、binary-sha256.json に実行binaryを保存した。前後のソース差は LibraryService+StoredMetadata.swift とハーネスの版名だけ。計数箇所は repro/instrumentation.patch に残した。
after StoredMetadata の SHA-256(計数hookを含む): 34d0d4c33f9e034e2f3059913af27890e8d28b8b1a3e4dc546e2b584a4b4bb9d。
再現資材は repro/generate.py/gen-one.sh、prepare.py、Issue65BulkBenchmarkTests.swift、benchmark.sh、run_compiled.py、summarize.py。ビルド済みsnapshotの測定は python3 repro/run_compiled.py before ROOT PACKAGES と after、照合は python3 repro/summarize.py ROOT。toolのパスとroot/repository/package引数は repro/README.md に記載した。今回の各回JSONと集計は results/*-Release-placeholders-*.json と summary.json。
今回の再現スクリプトと数値JSON・ソースhash・実行ログは benchmarks の README と 集計JSON に保存した。
測定対象の LibraryService と ModelContainer は専用temp領域を使用した。一方、この実行のアプリhostには通常の App.init が残り、起動時の通常Serviceのpending removal再開/temporary cleanup/永続container初期化を分離していなかった。したがって全プロセスがユーザーデータへアクセスしない構成だったとは主張しない。ここに保存したsource snapshot/hashは、実際に測定した版の記録として変更しない。後続のnative互換fixture hostでは、専用temp Service、inMemory host container、専用bundle IDへ分離した。測定したAPIのtemp fixture/storeと起動hostの設定を区別する。
#74のMP3入力には、encoding=1のCOMMで空descriptionが00 00、本文がFF FEから始まるframeが残っていた。後続のnative互換確認でffmpegがIncorrect BOM valueを表示した。ID3v2.3 §3.3は空のNULL終端Unicode文字列のBOMをmayとしているため、元タグ全体を不正形式とは断定しない。#74の比較は同一入力条件であり、既存の各回値・中央値を維持する。native互換確認では今回専用seedに明示Latin-1 frameを用意し、UTF-16 patch後との比較を別に記録する。
macOS DebugとiPhone/iPad iOS27.0 Simulatorで、一括編集の進捗とキャンセル、編集画面を閉じた後の別選択への再表示を確認した。検証専用のasync待機を2件目の書込前へ入れ、3項目中1件を更新した状態で中止した。ファイルごとの結果適用guardと実形式writerは製品処理を使い、待機はMainActorを同期停止しない。原本タグとmodelを読み戻し、1件更新/2件未処理と一致することを確認した。
iPhoneの関連2 UI caseは105.281秒、iPadは97.453秒で成功した。macOSは閉じる→別選択36.958秒、保存中止44.098秒で成功した。macOS中止testでは画面外のCancelへ届かない合成clickとlabel/valueの取得差を補正し、原本3件とmodel3件を正確一致で確認した。製品の保存処理は追加変更していない。各test全体の秒数は操作遅延や保存性能の指標に使わない。
日英の件数labelとmacOS UI testの原因・最終確認 に関連4操作、iPad最終2→1追随、採用外の失敗と公開範囲をまとめた。外部アプリでの保存後の変更・未変更項目は Mp3tag/Finder/Music互換性 に記録した。