一度読んだコードがセッション中は二度と出ない——重複排除の範囲を「セッション全体」から「いま見えているカード」に縮めた話
| 開発記録 | スキャナー
タグ: #Flutter #Riverpod #状態管理 #非同期処理 #UX
複数のコードをまとめて読み取るスキャンアプリで、一度検出したカードが消えたあと、同じセッション中は二度と出てこない。かざしても無反応でした。重複排除を「セッション全履歴」から「いま画面に見えているカード」だけに縮めた設計変更と、履歴保存の非同期レース対策の記録です。
消えたカードに、もう一度会えない
複数のコードを一度にまとめて読み取れるスキャンアプリを開発しています。カメラをかざすと、検出したコードが結果カードとして画面に積まれます。一定時間たつと自動でフェードアウトする UI です。
この手の連続検出型スキャナには、避けて通れない前提があります。カメラの検出ストリームは同じコードを毎フレーム報告してくることです。30fps なら、1枚のコードをかざしているだけで毎秒30回「検出しました」が届きます。何も対策しなければ、同じカードが毎フレーム積み上がって画面が埋まります。重複排除(dedup)は必須です。
最初の実装は素朴でした。セッション中に一度でも検出した内容(以下 body)を集合に貯めておく。含まれていたら skip する方式です。
// 最初の実装(セッション全体 dedup)
if (state.seenBodies.contains(body)) continue;
これで増殖は止まりました。ロジックとしては何も間違っていません。ところが実機で使い込むと、明確に「壊れている」ように見える挙動が出てきました。
一度読んだコードのカードが消えたあと、もう一度かざしても何も起きないのです。
カードはしばらくすると消えます。消えたあとに「さっきのをもう一回見たい」とかざし直すのは、ごく自然な操作です。でもセッション全体の検出履歴で skip しているので、アプリは沈黙します。検出はしています。ユーザーから見れば無反応です。
重複排除の目的に立ち返る
直し方を考える前に、そもそも dedup は何のためにあるのかを整理しました。
- やりたいこと: 画面に見えているカードを増殖させない
- やりたくないこと: データを一意に保つこと。これは違います。履歴 DB は body にユニーク制約を張った upsert なので、何回検出しても勝手に1行に収束します
守るべき不変条件は「同じ body のカードを同時に2枚見せない」だけでした。「セッション中は1回しか反応しない」はやり過ぎです(増殖を止めたくて、反応まで止めていました)。目的が「見えているカードの重複防止」なら、skip の判定条件もそのまま「いま画面に見えているか」にすべきです。
そこで dedup の判定を変えました。「セッションに貯めた集合」から「現在の状態から毎回導出する集合」へ。
/// 結果カードの表示ウィンドウ(値は例)
const Duration kResultCardVisibleWindow = Duration(seconds: 10);
/// 「いま画面に見えているカード」の body を集める。
/// 表示ウィンドウ内 かつ 明示的に閉じられていないものだけが対象。
Set<String> computeActiveBodies() {
final now = _clock();
final result = <String>{};
for (final code in state.detected) {
if (state.dismissedBodies.contains(code.body)) continue;
if (now.difference(code.detectedAt) >= kResultCardVisibleWindow) {
continue;
}
result.add(code.body);
}
return result;
}
/// 1フレーム分の検出結果を消費する。どの入力経路の検出結果も
/// 同じこの入口を通す。
void handleFrame(List<RawDetectedCode> frame) {
final seenInFrame = <String>{};
final activeBodies = computeActiveBodies();
for (final raw in frame) {
final body = normalizeScannedBody(raw.body);
if (body.isEmpty) continue;
if (activeBodies.contains(body)) continue; // 表示中の body だけ skip
if (!seenInFrame.add(body)) continue; // 同一フレーム内の重複は畳む
unawaited(_onDetect(raw, body));
}
}
ポイントは3つあります。
1つ目は、dedup 専用の状態を持たないことです。computeActiveBodies は「検出済みリスト」「閉じられた body の集合」「現在時刻」という既存の状態だけから毎回導出します。カードが消える経路はタイムアウトと明示的な dismiss の2通り。どちらで消えても導出結果に自動で反映されます。「消えたのに dedup 集合の更新を忘れた」という不整合が、構造的に起きません。
2つ目は、フレーム内の重複を別枠で畳むことです。1フレームに同じ内容のコードが2枚写ることは普通にあります。ループローカルの seenInFrame で潰します。可視ウィンドウの dedup とは寿命が違うので混ぜません。
3つ目は、検出結果の入力経路が複数あってもすべて同じ handleFrame を通ることです。dedup や履歴保存といった副作用の挙動が、経路によらず揃います。「特定の経路から読んだときだけ二重登録される」という分岐バグの入り込む余地がありません。
再検出は「追加」ではなく「置き換え」
可視ウィンドウ方式にすると、消えたカードの body が再検出で戻ってきます。このとき検出リストに素朴に append すると、同じ body のエントリが新旧2件になります。検出リストを表示ソースにしている結果一覧に、同じ内容が2件並びます。そこで再検出時は「同じ body の既存エントリを除去してから追加」にしました。あわせて dismiss 済み集合からもその body を取り除き、明示的に閉じたカードも再検出で復活するようにしました。
もう1つ、細かいけれど重要なことがあります。状態の書き換えを最初の await より前に同期的に済ませることです。状態更新まで非同期にすると、次のフレームが古い状態で dedup 判定をします。同じ body を二重に通してしまいます。「state 更新は同期区間で、副作用はそのあと」という順序を、コメントで契約として明記しました。
演出側にも同じバグが潜んでいた
カード側を直して一件落着。そう思っていたら、実機確認で別の症状が見つかりました。検出時に発火する画面演出が「最初の1回しか表示されない」のです。
原因は単純でした。演出側には別実装のセッション全体 dedup がそのまま残っていたのです。カードと演出は別の状態管理を通っていて、それぞれが自前の dedup を持っていました。片方だけ直して、もう片方が古い仕様のまま取り残されていました。
修正は、computeActiveBodies を public にして、演出側からも同じ関数を呼ぶ形にしました。「表示ウィンドウ内かつ未 dismiss」という判定ルールの実装が1箇所になります。今後ウィンドウ長を変えても、ズレようがありません。
履歴保存の非同期レース: 確定 id をキャッシュしない
最後に、この設計変更と地続きで直した非同期レースの話です。
カードをタップすると履歴詳細に遷移します。遷移には履歴 DB の行 id が必要です。でも履歴への upsert は、検出時の非同期副作用です。検出直後にタップされると、upsert がまだ終わっていません。body での行検索が、正常系でも miss します。
「upsert の結果 id をキャッシュすればいい」と考えたくなります。これは危険です。キャッシュした後にユーザーが履歴画面でその行を削除すると、キャッシュは存在しない行を指し続けます。再検出で行が作り直されれば、id も変わります。
採用したのは「確定 id は持たず、実行中の Future だけ持つ」方式です。
/// body → 実行中の履歴 upsert。完了したら Map から消える。
final Map<String, Future<void>> _pendingUpserts = {};
Future<void> _runHistoryUpsert(DetectedCode code) {
final base = _runSideEffect('history.upsert', () => _upsert(code));
late final Future<void> tracked;
tracked = base.then((_) {
// Map の値が「自分自身の Future」のときだけ除去する
if (identical(_pendingUpserts[code.body], tracked)) {
_pendingUpserts.remove(code.body);
}
});
_pendingUpserts[code.body] = tracked;
return tracked;
}
/// タップ時: 実行中の upsert があれば待ち、常に body で再解決する
Future<int?> historyIdForBody(String body) async {
final sanitized = normalizeScannedBody(body);
final pending = _pendingUpserts[sanitized];
if (pending != null) await pending;
final row = await _findByBody(sanitized); // 失敗・行なしは null の fail-soft
return row?.id;
}
タップ時は、実行中の upsert があれば完了を待ちます。そして毎回 body で行を引き直します。id をキャッシュしないので、行削除や再作成とのレースに構造的に強くなります。
Map からの除去に identical を使っている理由もあります。同じ body の upsert が複数走って、逆順に完了するケースのためです。古い upsert の完了処理が、あとから登録された新しいエントリを消してしまわないように。「Map に入っているのが自分自身のときだけ消す」という identity 比較でガードしています。セッションのクリア後に古い完了処理が走っても、identity が一致しないので何もしません。クリア側は Map を clear するだけで済みます。
学び
- dedup の「範囲」は、データモデルの都合ではなく UX から決める。守りたいのは「見えているカードの重複防止」であって「セッション中1回きり」ではなかった。目的を言語化すると、skip 条件は自ずと「いま見えているか」になる
- 判定用の状態は貯めずに、既存の状態から毎回導出する。導出にすれば、カードの消え方が何通りあっても自動で追随する。更新漏れによる不整合が構造的に消える
- 同じルールを2箇所に実装しない。カードと演出で dedup 実装が分かれていたせいで、片方だけ直して片方が取り残された。判定関数を1つにして共有する
- 非同期副作用の結果はキャッシュせず、「実行中の Future」だけ持って完了後に再解決する。削除・再作成とのレースに強い。除去は identity 比較でガードすれば、逆順完了も無害化できる
どれもスキャンアプリに限らない話です。「毎フレーム同じ入力が届き続ける」という連続検出の条件が、状態設計の甘さを容赦なく炙り出してくれました。ユーザーの「もう一回かざす」に無反応で返すアプリには、もうしたくありません。