綴Photo の勉強ノートに戻る
開発記録2026.05.15

月ごと12回スキャン → 年1回に集約した話 — 性能改善の典型パターン

パフォーマンスDartphoto_manager綴Photo

綴Photo で、「写真なし月を隠す」というボタンがあります。1年分の写真をスキャンして、その月に写真がない月を一覧から消す機能。

これがそこそこ遅かった。実装は AI に書いてもらっているので、レビューで上がってきたコードを後から眺めていて「OS への問い合わせを12回やってる」のに気づいた。直してみたら、構造としては 性能改善の典型パターン に当てはまる作業だった。記録しておく。


旧実装: 月ごとに12回スキャン

元のコードはこんな感じだった。

dart
for (var m = 1; m <= 12; m++) {
  final hasPhotos = await hasAnyPhotosInYearMonth(year: year, month: m);
  if (!hasPhotos) {
    result.add((year, m));
  }
}

1月、2月、3月…と12回ループして、各月ごとに「この月に写真ある?」を判定。素直で読みやすい。

問題は hasAnyPhotosInYearMonth の中身。この関数は呼ばれるたびに「OS から写真アルバムを取得して、新しい順にめくって、対象月の写真を探す」という流れ。

中身を雑に書くとこう:

dart
Future<bool> hasAnyPhotosInYearMonth(int year, int month) async {
  final album = await PhotoManager.getAssetPathList(...);  // ← ここが重い
  // album から指定月の写真を探す
}

PhotoManager.getAssetPathList という、OS とのやりとりが必要な重い処理 が毎回走ります。これを12回繰り返してた。


なぜ遅いか

OS への問い合わせ(写真ライブラリへのアクセス)は、コードの中の他の処理と比べて 桁が違うレベルで遅いです。配列をループする処理が数マイクロ秒で終わるところを、OS への問い合わせは数十〜数百ミリ秒かかります。

それを12回繰り返すと、ボタン1個押しただけで秒単位の待ち時間が生まれます。写真の枚数が多い端末ほど顕著になります。


新実装: 年単位で1回スキャン

直したコード。

dart
final photoMonths = await scanPhotoMonthsInYear(year);  // 年単位で1回だけ
 
for (var m = 1; m <= 12; m++) {
  if (!photoMonths.contains(m)) {
    result.add((year, m));
  }
}

scanPhotoMonthsInYear(year) の中身は、OS への問い合わせを1回だけ にして、その中で「何月に写真があるか」を集めます。

dart
Future<Set<int>> scanPhotoMonthsInYear(int year) async {
  final album = await PhotoManager.getAssetPathList(...);  // ← 1回だけ
  final months = <int>{};
 
  // album から年内の写真を順番にめくって、撮影月を集める
  for (final asset in assets) {
    months.add(asset.createDateTime.month);
  }
 
  return months;  // {1, 3, 5, ...} みたいな「写真がある月の集合」
}

構造の入れ替えがしてること

写真は端末の写真ライブラリ。月の集合はメモリにあり、保存しない。旧: OS へ 12 回端末の写真ライブラリOS。アプリの DB ではない1〜12月毎月アルバムを取り直す新: OS へ 1 回端末の写真ライブラリOS。アプリの DB ではないメモリの月集合{1, 3, 5, ...} 保存しない1〜12月判定は集合を見るだけ

これは性能改善の典型パターンで、特に「ネットワーク」「ファイルIO」「OS API」のように コードの外との通信が伴う重い処理 で効きます。

「重い処理を減らすために、軽い処理(メモリ上の計算)でカバーする」という発想。プログラミングをやってると、いろんな場面でこの構造に出会います。


おまけ: EXIF 欠落への耐性も入れた

ついでに副次的な改善も入れた。エミュレーターで動作確認してた時に気づいた罠で、

  • 写真の撮影日時(EXIF)が欠落していると、createDateTime が 1970-01-01 にフォールバックします
  • これを「日付が分かる写真」として扱うと、月の集計や最古判定が壊れます

なので、新しい scanPhotoMonthsInYear には「1990年より前の日付は信用しない」という閾値を入れた。エミュレーターの写真は全部 EXIF なしで、これがないと判定が全滅します。

実機なら大半の写真に EXIF があるので、ほぼ起きない問題。でも「1枚でも EXIF 欠落写真が混ざってると判定が汚れる」リスクは現実にあるので、保険として入れておく。


学んだこと

  • 「ボタン1個押すと裏で OS に12回問い合わせてた」みたいなのはよくあります。素直に書くとそうなります。性能を測ってから「これ多すぎない?」と気づいて初めて見えます
  • 構造の入れ替えで桁レベルで速くなります。「12回」と「1回」の差は、コードの行数では小さいけど、体感は桁違い
  • 「重い処理は外、軽い処理は中」が原則。OS / ネットワーク / DB はコードの「外」で、これらの呼び出し回数を意識すると性能の天井が変わります
  • 副次的な耐性追加はついでにやります。同じ関数に手を入れるなら、EXIF 欠落みたいな「気になってた罠」も一緒に塞いでおきます

おわりに

性能改善は「測ってから直す」が鉄則だけど、今回は「コード見て構造として変だな」で気づいた。OS への問い合わせを12回繰り返してたら、それは構造として赤信号。

「重い処理の回数」を意識するのは、コードの 横の長さ(行数) じゃなくて 縦の深さ(外との通信の頻度) を見る視点。性能を見るときはここを数えるようにしています。

この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。