綴Photo の勉強ノートに戻る
Flutter技術2026.04.16

「また2箇所直してる」と気づいて、Flutter のファイル構成と mixin で整理した話

FlutterDartmixinリファクタアーキテクチャRiverpod綴Photo

綴Photo のモバイル版に、Desktop 対応を足したとき、コードの構造が少しずつ散らかり始めた。

ある機能を直すたびに「また同じ場所を2箇所直してる」という感覚が来て、その感覚を信じて整理に踏み切った話。整理は2段階に分かれている。

  1. ファイル構成のリファクタ(分岐のためだけのファイルを消し、Mobile/Desktop の役割分担を明確化)
  2. mixin の切り出し(Mobile/Desktop で共通だったセッション管理ロジックを TsuzuruSessionMixin として1箇所に集約)

非エンジニア目線で「mixin って何?」のレベルから書く。with/on/型パラメータ T あたりまで踏み込んで、自分が手を動かしながら学んだ感覚で残す。


この記事の前提:AI に書かせていたら、同じコードが2箇所にできていた

正直に書いておくと、当時の俺は AI にかなりコードを書かせていた。

Desktop 対応を足したときも、Mobile 側のコードを横に並べて「同じ機能を Desktop 用にもう一度書いて」と頼むような進め方をしていた。雑なやり方だと自覚はあったけど、まず動くものを見たかった。

結果、同じセッション管理が2つの画面クラスに並んだ。

同じセッション管理が、画面クラス2つに並んでいる。カーソルとフィードバックは画面 State のメモリ。保存しない。パスは端末 SQLite の passed_photos。綴りは meanings。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 のファイル1つ。画面クラスは with で取り込む。カーソルは画面 State のメモリ。パスは passed_photos、綴りは meanings(端末 SQLite)。mixin のファイルはロジック。セッションそのものは保存しない。tsuzuru_session_mixin.dartwidgets/shared/パス・アンドゥ・保存はここ1箇所withwithtsuzuru_mobile.dartMobile の UItsuzuru_desktop.dartDesktop の UI画面クラスは別物のまま残す。直すのは上の1ファイル。
dart
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 側はこう書く。

dart
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 が持っている道具。

dart
mixin TsuzuruSessionMixin<T extends ConsumerStatefulWidget>
    on ConsumerState<T> { ... }   // ← 「ConsumerState の上でしか動かない」シール

だから「俺は ConsumerState の上でしか動かないよ(=取り込む相手は ConsumerState であること)」とシールを貼っておきます。こう宣言しておくと、mixin の中で ref や setState を当たり前に使えます。逆に貼っていないと、Dart は「この mixin の中で ref が使える保証がない」と判断して、ref が見えずエラーになります。

ポイントは、これが**「実行中のこういう状況のとき」みたいな話じゃないこと。「取り込む相手のクラスの種類(型)**」を縛るシールで、with で取り込もうとした瞬間にチェックされます。


詰まったポイント2:型パラメータ T の渡し方

もう1個ハマったのが、あちこちに出てくる T だ。

dart
mixin TsuzuruSessionMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>

T の正体は、「画面の名前を後から書き込む空欄」 だった。

Mobile の画面と Desktop の画面は別物で、状態管理クラスの型も別です。同じ mixin を両方に差し込みたいです。でも、動作環境シールに on ConsumerState<TsuzuruMobile> と画面名を決め打ちしたら、Mobile にしか差せなくなります。そこで画面名を空欄にしておきます。その空欄の名前が T。

空欄 T に、取り込む側が自分の画面名を書き込む。T はソース上の空欄。DB にも prefs にも残らない。TsuzuruSessionMixin<T>on ConsumerState<T>T は画面名の空欄T に TsuzuruMobileT に TsuzuruDesktop_TsuzuruMobileStateConsumerState<TsuzuruMobile>widget が TsuzuruMobile 型_TsuzuruDesktopStateConsumerState<TsuzuruDesktop>widget が TsuzuruDesktop 型画面名を決め打ちすると、片方にしか差せない。
dart
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 の今の判断」として読んでください。アプリは継続開発中なので、現在の実装と細部が違っている可能性があります。