綴Photo のデスクトップ版には、写真フォルダを選ぶと中の画像を1枚ずつ「綴る」画面に出していくスキャン機能がある。手元の数十枚〜数百枚のフォルダでは快適に動いていたのだが、6000 枚入ったフォルダで試したら、スキャン中の「読み込み中」画面が——アニメーションごと——完全に固まった。しばらく待つと最後まで読み込まれて綴る画面に切り替わる。つまり処理は進んでいるのに、UI が一切描画されない。
これは async/await への誤解が招いた、わりと典型的な罠だった。ついでに「待ち時間をどこに置くか」という UX の判断もセットで考えることになったので、両方まとめておく。
この記事で扱う観察ポイント
async/awaitは「別スレッドで動く」という意味ではありません(Dart のアイソレート)- CPU 仕事を
awaitで包んでも、メインアイソレートが詰まれば UI は固まります Isolate.runで重い処理をバックグラウンドに逃がします- 避けられない待ち時間は「待つのが自然な場所」に前倒しする(UX の判断)
症状: ローディングのアニメすら止まる
スキャンは2フェーズ構成になっています。
- フェーズ1: フォルダを再帰的に走査してファイル一覧を集め、各ファイルの
stat()(更新日時など)を取ります。撮影日時順に並べるための軽い処理。 - フェーズ2: 並べた候補を1枚ずつ開いて、画像の解像度(幅・高さ)を読みます。
数百枚までは一瞬で終わる。ところが 6000 枚だと、フェーズ1(一覧集め)は問題なく進むのに、フェーズ2 に入った瞬間、ローディング画面が固まる。スピナーも止まる。「処理中」を示すアニメーションが動かないので、ユーザーから見れば「アプリが死んだ」ように見える。実際には裏で解像度を読み続けていて、全部読み終われば復帰する。
最初の誤診: 「setState の連打だろう」
最初に疑ったのは UI の更新コストだった。フェーズ2 では1枚読むたびに状態を更新していて、こうなっていた。
case UnprocessedScanPhotoFound(:final photo):
setState(() {
sessionPhotos = [...sessionPhotos, photo]; // 毎回リスト全体をコピー
});[...sessionPhotos, photo] は毎回リストを丸ごと作り直すので、6000 枚なら合計でおよそ 1800 万要素ぶんのコピーになります(O(n²))。しかも1枚ごとに setState でリビルドが走ります。「これが UI スレッドを圧迫しているんだろう」と考えて、ローカルのリストに溜めて完了後に一括反映する形に直してもらった。
final scanned = <UnprocessedPhoto>[];
// ... ループ内では scanned.add(photo) するだけ。setState は打たない
// ループを抜けてから一度だけ setState で反映これでリビルド連打と O(n²) コピーは消えた。が、固まりは直らなかった。 ここで「犯人は別にいる」と分かった。
本当の原因: await してても仕事はメインアイソレートでやっていた
解像度を読む関数はこうなっていた。
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 を止めないはず」と思い込んでいた。ここが誤解だった。
その誤解を解く前に、そもそも「アイソレート」「スレッド」が何なのかを先に置いておく。
つまり「async と書いてあるか」ではなく「その仕事が実際にどのアイソレートの CPU を食っているか」が問題だった。await は処理を別スレッドに飛ばす魔法ではありません。
修正: Isolate.run でバックグラウンドに逃がす
Dart には Isolate.run(重い関数を新しいアイソレートで実行して結果を Future で返す)があるので、解像度の一括読み込みをまるごとそこへ退避する形にした。
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);
});
}呼び出し側は、フェーズ2 に入ったことを示すイベントを1つ流してローディング表示を「画像を読み込んでいます」+スピナーに切り替え、あとはこの関数の結果を待つだけ。「固まった」ではなく「読み込み中」に見えます。
もう一つの判断: 待ち時間を「どこに」置くか
ここで設計の分かれ道があった。6000 枚の解像度読み込みには結局それなりの時間がかかります。この待ちをユーザーにどう体験させるか。
候補に挙がったのが「解像度の遅延読み込み」だった。スキャンはフェーズ1だけで即終了させ、解像度は「いま画面に出す1枚」を表示する直前に読みます。こうすればスキャンの待ちはほぼゼロになります。
魅力的に見えたが、却下した。理由は綴る画面の操作リズムだ。綴る画面は写真を1枚ずつ見て「綴る」か「また今度」かをポンポン仕分けていく、カードめくりのような UX になっています。ここで遅延読み込みにすると、また今度/戻すで次の写真へ進むたびに、その写真の解像度読みが挟まります。すると、
- いったん 4:3 の仮枠で出して、読めた瞬間に正しい比率にカクッと跳ねる、か
- 読み終わるまで一瞬待たされる
のどちらかが、操作のたびに起きます。一番テンポよく動かしたいホットパスに、毎回小さな引っかかりが乗ります。
代わりに選んだのが今の形だ。
つまり「避けられない待ち時間は、待つのが自然な場所(読み込み中の画面)に前倒しして、インタラクティブな操作からは追い出す」という判断をした。同じ総待ち時間でも、ローディング画面で 15 秒待つのと、操作のたびに 0.2 秒引っかかるのとでは、後者の方が体験を壊します。
学びポイント
async/awaitは並行処理であって並列処理ではありません。awaitしてても、その仕事はメインアイソレートの CPU を食います- 「async という名前の API か」ではなく「この処理は実際にどこの CPU を使うか」で考えます。大量の CPU 仕事は
Isolate.runで別アイソレートへ - アイソレートはメモリを共有しないので、入口(送る値)と出口(返す値)を素朴な型に絞ると切り出しやすいです
- 「最初に疑ったコスト(setState 連打)」が真犯人とは限りません。直しても症状が消えなければ、仮説を捨てて別の層を疑います
- 避けられない待ち時間は「待つのが自然な場所に前倒し」します。インタラクティブな操作の途中に latency を置くと、同じ時間でも体感が悪化します
おわりに
「await を付けたから非同期、だから UI は固まらない」という素朴なモデルが、6000 枚という規模で初めて崩れた。Dart のアイソレートの話は知識としては知っていても、「自分のこの await がメインスレッドを食っている」と結びつくのは、実機で固まる画面を見たときだった。
そして、技術的な修正(Isolate へ退避)と UX の判断(待ちを前倒し)は別々の話に見えて、実は「重い処理をどこで・いつやるか」という同じ問いの裏表だった。
この記事は2026年5月時点の自分の理解で書いています。