綴Photo の勉強ノートに戻る
コードレビュー学習2026.06.17

「静かな天井」を直したら、別の「静かな天井」を作っていた — 写真ピッカーの打ち切りと、2周目のレビュー

FlutterUXページングコードレビュー綴Photo

修正済みのコードが、直したはずの欠陥を別の形で再生産していた——それをわざと穴を探す2周目のレビューが捕まえた事例を整理する。

綴Photo のモバイル版には、カメラロールから写真を1枚選ぶピッカー画面があります。レビューで分かったのは、この画面が先頭300枚しか出していなかったこと。スクロールしても「もっと見る」も無く、300枚目でグリッドが物理的に終わります。それ以上があることを示す表示すら出ません。

エラーが出るわけでも、空っぽなわけでもありません。ちゃんと写真は並んでいて、ただ途中で黙って終わっています。「カメラロールから選ぶ」と言っておいて300枚で打ち切る、気づかれにくい類のUX欠陥だ。これをページング(段階ロード)に直した——つもりが、直した実装が別の形で同じ「静かな天井」を作っていた。そこが今回いちばんの学びだった。

元の問題: エラーでも空白でもない「静かな天井」

写真を取ってくる部分が、こうなっていた。

dart
// アルバムの先頭300枚だけを取得する(それ以上は取りに行かない)
return album.getAssetListRange(start: 0, end: 300);
写真の実体は端末。画面が出すのはメモリの先頭300枚。DB には無い。端末の写真ライブラリカメラロール実ファイルは OS 側アプリの SQLite には無い数万枚あり得る先頭だけ画面のメモリ先頭300枚のリスト開いている間だけprefs にも無い保存しない並べるグリッド300枚目で物理終端まだあります、無し301枚目以降は出ない黙って終わるカメラロールは端末に残っている。切れているのは画面のメモリである。この打ち切りは SQLite にも prefs にも書かれない。

この手の欠陥が怖いのは、気づかれにくいことです。エラーなら気づきます。空っぽなら気づきます。でも「ちゃんと表示されているけど、実は途中で切れている」は、見た目が正常なので誰も疑いません。沈黙の天井、と呼んでおく。

直した方向: スクロールで段階ロード

300枚固定をやめて、末尾近くまでスクロールしたら次のページを継ぎ足す形に変えた。末尾には小さなインジケータ(読み込み中マーク)を置いて「まだ続く」を見せる(無言で終端させない、が狙い)。これで先頭300枚の天井は無くなった。

直したつもりが再生産していた沈黙

ここで2周目のレビューにかけた。1周目で「直す箇所」を見つけ、直したあと、今度はわざと穴を探す目で修正そのものを見直すパスだ。そこで出てきた指摘がこれだった。

追加ロードが失敗したとき、末尾のスピナーが回り続けるだけで、エラーも再試行ボタンも出ません。ユーザーが末尾で指を止めていると、永遠に回るスピナーだけが残ります。

dart
// isLoadingPage: ページ取得中かどうか(失敗しても false に戻る)
// hasMore:       まだ続きがあるかどうか(失敗では変えていない = true のまま残る)
// loadError:     失敗を記録する変数(ここにセットする)
catch (e) {
  loadError = e;          // ← エラーは記録した
  isLoadingPage = false;  // ← 「取得中」は終わりにした
  // hasMore は変えていない = 「まだ続く」という状態のまま
  // → フッターは「続きがある・ロード中でない」= スピナーを回し続ける(出口が無い)
}
 
// なぜこうなりやすいか:
// ページング実装は「成功する前提」で書かれることが多い。
// hasMore の更新を「全件取り終わった時」だけ想定すると、
// 失敗した時の分岐を書き忘れる——それが沈黙の出口になる。
失敗したあとの3つの旗は画面のメモリ。DB にも prefs にも無い。hasMoretrue のまま失敗では変えていないメモリ・保存しないisLoadingPagefalse取得中は終わりにしたメモリ・保存しないloadErrorセット済みエラーは記録したメモリ・保存しない続きがある・ロード中でない → スピナーが回り続ける出口が無い。カメラロールは端末に残ったまま1周目はメモリが300枚で止まって、グリッドが黙って終わる。2周目は hasMore が true のままで、スピナーが黙って回る。

直し直した方向

フッターの状態を、ロード中・失敗・終端で分けた。失敗中はスクロールで自動再試行しない——指を動かすたびに失敗を繰り返すと、それはそれで別の沈黙になるので、ボタンを押されたときだけやり直す。

dart
// フッターの出し分け:失敗をスピナーで終わらせず、必ず「終わり方」を見せる
if (hasLoadMoreError) return Footer.retry;        // 再試行ボタン
if (isLoadingMore || hasMore) return Footer.spinner; // ロード中/まだ続く
return Footer.none;                                // 全部出した
同じ3つの旗は画面のメモリ。閉じると消える。SQLite にも prefs にも無い。失敗hasLoadMoreError再試行ボタンこれ以上読み込めませんでしたロード中/まだ続くisLoadingMore か hasMoreスピナー成功するまでの一瞬全部出したどちらでもないフッター無しここで終わり、と分かるスクロール失敗中は自動ではやり直さない通さない再試行ボタンloadError を消して次ページを取る失敗を先に見る。旗の置き場はメモリだけで、DB の行は1つも増えない。

ポイントは「失敗をスピナーで終わらせない」こと。ロード中の表示は「成功するまでの一瞬」を暗黙に前提にしがちだが、失敗したときに何を見せるかまで決めておかないと、スピナーがそのまま沈黙の天井になります。

学びポイント

  • 沈黙系の欠陥(エラーでも空白でもなく、ただ途中で終わる/終わらない)は一番気づかれにくいです。だから潰したつもりでも形を変えて戻ってきます
  • ローディング表示は「成功するまでの一瞬」を前提にしがち。失敗したときに何を見せるかまで設計しないと、スピナーが新しい沈黙になります
  • 失敗中の自動再試行(スクロールのたびに再挑戦)は、それ自体が別の沈黙を生みます。明示的な「再試行」操作を待つ方が、状態が見えます
  • 「直した」と「直っている」は別。直した結果を、最初の欠陥と同じ目でもう一度見る2周目が効きます。特に自動テストが書きにくいUI部分では、わざと穴を探すレビューが頼りになります

おわりに

沈黙の天井は、場所を変えて何度でも生えます。共通していたのは「ユーザーに『ここで終わり』『失敗した』を告げていない」ことだった。表示が途切れる/止まること自体より、それを黙ってやることが問題です。

1周目は打ち切りを見つけ、2周目はその修正が同じ穴を別の形で開けているのを見つけた。直す目と疑う目は別だった。


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