🐦

Flutterで学ぶ詳細設計と実装

に公開

はじめに

本記事は、ジュニアレベルからステップアップを目指す方を対象としています。
プログラミングを始めたばかりの頃は、main() 関数にすべての処理を書いたり、「とにかく動けばいい」と責務を意識せず実装を進めてしまうことも多いでしょう。
個人開発であればそれでも問題ない場面もありますが、業務として開発を行う場合にはそれでは通用しません。

ソフトウェアの品質、開発プロセスの明瞭さ、チーム内の共有・保守・機能追加のしやすさといった観点では、一筆書きのような実装はすぐに限界を迎えます。
こうした問題を回避するためには、「適切な責務分離と設計」が必要です。

本記事では、FlutterのBlocパターンを題材に、詳細設計・実装・テストコードのつながりを解説しながら、設計の意義と実践的な取り組み方を紹介します。

なぜ設計が必要?

アプリを作り始めたばかりの頃、画面側(View)にすべての処理を書いてしまうことは珍しくありません。
APIの呼び出し、状態の更新、エラーハンドリングなどを全て StatefulWidget の中に詰め込んでいくと、最初はうまく動いていたアプリも、以下のような課題が次第に浮かび上がってきます。

・処理の流れが読みにくく、修正に時間がかかる
・テストを書こうにも、何をどこで検証すればいいか分からない
・UIとロジックが密結合になり、再利用や変更が困難になる
・レビューやチーム共有の際に意図が伝わらない

これらの問題は、設計段階で「どの責務をどこが持つか」を明確にしておけば、大幅に緩和できます。
つまり、「設計」とは、将来の混乱を未然に防ぐ“地図づくり”なのです。

分割なしでの実装と設計例

今回の題材として、四則演算ができる簡単な電卓アプリを作成してみます。

まずは、すべてのロジック・状態・UIを1つのファイル(main.dart)にまとめて実装してみた例です。

main.dart

// main.dart

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(title: 'Flat Calculator', home: CalculatorPage());
  }
}

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

  
  _CalculatorPageState createState() => _CalculatorPageState();
}

class _CalculatorPageState extends State<CalculatorPage> {
  String _display = '';
  String _operator = '';
  double? _firstOperand;

  void _onPressed(String value) {
    setState(() {
      if (value == 'C') {
        _display = '';
        _firstOperand = null;
        _operator = '';
      } else if (value == '+' || value == '-' || value == '*' || value == '/') {
        if (_display.isNotEmpty) {
          _firstOperand = double.tryParse(_display);
          _operator = value;
          _display = '';
        }
      } else if (value == '=') {
        if (_firstOperand != null && _display.isNotEmpty) {
          final secondOperand = double.tryParse(_display);
          if (secondOperand == null) {
            _display = 'Error';
            return;
          }
          double result;
          switch (_operator) {
            case '+':
              result = _firstOperand! + secondOperand;
              break;
            case '-':
              result = _firstOperand! - secondOperand;
              break;
            case '*':
              result = _firstOperand! * secondOperand;
              break;
            case '/':
              if (secondOperand == 0) {
                _display = 'Div by 0';
                return;
              }
              result = _firstOperand! / secondOperand;
              break;
            default:
              return;
          }
          _display = result.toString();
          _firstOperand = null;
          _operator = '';
        }
      } else {
        _display += value;
      }
    });
  }

  Widget _buildButton(String text) {
    return Expanded(
      child: Padding(
        padding: const EdgeInsets.all(4.0),
        child: ElevatedButton(
          onPressed: () => _onPressed(text),
          child: Text(text, style: TextStyle(fontSize: 24)),
        ),
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Calculator')),
      body: Column(
        children: [
          Container(
            padding: EdgeInsets.all(16),
            alignment: Alignment.centerRight,
            child: Text(_display, style: TextStyle(fontSize: 32)),
          ),
          Expanded(
            child: Column(
              children: [
                Row(children: ['7', '8', '9', '/'].map(_buildButton).toList()),
                Row(children: ['4', '5', '6', '*'].map(_buildButton).toList()),
                Row(children: ['1', '2', '3', '-'].map(_buildButton).toList()),
                Row(children: ['0', 'C', '=', '+'].map(_buildButton).toList()),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

どうでしょうか?
「ちょっとしたアプリ」のはずなのに、なんだかごちゃごちゃして見えませんか?
・UIとロジックが混在していて、読みづらい
・演算処理や状態がクラス内にべったり張り付いている
・画面やロジックを増やしたくなったとき、すぐに破綻しそう…

ちなみに、この構造をクラス図にしてみるとこんな感じです。

ご覧の通り、すべての責任が1つのクラスに集まりすぎており、拡張性・可読性・テスト性のすべてに課題があります。
このまま機能を追加していくと、状態の管理やロジックの切り出しが難しくなり、変更のたびにコード全体を追いかけなければならなくなります。
これは、UIとロジック、状態管理の責務が分離されていないことが原因です。

では、これらの責任を分けると、何がどう改善されるのでしょうか?
次章では、「責務分離」という観点から、この電卓アプリを再設計してみます。

責務分離

前章で見たように、すべての処理を1つのクラスにまとめてしまうと、実装はシンプルに見えても、保守・拡張・テストのたびに苦労することになります。
このような問題を避けるための基本的な考え方が「責務分離」です。

責務分離とは、「それぞれのパーツが果たすべき役割(責務)を明確にし、それ以外のことはやらせない」という設計の考え方です。
たとえば今回の電卓アプリであれば、以下のように分けられます。

機能・処理内容 責務を持つ層
ボタンの表示・UI更新 View(Widget)
ユーザー操作の受付と処理指示 イベント(Bloc Event)
状態の保持と演算処理 Bloc(ビジネスロジック)
状態の変化(表示データ) State(Bloc State)

・ UIは「どう表示するか」だけを考えればよくなる
・ 計算処理の単体テストが可能になる(UIを起動しなくてよい)
・ 状態遷移が見える化され、バグの原因が追いやすくなる
・ 役割ごとに変更できるようになる(UIだけ差し替えなど)

つまり、設計の段階で“役割ごとに分けておく”ことで、実装後のあらゆる作業が楽になるのです。

クラス図設計

責務分離に基づいて再設計した電卓アプリのクラス構成は、以下のようになります。

前章の「すべてが1つのクラスに集まった構造」と比べて、
この設計図からは役割ごとの責務分離が視覚的にも明確に読み取れます。

このクラス構成から得られるメリットは
・ UIとロジックが分離されている
 → UIを変更してもロジックには影響せず、逆も同様。保守や追加が容易になります。
・単体テストが可能になる
 → Bloc単体に対して、状態遷移や演算処理をピンポイントで検証できます。
・状態遷移が追いやすくなる
 → 明示的にイベント・状態を定義することで、アプリの「振る舞いの流れ」が把握しやすくなります。

Blocパターンを使うことで、アプリの構造が整理され、実装・テスト・保守のすべてが楽になります。
次章では、このクラス図をもとに実際のコードに落とし込んでいきます。

実装

前章で設計したクラス図をもとに、実際のコードを実装していきましょう。
ここでも大切なのは「責務を意識したファイル分割と役割の明確化」です。

ディレクトリ構成

まずは、今回の構成をざっくりと整理しておきます

lib/
├── main.dart                        // アプリのエントリポイント
├── ui/                              // UI関連
│   ├── calculator_page.dart         // ページ単位の構成
│   ├── calculator_view.dart         // UI構成と状態描画
│   └── calculator_button.dart       // ボタン部品
└── bloc/
    └── calculator_bloc.dart         // ビジネスロジック

pubspec.yamlの更新

Blocを利用するためにpubspec.yamlファイルを更新します。

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1 # 追加

main.dart

アプリの起動と Bloc の注入を行います。

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc/calculator_bloc.dart';
import 'ui/calculator_page.dart';

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

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => CalculatorBloc(),
        child: CalculatorPage(),
      ),
    );
  }
}

UI層

それぞれのWidgetは責務を小さく切り分けて設計しています。

calculator_page.dart

画面の構成と CalculatorView の呼び出しを行います。

// ui/calculator_page.dart
import 'package:flutter/material.dart';
import 'calculator_view.dart';

class CalculatorPage extends StatefulWidget {
  
  _CalculatorPageState createState() => _CalculatorPageState();
}

class _CalculatorPageState extends State<CalculatorPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Bloc Calculator')),
      body: CalculatorView(),
    );
  }
}

calculator_view.dart

状態の描画とボタン一覧のUIを担当します。

// ui/calculator_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/calculator_bloc.dart';
import 'calculator_button.dart';

class CalculatorView extends StatelessWidget {
  final List<String> _buttons = [
    '7','8','9','/',
    '4','5','6','*',
    '1','2','3','-',
    '0','C','=','+',
  ];

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        BlocBuilder<CalculatorBloc, CalculatorState>(
          builder: (context, state) {
            String display = '';
            if (state is CalculatorResult) {
              display = state.display;
            }
            return Container(
              alignment: Alignment.centerRight,
              padding: const EdgeInsets.all(16),
              child: Text(display, style: TextStyle(fontSize: 32)),
            );
          },
        ),
        Expanded(
          child: LayoutBuilder(
            builder: (context, constraints) {
              final buttonHeight = constraints.maxHeight / 4;
              final buttonWidth = constraints.maxWidth / 4;
              final aspectRatio = buttonWidth / buttonHeight;
              return GridView.count(
                childAspectRatio: aspectRatio,
                crossAxisCount: 4,
                children:
                    _buttons.map((label) {
                      return CalculatorButton(label: label);
                    }).toList(),
              );
            },
          ),
        ),
      ],
    );
  }
}

calculator_button.dart

押下されたボタンに応じてイベントをBlocに送信します。

// ui/calculator_button.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/calculator_bloc.dart';

class CalculatorButton extends StatelessWidget {
  final String label;

  const CalculatorButton({required this.label});

  
  Widget build(BuildContext context) {
    void _onTap() {
      final bloc = context.read<CalculatorBloc>();
      if (RegExp(r'^[0-9]$').hasMatch(label)) {
        bloc.add(InputDigit(label));
      } else if (label == 'C') {
        bloc.add(Clear());
      } else if (label == '=') {
        bloc.add(Calculate());
      } else {
        bloc.add(InputOperator(label));
      }
    }

    return Padding(
      padding: const EdgeInsets.all(4.0),
      child: ElevatedButton(
        onPressed: _onTap,
        child: Text(label, style: TextStyle(fontSize: 24)),
      ),
    );
  }
}

Bloc層

イベント・状態・Bloc本体を1ファイルに統合し、ロジックの中核を担います。
イベント・状態・Blocはそれぞれ別ファイルに切り出しても問題ありません。

calculator_bloc.dart

// calculator_bloc.dart

import 'package:flutter_bloc/flutter_bloc.dart';

/// --- Events ---
abstract class CalculatorEvent {}

class InputDigit extends CalculatorEvent {
  final String digit;
  InputDigit(this.digit);
}

class InputOperator extends CalculatorEvent {
  final String operator;
  InputOperator(this.operator);
}

class Calculate extends CalculatorEvent {}

class Clear extends CalculatorEvent {}

/// --- States ---
abstract class CalculatorState {}

class CalculatorInitial extends CalculatorState {}

class CalculatorResult extends CalculatorState {
  final String display;
  CalculatorResult(this.display);
}

/// --- Bloc ---
class CalculatorBloc extends Bloc<CalculatorEvent, CalculatorState> {
  String _input = '';
  String _operator = '';
  double? _firstOperand;

  CalculatorBloc() : super(CalculatorInitial()) {
    on<InputDigit>(_onInputDigit);
    on<InputOperator>(_onInputOperator);
    on<Calculate>(_onCalculate);
    on<Clear>(_onClear);
  }

  void _onInputDigit(InputDigit event, Emitter<CalculatorState> emit) {
    _input += event.digit;
    emit(CalculatorResult(_input));
  }

  void _onInputOperator(InputOperator event, Emitter<CalculatorState> emit) {
    _firstOperand = double.tryParse(_input);
    _operator = event.operator;
    _input = '';
  }

  void _onCalculate(Calculate event, Emitter<CalculatorState> emit) {
    final secondOperand = double.tryParse(_input);
    if (_firstOperand == null || secondOperand == null) {
      emit(CalculatorResult('Error'));
      return;
    }

    double result;
    switch (_operator) {
      case '+':
        result = _firstOperand! + secondOperand;
        break;
      case '-':
        result = _firstOperand! - secondOperand;
        break;
      case '*':
        result = _firstOperand! * secondOperand;
        break;
      case '/':
        if (secondOperand == 0) {
          emit(CalculatorResult('Div by 0'));
          return;
        }
        result = _firstOperand! / secondOperand;
        break;
      default:
        emit(CalculatorResult('Invalid op'));
        return;
    }

    _input = result.toString();
    _firstOperand = null;
    _operator = '';
    emit(CalculatorResult(_input));
  }

  void _onClear(Clear event, Emitter<CalculatorState> emit) {
    _input = '';
    _operator = '';
    _firstOperand = null;
    emit(CalculatorResult(''));
  }
}

Blocパターンを活用して UI・状態・ロジックの責務を分離することで以下のような効果が得られました。
・UIは状態に応じた描画のみを担当し、ロジックに触れずに済む
・演算処理や状態遷移はBlocに集約されており、変更や追加がしやすい
・イベントと状態が明示的に定義されているため、コード全体の見通しがよい
・テスト可能な構造が自然にできあがっており、UIを起動せずに検証が可能

小規模なアプリであっても、こうした設計を意識することで保守性・再利用性・テスト性が一気に高まります。

次章では、実際にこの設計がどのようにテストのしやすさにつながるかを、具体的なコードを交えて見ていきます。
「テストが書ける設計」とはどういうものなのか、ぜひ体感してみてください。

テストコード実装

設計を工夫することで、テストがしやすくなることを体感してみましょう。
今回は CalculatorBloc の単体テストを中心に実装していきます。

テスト用パッケージの追加

まずは bloc_test パッケージを dev_dependencies に追加します。
pubspec.yaml の設定を以下のように変更してください

pubspec.yaml

dev_dependencies:
  flutter_test:
    sdk: flutter
  bloc_test: ^10.0.0

テスト用ディレクトリの作成

テストコードは以下のように test/ ディレクトリに配置します。
テストコードを実装するためのディレクトリを用意します。

lib/
├── main.dart
├── ui/
│   └── ...
├── bloc/
│   └── calculator_bloc.dart
└── test/
    └── calculator_bloc_test.dart

Blocのテストコード実装

以下が Bloc の単体テストコードです。状態遷移の期待値を明確に書ける点が大きなメリットです。

test/calculator_bloc_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:sample_calculator/bloc/calculator_bloc.dart';

void main() {
  group('CalculatorBloc', () {
    late CalculatorBloc bloc;

    setUp(() {
      bloc = CalculatorBloc();
    });

    tearDown(() {
      bloc.close();
    });

    test('初期状態は CalculatorInitial', () {
      expect(bloc.state, isA<CalculatorInitial>());
    });

    blocTest<CalculatorBloc, CalculatorState>(
      '数字を1つ入力すると状態が "1" になる',
      build: () => CalculatorBloc(),
      act: (bloc) => bloc.add(InputDigit('1')),
      expect: () => [isA<CalculatorResult>().having((s) => s.display, 'display', '1')],
    );

    blocTest<CalculatorBloc, CalculatorState>(
      '2 + 3 = を入力すると状態が "5.0" になる',
      build: () => CalculatorBloc(),
      act: (bloc) {
        bloc
          ..add(InputDigit('2'))
          ..add(InputOperator('+'))
          ..add(InputDigit('3'))
          ..add(Calculate());
      },
      wait: const Duration(milliseconds: 10),
      expect: () => [
        isA<CalculatorResult>().having((s) => s.display, 'display', '2'),
        // オペレータ入力で表示は変わらない
        isA<CalculatorResult>().having((s) => s.display, 'display', '3'),
        isA<CalculatorResult>().having((s) => s.display, 'display', '5.0'),
      ],
    );

    blocTest<CalculatorBloc, CalculatorState>(
      '0で割った場合は "Div by 0" を返す',
      build: () => CalculatorBloc(),
      act: (bloc) {
        bloc
          ..add(InputDigit('9'))
          ..add(InputOperator('/'))
          ..add(InputDigit('0'))
          ..add(Calculate());
      },
      wait: const Duration(milliseconds: 10),
      expect: () => [
        isA<CalculatorResult>().having((s) => s.display, 'display', '9'),
        isA<CalculatorResult>().having((s) => s.display, 'display', '0'),
        isA<CalculatorResult>().having((s) => s.display, 'display', 'Div by 0'),
      ],
    );

    blocTest<CalculatorBloc, CalculatorState>(
      'Clearを押すと表示が空になる',
      build: () => CalculatorBloc(),
      act: (bloc) {
        bloc
          ..add(InputDigit('8'))
          ..add(Clear());
      },
      expect: () => [
        isA<CalculatorResult>().having((s) => s.display, 'display', '8'),
        isA<CalculatorResult>().having((s) => s.display, 'display', ''),
      ],
    );
  });
}

テストの実行

Flutterのディレクトリに移動しターミナルからコマンドでテストを実行します。

cd sample_calculator
flutter test

テストの実行結果がターミナルに出力されます。

00:12 +0: CalculatorBloc 初期状態は CalculatorInitial                                00:12 +1: CalculatorBloc 初期状態は CalculatorInitial
00:12 +1: CalculatorBloc 数字を1つ入力すると状態が "1" になる
00:12 +2: CalculatorBloc 数字を1つ入力すると状態が "1" になる
00:12 +2: CalculatorBloc 2 + 3 = を入力すると状態が "5.0" になる
00:12 +3: CalculatorBloc 2 + 3 = を入力すると状態が "5.0" になる
00:12 +3: CalculatorBloc 0で割った場合は "Div by 0" を返す
00:12 +4: CalculatorBloc 0で割った場合は "Div by 0" を返す
00:12 +4: CalculatorBloc Clearを押すと表示が空になる           
00:12 +5: CalculatorBloc Clearを押すと表示が空になる           
00:12 +5: All tests passed!

Blocテストのメリットまとめ

・Bloc単体でビジネスロジックの動作確認ができる
→ UIに依存しないのでテストが高速・明確。

・仕様の変更に強くなる
→ テストがあることでリファクタ時も安心。

・設計の妥当性を客観的に証明できる
→ 「このBlocは正しく状態遷移する」と自信を持てる。

まとめ

本記事では、Flutterを用いた簡単な電卓アプリを題材にして、

・設計がなぜ必要か?
・Blocによる責務分離が何をもたらすのか?
・実装やテストにどう活きるのか?

というテーマで解説を行ってきました。

最初はすべてを1つのファイルで完結させた例から始めましたが、
責務を意識して設計・実装・テストを分離することで、

・コードの保守性・拡張性が高まる
・UIとロジックの独立性が保てる
・テスト可能な構造が自然に生まれる

といったメリットが得られることが確認できました。
もしあなたが「動くものは作れるようになったけれど、その後どう構造化すべきかわからない」と感じているなら、今回のような設計→実装→テストの流れを実践してみることをおすすめします。

小さなアプリでも、設計の意識ひとつで大きな違いが生まれます。
まずは身近なアプリから、責務を分けた設計にチャレンジしてみてください。

今回実装したGitHubのリポジトリはコチラ

Discussion