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

カメラロール 5000 枚でも速い写真スキャンを Flutter で作った話

Flutterphoto_managerパフォーマンス綴Photo

綴Photo では、 端末のカメラロールから「まだ綴っていない写真」を集めてユーザーに見せます。 最初の素直な版で 5000 枚のカメラロールを舐めようとしたら、 普通にアプリが固まった。 そこから「全部読む」をやめて「必要なところで止める」に直していった話だ。


なぜスキャンが要るのか

綴Photo は、ユーザーがアプリ内に写真を取り込む形ではなく、 端末の写真ライブラリ(カメラロール)を直接見にいく作りです。

これは設計判断としてかなり意識的に選んだ。

  • 写真を二重保存したくない(容量がもったいない)
  • 取り込みのひと手間を省きたい(続けるためのハードルを下げたい)
  • 既存の写真資産が、アプリを開いた瞬間にそのまま使える状態にしたい

代わりに、「まだ綴られていない写真はどれか」をアプリ側で計算する必要が出てきます。 ここで「写真ライブラリのスキャン」が登場します。


詰まったところ:全部舐めるとアプリが固まる

最初は素直な作りだった。

1. 端末の全写真を取得する
2. その中から、すでに綴った写真の ID を除外する
3. 残ったものを表示用にソートする

ロジックは合っているし、テスト用の少数写真だと一瞬で動きます。

ところが、自分の端末(写真 5000 枚以上)で動かしたら、 アプリのスプラッシュ後がほぼ凍った。

写真ライブラリへのアクセスは、ファイル名や日時のメタデータだけ取るのも含めて、 意外とコストが高かった。 端末によっては「サムネイル生成」が裏で走って、それで詰まることもあります。

「全部舐めるのは無理」という前提で、設計を組み直した。


解決1:新しい順にスキャンして、cutoff より古くなったら即終了

最初に入れた最適化はこれだった。

ユーザーが綴る写真は、ほとんどの場合「最近の写真」です。 3年前の旅行を毎日掘り起こす人はそんなにいません(綴Photo はそれを否定しませんけど、ベースは「日常を綴る」設計です)。

写真の実体はカメラロール。候補リストはメモリにあり、保存しない。新しい古い今日採る昨日採る綴済み飛ばす先週採るcutoff(N日前)1年前読まない3年前読まない綴済みは1枚飛ばす(continue)。cutoff より古い側は読まない(break)。読む範囲は cutoff より新しい側だけ。古い側はカメラロールに置いたまま触らない。

cutoff という変数を持って、ループの中でこういう判定を入れています。

dart
for (final asset in assetsNewestFirst) {
  if (asset.createdAt.isBefore(cutoff)) {
    break; // ここで打ち切る
  }
  if (alreadyTsuzuredIds.contains(asset.id)) continue;
  candidates.add(asset);
}

このループ、重要なのは break の方で、continue じゃありません。 **「これより古いやつは全部スキップでいい」**と分かっている前提なので、 そこで読み込み自体を止められます。

これだけで、5000 枚舐めていたのが、最近の数百枚だけで済むようになった。 体感がぐんと速くなった。


解決2:ページングで一度に取りすぎない

もう1つ入れたのが、ページング(分割取得)だ。

photo_manager のような写真アクセスのライブラリは、 一度に「全件」を取らずに「N 件ずつ」取る API を持っていることが多いです。

最初は雑に「最大 1000 件」みたいな指定で取っていたんだけど、 それでも端末によっては最初の 1000 件取得で数秒固まります。

取ったページはメモリ。実体はカメラロール。保存しない。ページ 1200 件ここから使い始めるページ 2200 件まだ新しい側ページ 3cutoff を踏むここで取得終了ページ 4 以降取らないカメラロールに残す1ページごとに cutoff を見る。古い側のページは要求しない。分割したページの中身はメモリにしか無い。次のスキャンでは作り直す。

この変更で、「ユーザーが画面を開いて最初の写真が表示されるまで」の時間が ぐっと短くなった。

裏では追加スキャンが続いているけど、ユーザーから見ると最初の体験は速いです。 これは個人アプリの世界では結構大事だと思っている。


「すでに綴った写真」をどう持つか

ループの中の alreadyTsuzuredIds.contains(...) は、 「もう綴った写真かどうか」の判定です。

ここを毎回 DB に SQL で問い合わせていたら遅いです。

実体と名簿と、スキャン中の写しは別の場所にある。カメラロール端末の写真ライブラリ実体はここアプリにコピーしないページでメタデータを読む端末 SQLitephotos.asset_id綴り済み ID の正閉じても残る1件ごとの問い合わせ先ではない開始時に1回スキャン中のメモリalreadyTsuzuredIdsSet<String> の写しループの contains はここ保存しない・常駐しないスキャンのたびに SQLite から集合を作り直す。1件ごとに DB は叩かない。候補リストもメモリ。綴ったら書くのは SQLite 側で、この集合へは戻さない。

これで、ループの中の判定は Set.contains で O(1) になります。

いまはスキャンを呼ぶたびに、この Set を DB から作り直すシンプルな作りにしています。 起動時に1回だけ作ってメモリに常駐させ、綴ったら差分で足していく……という持ち方も考えられるけど、 そうすると「DB とメモリ上の状態をズレないように同期し続ける」責任を別途抱えることになります。 そこはまだ手をつけていなくて、改善余地があるなと思っている部分でもある。



この記事で書かなかった話

  • photo_manager のページング API の具体名
  • iOS と Android で挙動が違う部分
  • バックグラウンドでの追加スキャン戦略

このあたりは実装の話に深く入るので、別記事に分けたい。


学んだこと

  • 「全部舐める」発想を捨てます。「必要なところで止める」に切り替える
  • 新しい順スキャン + cutoff という発想は、いろんな場面で応用が効く
  • ユーザーに見える時間(最初の数秒)を最優先で速くする
  • メモリ上の Set と DB の状態を同期させる設計は、最初からちゃんと考える

5000 枚というのは、自分にとっては「ふつう」の枚数だった。 でも最初に書いてもらった版は、その「ふつう」を相手にできなかった。

テスト用の少数データで動いても、実機の「ふつうの量」で詰まったら意味がありません。 実装を AI に任せていても、実機の「ふつう」で試して詰まりを見つけるのは自分の仕事だ。 個人開発でも、自分の実機で試すフェーズはサボらないようにしたい。


この記事は2026年4月時点の自分の理解と実装方針で書いています。アプリは継続開発中なので、現在の実装と細部が違っている可能性があります。