対象
- プログラミング初心者〜初級レベル
- Flutter はインストール済み、flutter create が実行できる人
この記事を読んでわかること
この教材を終えると、次のことができるようになります。
- 最小構成の Flutter アプリを自分で動かせる
- 「1 ファイルに全部書く」と何がつらくなるかを体感できる
- lib/screens/[画面名].dart に画面を分けて、UI とロジックをゆるく分離できる
- Model / View / ViewModel(MVVM)という“設計の名前”を理解できる
- 外部連携・ビジネスロジック・モデル定義の置き場所のイメージを持てる
- 将来 AI にコードを生成させるときにも、ファイルが肥大しにくい分割ルールを意識できる
全体像
- 動く:まずは lib/main.dart だけで 1 画面を動かす
- 汚くなる:あえて main.dart に機能を詰め込み、「読めない…」を体感
- 分ける:lib/screens/home_screen.dart を作って画面を外出し
- 名前を付ける:やっていることに「MVVM」という名前を貼る
- 広げる:外部連携・モデル・ビジネスロジック・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 にコードを書かせる際にも、この構造を意識しておくと、より精度の高い指示とリファクタリングが可能になります。
