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

非同期にしたのに画面が固まる — Dart のアイソレートと、待ち時間をどこに置くか

FlutterDartIsolate非同期パフォーマンスUX綴Photo

綴Photo のデスクトップ版には、写真フォルダを選ぶと中の画像を1枚ずつ「綴る」画面に出していくスキャン機能がある。手元の数十枚〜数百枚のフォルダでは快適に動いていたのだが、6000 枚入ったフォルダで試したら、スキャン中の「読み込み中」画面が——アニメーションごと——完全に固まった。しばらく待つと最後まで読み込まれて綴る画面に切り替わる。つまり処理は進んでいるのに、UI が一切描画されない。

これは async/await への誤解が招いた、わりと典型的な罠だった。ついでに「待ち時間をどこに置くか」という UX の判断もセットで考えることになったので、両方まとめておく。

この記事で扱う観察ポイント

  • async/await は「別スレッドで動く」という意味ではありません(Dart のアイソレート)
  • CPU 仕事を await で包んでも、メインアイソレートが詰まれば UI は固まります
  • Isolate.run で重い処理をバックグラウンドに逃がします
  • 避けられない待ち時間は「待つのが自然な場所」に前倒しする(UX の判断)

症状: ローディングのアニメすら止まる

スキャンは2フェーズ構成になっています。

  1. フェーズ1: フォルダを再帰的に走査してファイル一覧を集め、各ファイルの stat()(更新日時など)を取ります。撮影日時順に並べるための軽い処理。
  2. フェーズ2: 並べた候補を1枚ずつ開いて、画像の解像度(幅・高さ)を読みます。

数百枚までは一瞬で終わる。ところが 6000 枚だと、フェーズ1(一覧集め)は問題なく進むのに、フェーズ2 に入った瞬間、ローディング画面が固まる。スピナーも止まる。「処理中」を示すアニメーションが動かないので、ユーザーから見れば「アプリが死んだ」ように見える。実際には裏で解像度を読み続けていて、全部読み終われば復帰する。

最初の誤診: 「setState の連打だろう」

最初に疑ったのは UI の更新コストだった。フェーズ2 では1枚読むたびに状態を更新していて、こうなっていた。

dart
case UnprocessedScanPhotoFound(:final photo):
  setState(() {
    sessionPhotos = [...sessionPhotos, photo]; // 毎回リスト全体をコピー
  });

[...sessionPhotos, photo] は毎回リストを丸ごと作り直すので、6000 枚なら合計でおよそ 1800 万要素ぶんのコピーになります(O(n²))。しかも1枚ごとに setState でリビルドが走ります。「これが UI スレッドを圧迫しているんだろう」と考えて、ローカルのリストに溜めて完了後に一括反映する形に直してもらった。

dart
final scanned = <UnprocessedPhoto>[];
// ... ループ内では scanned.add(photo) するだけ。setState は打たない
// ループを抜けてから一度だけ setState で反映

これでリビルド連打と O(n²) コピーは消えた。が、固まりは直らなかった。 ここで「犯人は別にいる」と分かった。

本当の原因: await してても仕事はメインアイソレートでやっていた

解像度を読む関数はこうなっていた。

dart
Future<(int, int)> _readImageSize(File file) async {
  final result = await ImageSizeGetter.getSizeResultAsync(
    AsyncImageInput.input(FileInput(file)),
  );
  return (result.size.width, result.size.height);
}

await が付いているし、getSizeResultAsync という「async」な名前の API を使っている。なんとなく「これは非同期だから UI を止めないはず」と思い込んでいた。ここが誤解だった。

その誤解を解く前に、そもそも「アイソレート」「スレッド」が何なのかを先に置いておく。

実ファイルはディスク。幅・高さはファイル先頭のヘッダから読む。スキャン中はメモリにしか無い。DB には書かない。ディスク 選んだフォルダの写真ファイルヘッダに幅・高さがある。実体はアプリの DB に入らない。ヘッダを読むメインアイソレート(作業台は1つ。メモリも1つ)ヘッダ解析が列を埋める6000枚ぶん、隙間が無いフレーム描画割り込めない画面のスピナーは止まったまま

つまり「async と書いてあるか」ではなく「その仕事が実際にどのアイソレートの CPU を食っているか」が問題だった。await は処理を別スレッドに飛ばす魔法ではありません。

修正: Isolate.run でバックグラウンドに逃がす

Dart には Isolate.run(重い関数を新しいアイソレートで実行して結果を Future で返す)があるので、解像度の一括読み込みをまるごとそこへ退避する形にした。

dart
Future<List<(int, int)>> _readImageSizesInIsolate(List<String> paths) {
  return Isolate.run(() {
    // この中は別アイソレート。メインの UI スレッドを止めない。
    return paths.map((path) {
      try {
        final result = ImageSizeGetter.getSizeResult(FileInput(File(path)));
        return (result.size.width, result.size.height);
      } catch (_) {
        return (0, 0); // 読めない画像は (0,0)。表示側は 4:3 で代替する
      }
    }).toList(growable: false);
  });
}
実ファイルはディスクに残す。渡すのはパスの文字列だけ。返す幅・高さは別アイソレートのメモリで作り、画面のメモリへ載る。DB にはまだ書かない。ディスク 写真ファイルはそのまま中身は渡さない。住所(パス)だけ渡す。メインアイソレートフレーム描画(スピナーが回る)幅・高さのリストを受け取る画面 State はこの台のメモリパス幅・高さ別アイソレートヘッダ解析 × 6000自分のメモリ(共有しない)ウィジェットは置けないスキャン完了後に画面のメモリへ。DB の Photos へ書くのは綴ったあと。

呼び出し側は、フェーズ2 に入ったことを示すイベントを1つ流してローディング表示を「画像を読み込んでいます」+スピナーに切り替え、あとはこの関数の結果を待つだけ。「固まった」ではなく「読み込み中」に見えます。

もう一つの判断: 待ち時間を「どこに」置くか

ここで設計の分かれ道があった。6000 枚の解像度読み込みには結局それなりの時間がかかります。この待ちをユーザーにどう体験させるか。

候補に挙がったのが「解像度の遅延読み込み」だった。スキャンはフェーズ1だけで即終了させ、解像度は「いま画面に出す1枚」を表示する直前に読みます。こうすればスキャンの待ちはほぼゼロになります。

魅力的に見えたが、却下した。理由は綴る画面の操作リズムだ。綴る画面は写真を1枚ずつ見て「綴る」か「また今度」かをポンポン仕分けていく、カードめくりのような UX になっています。ここで遅延読み込みにすると、また今度/戻すで次の写真へ進むたびに、その写真の解像度読みが挟まります。すると、

  • いったん 4:3 の仮枠で出して、読めた瞬間に正しい比率にカクッと跳ねる、か
  • 読み終わるまで一瞬待たされる

のどちらかが、操作のたびに起きます。一番テンポよく動かしたいホットパスに、毎回小さな引っかかりが乗ります。

代わりに選んだのが今の形だ。

解像度はファイルヘッダから読む。採用ではスキャン中にメモリへ載せる。綴る操作はそのメモリを読む。DB の Photos へ書くのは綴ったあと。却下 遅延読み込み読み込み画面ほぼ待たない綴る操作毎回ディスクのヘッダを読むまた今度/戻すのたびに引っかかる採用 スキャン中に先読み読み込み画面まとめて待つ綴る操作メモリ上の幅・高さを使う操作は常に即時

つまり「避けられない待ち時間は、待つのが自然な場所(読み込み中の画面)に前倒しして、インタラクティブな操作からは追い出す」という判断をした。同じ総待ち時間でも、ローディング画面で 15 秒待つのと、操作のたびに 0.2 秒引っかかるのとでは、後者の方が体験を壊します。

学びポイント

  • async/await は並行処理であって並列処理ではありません。await してても、その仕事はメインアイソレートの CPU を食います
  • 「async という名前の API か」ではなく「この処理は実際にどこの CPU を使うか」で考えます。大量の CPU 仕事は Isolate.run で別アイソレートへ
  • アイソレートはメモリを共有しないので、入口(送る値)と出口(返す値)を素朴な型に絞ると切り出しやすいです
  • 「最初に疑ったコスト(setState 連打)」が真犯人とは限りません。直しても症状が消えなければ、仮説を捨てて別の層を疑います
  • 避けられない待ち時間は「待つのが自然な場所に前倒し」します。インタラクティブな操作の途中に latency を置くと、同じ時間でも体感が悪化します

おわりに

「await を付けたから非同期、だから UI は固まらない」という素朴なモデルが、6000 枚という規模で初めて崩れた。Dart のアイソレートの話は知識としては知っていても、「自分のこの await がメインスレッドを食っている」と結びつくのは、実機で固まる画面を見たときだった。

そして、技術的な修正(Isolate へ退避)と UX の判断(待ちを前倒し)は別々の話に見えて、実は「重い処理をどこで・いつやるか」という同じ問いの裏表だった。


この記事は2026年5月時点の自分の理解で書いています。