綴Photo のモバイル版に、Desktop 対応を足したとき、コードの構造が少しずつ散らかり始めた。
ある機能を直すたびに「また同じ場所を2箇所直してる」という感覚が来て、その感覚を信じて整理に踏み切った話。整理は2段階に分かれている。
- ファイル構成のリファクタ(分岐のためだけのファイルを消し、Mobile/Desktop の役割分担を明確化)
- mixin の切り出し(Mobile/Desktop で共通だったセッション管理ロジックを
TsuzuruSessionMixinとして1箇所に集約)
非エンジニア目線で「mixin って何?」のレベルから書く。with/on/型パラメータ T あたりまで踏み込んで、自分が手を動かしながら学んだ感覚で残す。
この記事の前提:AI に書かせていたら、同じコードが2箇所にできていた
正直に書いておくと、当時の俺は AI にかなりコードを書かせていた。
Desktop 対応を足したときも、Mobile 側のコードを横に並べて「同じ機能を Desktop 用にもう一度書いて」と頼むような進め方をしていた。雑なやり方だと自覚はあったけど、まず動くものを見たかった。
結果、同じセッション管理が2つの画面クラスに並んだ。
ここで動いているロジックを大雑把にいうと、
- 写真リストをパスしたり戻したり(アンドゥ)
- 綴ったときの保存処理
- 保存したときのフィードバック表示
- セッション全体の状態管理
みたいな集合体だ。
「散らかってきた」という感覚はいつ来るか
最初から構造がおかしかったわけじゃない。Mobile 版だけのときは、ファイル構成がフラットでも問題なかった。スクリーンが1種類だから、分岐が少なくシンプルに動いていた。
Desktop 版を追加した瞬間から問題が始まる。上の重複に加えて、「分岐のためだけ」のファイルも存在している。
「あ、これ続けてたら破綻するな」と気づいたのがリファクタの始点だった。
ビフォー:「分岐のためだけのファイル」が存在していた
整理前のファイル構成はこういう状態だった。
features/tsuzuru/presentation/
├── screens/
│ ├── tsuzuru_mode_select_desktop.dart ← Desktop用
│ ├── tsuzuru_mode_select_mobile.dart ← Mobile用
│ ├── tsuzuru_mode_select_page.dart ← 分岐するだけのファイル
│ ├── tsuzuru_screen.dart ← ロジックが混ざっていた
│ └── ...
└── widgets/
├── tsuzuru_photo_stage.dart ← 全部フラットに並んでいた
├── tsuzuru_photo_stage_desktop.dart
└── ...
tsuzuru_mode_select_page.dart が何をしているかというと、「Desktop か Mobile かを判定して、対応するウィジェットを返すだけ」だった。
このファイルを開くたびに、「このファイル、そのためだけに存在してるんだよな」という居心地の悪さがあった。
アフター:ロジックを1箇所に集めた
整理後はこうなった。
features/tsuzuru/presentation/
├── screens/
│ ├── tsuzuru_page.dart ← ルーティングだけの薄いラッパー
│ ├── tsuzuru_mobile.dart ← MobileのUI
│ └── tsuzuru_desktop.dart ← DesktopのUI
└── widgets/
├── desktop/ ← Desktop専用ウィジェット
├── mobile/ ← Mobile専用ウィジェット
└── shared/ ← 共通ロジック・共通パーツ
├── tsuzuru_session_mixin.dart ← セッションロジックが全部ここに
└── tsuzuru_status_views.dart
一番大きな変化は TsuzuruSessionMixin の存在だ。パス操作、アンドゥ、保存フィードバックを mixin として切り出した。
ここから先は、その mixin そのものの話に入っていく。
ConsumerState に mixin を噛ませる
具体的にやったことを書く。
セッション管理のロジックを TsuzuruSessionMixin という mixin に切り出した。
mixin TsuzuruSessionMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
// パス・アンドゥ・保存などのロジックをここに書く
Future<void> passCurrent(UnprocessedPhoto photo) async { /* パス */ }
Future<void> undoLastPass() async { /* アンドゥ */ }
Future<void> openAddMeaning(UnprocessedPhoto photo) async { /* 綴る → 保存 */ }
}Mobile 側と Desktop 側はこう書く。
class _TsuzuruMobileState extends ConsumerState<TsuzuruMobile>
with TsuzuruSessionMixin<TsuzuruMobile> { ... }
class _TsuzuruDesktopState extends ConsumerState<TsuzuruDesktop>
with TsuzuruSessionMixin<TsuzuruDesktop> { ... }最初に書いてあった passCurrent() undoLastPass() などの中身は完全に同じものだから、両方に同じコードが残らないことが、何よりの恩恵だった。
詰まったポイント1:on 句って何?
mixin 定義の冒頭にある on ConsumerState<T> の部分。ここで一回詰まった。最初は「条件式の一種?」と勘違いした。
結論から言うと、on は 共有フォルダに貼ってある「動作環境シール」 だった。「このフォルダ(mixin)は、ConsumerState という種類のクラスにしか取り込めません」と、取り込める相手を限定する宣言になっています。
なんでそんな限定が要るのか。mixin の中で ref(Riverpod のデータ取得)や setState(画面更新)を使いたいからです。でも ref も setState も mixin 自身の持ち物じゃなくて、ConsumerState が持っている道具。
mixin TsuzuruSessionMixin<T extends ConsumerStatefulWidget>
on ConsumerState<T> { ... } // ← 「ConsumerState の上でしか動かない」シールだから「俺は ConsumerState の上でしか動かないよ(=取り込む相手は ConsumerState であること)」とシールを貼っておきます。こう宣言しておくと、mixin の中で ref や setState を当たり前に使えます。逆に貼っていないと、Dart は「この mixin の中で ref が使える保証がない」と判断して、ref が見えずエラーになります。
ポイントは、これが**「実行中のこういう状況のとき」みたいな話じゃないこと。「取り込む相手のクラスの種類(型)**」を縛るシールで、with で取り込もうとした瞬間にチェックされます。
詰まったポイント2:型パラメータ T の渡し方
もう1個ハマったのが、あちこちに出てくる T だ。
mixin TsuzuruSessionMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>T の正体は、「画面の名前を後から書き込む空欄」 だった。
Mobile の画面と Desktop の画面は別物で、状態管理クラスの型も別です。同じ mixin を両方に差し込みたいです。でも、動作環境シールに on ConsumerState<TsuzuruMobile> と画面名を決め打ちしたら、Mobile にしか差せなくなります。そこで画面名を空欄にしておきます。その空欄の名前が T。
with TsuzuruSessionMixin<TsuzuruMobile> // 空欄 T に「TsuzuruMobile」を記入
with TsuzuruSessionMixin<TsuzuruDesktop> // Desktop 側は「TsuzuruDesktop」を記入mixin の中で widget がちゃんと TsuzuruMobile 型として触れるようになるのは、この空欄の中継のおかげです。最初は「なんでわざわざ <TsuzuruMobile> まで書くんだ」と思っていたけど、書かないとこの中継が途切れて、widget が正しい型で取れなくなります。
そして T extends ConsumerStatefulWidget は、その空欄に書けるのは「画面(ウィジェット)」だけ、というガード。int(ただの数字)みたいな変なものを書き込めないようにしています。"なんでも入る"わけじゃなくて、"画面ならどれでも入る" という意味です。
これが理解できると、mixin の世界が一気に広がった気がした。
「機能のかたまりを共有フォルダのように取り込む」というメンタルモデル
この見方が固まると、
- 「これは複数のクラスで使い回す機能だな」→ mixin に切り出す
- 「これは特定のクラス固有の機能だな」→ そのクラスに残す
の判断が、ふつうにできるようになった。
mixin はやりすぎると逆に読みづらいですけど、 「同じコードが複数のクラスにある」状態を解消する道具としては、本当に強いです。
機能は何も変わっていない
ここが大事なところで、この整理(ファイル構成のリファクタ + mixin の切り出し)で 機能は一切変わっていません。ユーザーから見たら、整理前と整理後で何も違いません。
でも開発者(俺)から見ると、「次の機能が追加しやすくなった」という感覚がはっきりある。
あと、なんというか 「気持ちよくなった」。コードを開いたときの居心地が変わった。
これが意外と重要で、開発のモチベーションに直結しています。散らかったコードを毎回開くのと、整理された構造を開くのでは、次の一手が出てくる速さが全然違います。
「整理するタイミング」の自分ルール
今回の経験から、自分なりの判断軸ができた。
「同じ機能変更を2箇所以上で行うようになったら、整理する」
1箇所の修正が他の箇所に波及するようになったとき、それは構造の問題のサイン。その感覚を無視して機能追加を続けると、どんどん直しにくくなっていきます。
逆に、「まだ1箇所で完結してる」と感じているうちは、整理を急がなくていいです。早すぎる最適化は時間の無駄。感覚が来てから整理する、で十分。
学んだこと
- 「また2箇所直してる」という感覚が、整理のサイン
- ファイル構成の整理 + mixin の切り出しはセットで効きます。片方だけだと中途半端になります
- mixin は「機能のかたまりを共有フォルダのように取り込む」道具
withは何個でも書けるので、機能の組み合わせが柔軟on句で「土台にする型」を縛れます(= mixin の中でその型のメソッドが使えます)- 型パラメータ
Tを中継すれば、mixin の中でもwidgetを正しい型で扱えます - 機能は変わらなくても、整理後の「気持ちよさ」が開発速度に効きます
mixin は最初「概念の説明」で読むより、「自分の重複コード」に当てて初めて意味が分かる類の機能だった。
非エンジニアでも、「同じコードが2箇所にある」が見つけられるなら、もう mixin を使う準備はできています。 そう思えたのが、この一連の整理の一番の収穫だったかもしれない。
個人開発はレビューしてくれる人もいませんし、「このコード大丈夫ですか?」と聞ける場所も少ないです。
でも「なんか散らかってきた」という感覚は、意外と正確なセンサーです。その感覚を信じて一度立ち止まる勇気は持っておくといいです。
整理した後のコードは、ちょっと好きになれた。
この記事は2026年4月時点の実装内容で書いています。Flutter のディレクトリ構成は流派が複数あるので、ここで書いた構成はあくまで「綴Photo の今の判断」として読んでください。アプリは継続開発中なので、現在の実装と細部が違っている可能性があります。