綴Photo の勉強ノートに戻る
プロダクト設計2026.07.08

写真1枚に何を残したか — 状態を「集合」で持って union で数える

Dart状態機械設計綴Photo

綴Photo の「綴る画面」(写真を1枚ずつ見て決めていく画面)に mood リアクションを分離して置いたときの設計メモです。

出発点だった要望

写真ステージに「気分だけ残す(mood ボタン)」を分離して置きたくなった。実装中に自分で出した要望は3行:

  • リアクションを押した写真は「決めた」扱いにしたい。
  • ただし「綴った」でも「また今度」でもない、第3の状態にしたい。
  • 綴った後にリアクション押しても付けられるようにしたい。

最初の2行は素直に集合を1個増やす話。刺さったのは3行目だった。

実装は AI と進めている。この記事のコードも自分で1行ずつ書いたものではなく、上の3行を渡して出てきたものを読んで判断した形だ。

元の cursor(2集合モデル)

綴る画面の「今どこにいるか+どれが決定済みか」は TsuzuruSessionCursor という immutable クラスに集めてある。元は 2集合+インデックスの単純な形だった。

dart
class TsuzuruSessionCursor {
  final int index;
  final Set<String> _processedKeys; // 決定済み(綴った or また今度)
  final Set<String> _passedKeys;    // うち「また今度」だけ
 
  int get frontier => _processedKeys.length;
  bool isWritten(String key) =>
      _processedKeys.contains(key) && !_passedKeys.contains(key);
}

_passedKeys は _processedKeys の中に丸ごと含まれる入れ子で、「綴った写真」は別に持たず 決定済みだが「また今度」ではないもの として毎回その場で導出していた。回りくどく見えるが、当時は決定の出口が「綴った」「また今度」の2つだけで、その2つは互いに排他=1枚の写真が持つ決定は常に1つだったから、集合は1本で足りた。frontier もそれを数えるだけで済む。構造が悪かったのではなく、この前提がこの後で失効する。

「綴った後のリアクション」で崩れる関係

新しくリアクションの状態を足すとき、まず思いつくのは passed / reacted / written の3択(排他) だ。押されたら他は消える。ラジオボタン的な素朴なモデル。既存が「1枚=1つの決定」だったので、素直に伸ばすと選択肢を1つ増やす形になる。

これがさっきの3行目で崩れる。「綴った」と「リアクション」を両方付けられるようにしたい、と自分が言ったから。

つまり written と reacted は共存させたい。両方の集合に同じ key が入っていて構わない。

一方で passed(また今度)は他の2つとは共存させたくない。また今度=先送りなのに、そこにリアクションや綴りが同時に付いているのは意味が破綻する。「passed だけどリアクションついてる」写真があっても UI で見せる場所が無い。

まとめると3種類の関係が混ざる:

組み合わせ 関係 意味
passed と reacted 排他 同時には成り立たない(片方が付いたらもう片方は外す)
passed と written 排他 同時には成り立たない
written と reacted 共存可 同じ写真に両方付いていてよい

排他だけのモデルではもう閉じない。集合を素直に3本持って、それぞれの mark 操作で「どの集合から外し、どの集合に入れるか」を明示する形に切り替えた。

3集合に増やす

切り替えた後の形を、先に1枚で。

写真1枚がどこに入るかまた今度passed排他綴ったwritten気分だけreacted両方付く共存できるボタンを押したとき「また今度」を押す → 綴った・気分だけ から外す「綴った」/「気分だけ」を押す → 相手には触らない決めた枚数(frontier)= また今度の数 +(綴った ∪ 気分だけ)の数両方に入っている写真は1回だけ数える=これが unionここまでしか先へ進めない(canAdvance の頭打ち)

書き直した cursor はこう。

dart
class TsuzuruSessionCursor {
  final Set<String> _passedKeys;   // また今度
  final Set<String> _reactedKeys;  // 気分だけ残した
  final Set<String> _writtenKeys;  // 綴った
 
  TsuzuruSessionCursor markPassed(String key) => _copyWith(
        // {...集合, key} = 元の中身を全部コピーして key を足した「新しい集合」
        passedKeys: {..._passedKeys, key},
        // ドット2つの ..remove = 削除した後、集合そのものを返す(1つだと true/false が返る)
        reactedKeys: {..._reactedKeys}..remove(key),   // 排他
        writtenKeys: {..._writtenKeys}..remove(key),   // 排他
      );
 
  TsuzuruSessionCursor markReacted(String key) => _copyWith(
        passedKeys: {..._passedKeys}..remove(key),     // 排他
        reactedKeys: {..._reactedKeys, key},
        // written は触らない = 共存可
      );
 
  TsuzuruSessionCursor markWritten(String key) => _copyWith(
        passedKeys: {..._passedKeys}..remove(key),     // 排他
        writtenKeys: {..._writtenKeys, key},
        // reacted は触らない = 共存可
      );
}

コードとして見ると「排他ならその集合から remove、共存可なら触らない」というだけ。関係のルールが mark 操作の分岐に1対1で現れる。上がってきたコードを読んで、ここは関係表とちゃんと対応が取れていると納得できた箇所。

frontier をどう数えるか

3集合に増やしたら、単純な _processedKeys.length はもう使えない。同じ key が written と reacted の両方に入ることがあるから、単純加算だと二重カウントされる(3集合 union が本来の決定済み数)。

これは数え方の美しさの問題ではなく、frontier の不変則が壊れる話だった。frontier が実際の決定済み数より大きく出ると、canAdvance = index < frontier の頭打ちがゆるむ = まだ決めていない写真を素通りして先へ進めてしまう。決定済みが「境界線より前に連続して並んでいる」という前提そのものが崩れる。当時は進捗表示も frontier を見ていたので、全部決め終わる前に右側の数字に届いてしまう。union は「きれいに書くため」ではなく、この不変則を守るために要る。

入った式はこれだけ:

dart
int get frontier =>
    _passedKeys.length + _writtenKeys.union(_reactedKeys).length;

passed は他2つと排他だから(mark 操作がそう保っているから)、passed に入っている key は他のどちらにも居ない。だから passed は単純加算していい。一方 written と reacted は共存可なので、両方に入っている key を1回だけ数えたい → Set.union() のサイズを取る。

排他な集合は加算、共存可な集合は union、と自然に分かれる。上の関係表の3行がそのまま「足し算」と「union」に翻訳される。

テストで固定できた

状態機械の変更で怖いのは、既存の呼び出し側(前後移動、全部決め終わったかの判定、進捗表示)を壊すこと。

cursor は純関数の塊——電卓アプリと同じで、同じ入力を渡せば必ず同じ結果が返り、外の何にも触らない——なので、画面を動かさなくても「この操作をしたらこの集合になる」を紙の上で全部確かめられる。集合の遷移と frontier の値をテストで固定してから mark 系メソッドの中身を書き直す、という順で進めた。

テストは新規7ケース(3集合の関係が6本+DB から3集合を復元する withState が1本)+既存15ケース保持。

dart
// expect(調べたい値, 期待する結果) で「こうなっているはず」を書き留める形
test('markWritten の後 markReacted すると共存(written は残る・frontier=1)', () {
  final photos = [_photo('a')];              // 写真1枚だけの状況を用意
  final cursor = TsuzuruSessionCursor.initial(photos) // 写真リストから初期状態の cursor を作る
      .markWritten(_key(photos[0]))          // ① その写真を「綴った」にする(_key = 写真1枚を指す文字列)
      .markReacted(_key(photos[0]));         // ② 続けて「気分だけ」も押す
  // 確かめたいのは、②が①を消していないこと = 共存が成り立っているか。
  expect(cursor.isWritten(_key(photos[0])), isTrue);   // 綴った、は残っている
  expect(cursor.isReacted(_key(photos[0])), isTrue);   // 気分だけ、も付いている
  expect(cursor.frontier, 1);                          // 2つの集合に居るが写真は1枚 = union なので 1
});
 
test('markReacted の後 markPassed で reacted を外す(排他)', () {
  final photos = [_photo('a')];              // 写真1枚だけの状況を用意
  final cursor = TsuzuruSessionCursor.initial(photos) // 写真リストから初期状態の cursor を作る
      .markReacted(_key(photos[0]))          // ① 気分だけ残す
      .markPassed(_key(photos[0]));          // ② そのあと「また今度」に変える
  // 「また今度」は先送りなので、①の気分が残ったままだと意味が破綻する。
  // ②が①を外してくれたか = 排他が効いているかを、ここで固定している。
  expect(cursor.isReacted(_key(photos[0])), isFalse);  // 気分は外れた(これが求めている結果)
  expect(cursor.isPassed(_key(photos[0])), isTrue);    // また今度、だけが残った
});

テストは関係表の直接翻訳になっている。並べてみて「関係表の各行が全部テスト1本に対応してるな」と気づいた。逆に言うと、この関係表を最初に確定させてしまえば、テストの網羅は機械的に決まる。

学んだこと

  • 状態機械の集合は増やしていい。排他だけの構造は「共存させたい」要望が来た瞬間に破綻する。破綻したときに集合を増やすのは大した変更じゃない。
  • 関係の種類を集合の演算に落とし込める(排他 → remove と足し算、共存可 → 触らない と union)。設計の言葉と実装の演算が対応する形にできると、後から読む自分が迷わない。
  • 要望の3行目(綴った後にリアクション)が一番設計を動かした。素朴なラジオボタン的モデルで書き始めてたら、この一行に気づいた時に「あれ、written に上書きされちゃう」と実装から突き返して、要望を歪めていたかもしれない。

コード自体は Set 3個と .length + union().length だけの短い変更。だけど「関係を種類分けする」というレイヤーを1枚挟むかどうかで、後の分岐(mark メソッド・frontier・テスト)の見え方が全部変わった。設計はコードの量じゃないな、と改めて思った1件だった。

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