Flutter入門:フォルダ構成と設計パターンの基本|動く→汚くなる→分ける→名前を付けるで学ぶMVVM実践 のサムネイル

Flutter入門:フォルダ構成と設計パターンの基本|動く→汚くなる→分ける→名前を付けるで学ぶMVVM実践

更新:

The Prince Academy株式会社・Seino AI・DX Lab

対象

  • プログラミング初心者〜初級レベル
  • Flutter はインストール済み、flutter create が実行できる人

この記事を読んでわかること

この教材を終えると、次のことができるようになります。

  1. 最小構成の Flutter アプリを自分で動かせる
  2. 「1 ファイルに全部書く」と何がつらくなるかを体感できる
  3. lib/screens/[画面名].dart に画面を分けて、UI とロジックをゆるく分離できる
  4. Model / View / ViewModel(MVVM)という“設計の名前”を理解できる
  5. 外部連携・ビジネスロジック・モデル定義の置き場所のイメージを持てる
  6. 将来 AI にコードを生成させるときにも、ファイルが肥大しにくい分割ルールを意識できる

全体像

  1. 動く:まずは lib/main.dart だけで 1 画面を動かす
  2. 汚くなる:あえて main.dart に機能を詰め込み、「読めない…」を体感
  3. 分ける:lib/screens/home_screen.dart を作って画面を外出し
  4. 名前を付ける:やっていることに「MVVM」という名前を貼る
  5. 広げる:外部連携・モデル・ビジネスロジック・AI 時代の分割ルールへ

2. ステージ 1:「まずは動く」最小アプリ

2-1. 何を作るか

  • ボタンを押すと数字が 1 ずつ増える、最小カウンターアプリ

2-2. 新しく出てくる概念

Widget(ウィジェット)

  • 画面を構成する部品。テキスト、ボタン、画面全体も含めて「全部 Widget」です。 StatelessWidget / StatefulWidget

  • StatelessWidget:内部状態を持たない部品(常に同じ見た目)

  • StatefulWidget:内部状態(カウンターなど)を持ち、値が変わる部品

2-3. 最小構成のmain.dart

// lib/main.dart

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

/// アプリ全体のルートウィジェット
/// 役割: MaterialApp を返し、最初に表示する画面を指定する。
/// 入力: なし
/// 出力: MaterialApp ウィジェット
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Simple Counter',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      home: const CounterPage(),
    );
  }
}

/// カウンター画面
/// 役割: 数字とボタンを表示し、ボタン押下でカウントアップする。
/// 入力: なし
/// 出力: Scaffold ウィジェット(1画面分の UI)
class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0; // 画面の「状態」

  void _increment() {
    setState(() {
      _count += 1;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Simple Counter'),
      ),
      body: Center(
        child: Text(
          'Count: $_count',
          style: const TextStyle(fontSize: 32),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _increment,
        child: const Icon(Icons.add),
      ),
    );
  }
}

2-4. ポイント

  • 「1 ファイルだけでも、アプリは動く」
  • Widget と状態のイメージがつかめれば、次のステップに進む準備完了です。

3. ステージ 2:あえて「汚くする」― 1 ファイル地獄

3-1. ここでやること

先ほどのカウンターに、あえて機能を 全部 main.dart に足していきます。

  • テキスト入力フォーム
  • 入力履歴をリスト表示
  • 設定用のスイッ
// lib/main.dart (抜粋)
// ※フォームやリスト、設定まで全部ここに押し込む

class _CounterPageState extends State<CounterPage> {
  int _count = 0;
  final TextEditingController _controller = TextEditingController();
  final List<String> _logs = [];
  bool _isDebugMode = false;

  // ... onChanged や onSubmitted などのロジックも全部ここに書く
  // ... さらに ListView.builder, Switch, TextField なども build に全部混ぜる
}

3-2. 体験して欲しいこと

スクロールが長くなり、

  • 「どこに何が書いてあるか分からない」
  • 「バグが出ると全部読まされる」
  • これがいわゆる “God ファイル(巨大 main.dart)” です。

3-3. まとめ

  • 「動く」は正しい第一歩。
  • ただし、このまま画面が 2、3… と増えると 破綻する未来 が見える。
  • だからこそ、分けたくなる理由をここで体感してもらいます。

4. ステージ 3:「分ける」―lib/screens/home_screen.dartへ

4-1. フォルダ構成を一段階レベルアップ

まずは画面単位でファイルを分けます。

lib/
  main.dart
  screens/
    home_screen.dart

4-2.main.dartは「アプリの入口だけ」にする

// lib/main.dart

import 'package:flutter/material.dart';
import 'screens/home_screen.dart';

void main() {
  runApp(const MyApp());
}

/// 役割: アプリ全体のテーマと最初の画面を決める
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Folder Structure Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      home: const HomeScreen(),
    );
  }
}

4-3.home_screen.dartに画面ロジックを移す

// lib/screens/home_screen.dart

import 'package:flutter/material.dart';

/// 役割: アプリのメイン画面(カウンター + 簡単なメモ)
/// 入力: なし
/// 出力: 1画面分の UI(Scaffold)
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int _count = 0;
  final TextEditingController _controller = TextEditingController();
  final List<String> _logs = [];

  void _increment() {
    setState(() {
      _count += 1;
    });
  }

  void _addLog() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    setState(() {
      _logs.add(text);
      _controller.clear();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home (Single Screen)'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Text(
              'Count: $_count',
              style: const TextStyle(fontSize: 32),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _increment,
              child: const Text('Increment'),
            ),
            const SizedBox(height: 24),
            TextField(
              controller: _controller,
              decoration: const InputDecoration(
                labelText: 'Log something',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 8),
            ElevatedButton(
              onPressed: _addLog,
              child: const Text('Add Log'),
            ),
            const SizedBox(height: 16),
            Expanded(
              child: ListView.builder(
                itemCount: _logs.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    title: Text(_logs[index]),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

4-4. ポイント

「画面ごとにファイルを分ける」だけで、探す場所がはっきりし、全体を把握しやすくなる。ここではまだ「UI とロジックの厳密な分離」は意識しなくて構いません。


5. ステージ 4:UI とロジックをもう一段「分ける」

ここから、「UI 部分」と「画面ロジック」を少しだけ区別し始めます。

5-1. 何を分けるのか

  • HomeScreen(画面の骨格)
  • 細かい UI 部品(セクションごとの Widget)

を別ファイルに分けます。

lib/
  main.dart
  screens/
    home_screen.dart        // 画面の骨格
    home_screen_ui.dart     // 画面内の部品(セクションなど)

5-2.home_screen_ui.dart(UI 部品)の例

// lib/screens/home_screen_ui.dart

import 'package:flutter/material.dart';

/// 役割: カウンター部分のUIのみ担当する StatelessWidget
/// 入力: 現在のカウント値, 押下時コールバック
/// 出力: カウンター表示 + ボタンの UI
class CounterSection extends StatelessWidget {
  final int count;
  final VoidCallback onIncrement;

  const CounterSection({
    super.key,
    required this.count,
    required this.onIncrement,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          'Count: $count',
          style: const TextStyle(fontSize: 32),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: onIncrement,
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

/// 役割: ログ入力とリスト表示の UI を担当する StatelessWidget
/// 入力: TextEditingController, ログ一覧, 追加ボタン押下時コールバック
/// 出力: フォーム + ログ一覧の UI
class LogSection extends StatelessWidget {
  final TextEditingController controller;
  final List<String> logs;
  final VoidCallback onAdd;

  const LogSection({
    super.key,
    required this.controller,
    required this.logs,
    required this.onAdd,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: controller,
          decoration: const InputDecoration(
            labelText: 'Log something',
            border: OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 8),
        ElevatedButton(
          onPressed: onAdd,
          child: const Text('Add Log'),
        ),
        const SizedBox(height: 16),
        Expanded(
          child: ListView.builder(
            itemCount: logs.length,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text(logs[index]),
              );
            },
          ),
        ),
      ],
    );
  }
}

5-3.home_screen.dartから呼び出す

// lib/screens/home_screen.dart

import 'package:flutter/material.dart';
import 'home_screen_ui.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  int _count = 0;
  final TextEditingController _controller = TextEditingController();
  final List<String> _logs = [];

  void _increment() {
    setState(() {
      _count += 1;
    });
  }

  void _addLog() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    setState(() {
      _logs.add(text);
      _controller.clear();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home (Separated UI)'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            CounterSection(
              count: _count,
              onIncrement: _increment,
            ),
            const SizedBox(height: 24),
            Expanded(
              child: LogSection(
                controller: _controller,
                logs: _logs,
                onAdd: _addLog,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

5-4. ポイント

  • 状態 _count, _logs やロジック _increment, _addLog は State クラス側 に残し、
  • UI 部品は 引数を通じて値やコールバックを受け取る
  • 実質的に「UI」と「ロジック」を分ける第一歩

6. ステージ 5:「名前を付ける」― MVVM に触れる

ここまででやってきた「分け方」に、設計パターンとしての名前を与えます。

6-1. MVVM とは

  • Model – View – ViewModel の略
  • **Model:**データの「形」を表すクラス。例:Todo, User, Product など。
  • **View(ビュー):**実際の画面。Flutter では Widget がこれに相当。例:HomeScreen, TodoListScreen。
  • **ViewModel(ビューモデル):**View が使いやすい形にデータを整える。画面からの操作(ボタン押下など)に応じて Model や Service を呼ぶ役割を持つ中間層です。

6-2. Flutter でのフォルダ構成の一例

lib/
  screens/          // View(画面)
    home/
      home_screen.dart
  viewmodels/       // ViewModel(画面ロジック)
    home/
      home_view_model.dart
  models/           // Model(データの形)
    todo.dart
  services/         // 外部連携(API など)
    todo_service.dart

6-3. シンプルな Model / ViewModel の例

// lib/models/todo.dart

/// 役割: Todo データの形を定義するモデル
/// 入力: id, title, isDone
/// 出力: 不変な Todo オブジェクト
class Todo {
  final String id;
  final String title;
  final bool isDone;

  const Todo({
    required this.id,
    required this.title,
    this.isDone = false,
  });

  Todo toggle() => Todo(
        id: id,
        title: title,
        isDone: !isDone,
      );
}
// lib/viewmodels/home/home_view_model.dart

import 'package:flutter/foundation.dart';
import '../../models/todo.dart';

/// 役割: Home 画面用の状態とロジックを管理する ViewModel
/// 入力: なし(本来はサービスなどを DI する)
/// 出力: 画面で利用しやすい Todo リストや操作メソッド
class HomeViewModel extends ChangeNotifier {
  List<Todo> _todos = [];

  List<Todo> get todos => List.unmodifiable(_todos);

  void addTodo(String title) {
    if (title.trim().isEmpty) return;
    _todos = [
      ..._todos,
      Todo(
        id: DateTime.now().millisecondsSinceEpoch.toString(),
        title: title.trim(),
      ),
    ];
    notifyListeners();
  }

  void toggleTodo(String id) {
    _todos = _todos
        .map((todo) => todo.id == id ? todo.toggle() : todo)
        .toList();
    notifyListeners();
  }
}

※ View(HomeScreen)側は、ChangeNotifierProvider などの仕組みを使って ViewModel を利用する構成にできます(ここでは概念紹介にとどめます)。


7. ステージ 6:外部連携・変数定義・ビジネスロジック

7-1. 外部連携(Service 層)のイメージ

HTTP API, Firebase, ローカル DB など、「アプリの外の世界」と話す担当を サービス層にまとめる。

// lib/services/todo_service.dart

/// 役割: Todo データの取得・保存など、外部とのやり取りを担当するサービス
/// 入力: (本来は HTTP クライアント等を受け取る)
/// 出力: Todo の一覧や更新結果
class TodoService {
  Future<List<Map<String, dynamic>>> fetchTodos() async {
    // 本来は HTTP などを使うが、ここではダミー実装
    await Future.delayed(const Duration(milliseconds: 300));
    return [
      {'id': '1', 'title': 'Sample from server', 'isDone': false},
    ];
  }
}
  • ViewModel から TodoService を呼び出し、Model に変換して保持する、という流れになります。

7-2. 変数定義・モデル・ビジネスロジックの整理

  • Model(models/):データの形を定義するクラス群
  • ViewModel(viewmodels/):画面用の状態・ビジネスルールを持つ
  • Service(services/):外部 API / DB とのやり取り
  • View(screens/):UI 描画に専念

この「責任の分担」を押さえると、React、Next.js、Vue など他のフレームワークに移ったときもスムーズです。


8. ステージ 7:AI にコードを生成させるときの分割ルール

AI 時代ならではの、フォルダ構成とファイル分割のコツをまとめます。

8-1. なぜファイルを分けるのか(AI 視点)

1 ファイルが巨大だと:

  • AI が一度に読み込めるコンテキストを超える
  • 「この部分だけ直して」が伝わりにくい

小さく分けておけば:

  • 「HomeScreen の UI だけ改善して」
  • 「HomeViewModel のビジネスロジックを整理して」

といった ピンポイント指示 が出しやすくなります。

8-2. 具体的なルール例

1ファイルの目安行数

  • 200〜300 行を超えたら、「分割を検討するサイン」とする。

画面と部品の分離

  • lib/screens/home/home_screen.dart:画面の骨格
  • lib/screens/home/home_screen_ui.dart:画面内の部品
  • 複数画面で使う共通部品は lib/widgets/

繰り返し UI は Widget / 変数にまとめる

  • 同じカード UI を何度もコピペせず、
  • PrimaryCard, TodoListItem のような Widget にする
  • 或いは、見た目だけ違うパターンはパラメータで切り替える

AI への依頼テンプレ例

  • 「lib/screens/home/ の UI ファイルを、行数が多い順に分割し、再利用しやすい Widget に整理してください」
  • 「lib/viewmodels/home/home_view_model.dart から、API 呼び出し部分を TodoService に切り出してください」

9. 他の言語・環境でも通用する考え方

Flutter 以外でも、「役割ごとにファイル・フォルダを分ける」という思想はそのまま使えます。

React / Next.js

  • components/(View)
  • hooks/ 、/ stores/(ViewModel 相当)
  • lib/ / services/(外部連携・ビジネスロジック)
  • types/(Model)
  • Vue / Nuxt
  • 共通するキーワードは「UI とロジックの分離」「データの形をモデルとして定義」「外部連携は専用の場所へ」です。

10. まとめ

動く → 汚くなる → 分ける → 名前を付ける**:**このステップで進むことで、初心者でも自然に設計の必要性を理解できます。最初は main.dart 一枚から始めて構いません。コードが長くなって「読みにくい」と感じたら、

  • 画面ごとにファイルを分け、
  • 画面の中身を Widget に分け、
  • ゆくゆくは MVVM のような名前付きパターンに育てていけば十分です。
  • AI にコードを書かせる際にも、この構造を意識しておくと、より精度の高い指示とリファクタリングが可能になります。