1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ChangeNotifierを使ったObserverパターンの実装

Posted at

はじめに

Flutterで開発していくと、イベント、状態管理、再描画が密接になり、コードを切り離そうとしてもうまく切り離せない事がしばしば。そこで、UIとビジネスロジックを分離しつつ、効率的に状態管理できるObserverパターンを導入します。
FlutterではChangeNotifierを使って実装する事が一般的なため、サンプルコードを実装していきます。

そもそもObserverパターンって何??

Observerパターン(監視者パターン)は、オブジェクト指向プログラミングにおけるデザインパターンの一つです。このパターンは、一つのオブジェクト(被観測者/Subject)が持つデータや状態が変更されたときに、その変更を他のオブジェクト(監視者/Observer)に自動的に通知する仕組みを提供します。

Observerパターンの主な特徴

  1. 対象(Subject)
     •状態を持つオブジェクトで、状態が変わると監視者に通知を行います
  2. 監視者(Observer)
     •対象の状態を監視しているオブジェクトで、対象から通知を受け取ると、自身の状態や振る舞いを更新します。
  3. 通知の仕組み:
     •被観測者(Subject)は自分の状態が変更されると、すべての監視者(Observer)にその変更を通知します。これにより、監視者は最新の状態に基づいて自身の動作を変更できます。

Observerパターンの流れ

1.	登録:監視者(Observer)は、被観測者(Subject)に対して「状態の変更を知りたい」と登録します。
2.	状態変更:被観測者(Subject)の状態が何らかの理由で変更されます。
3.	通知:被観測者(Subject)は、自分に登録されているすべての監視者(Observer)に「状態が変わったよ!」と通知します。
4.	更新:通知を受け取った監視者(Observer)は、新しい状態を反映して自分自身を更新します。

Observerパターンのメリット

 •疎結合:SubjectとObserverは独立しており、ObserverはSubjectの実装に依存しません。このため、システムの柔軟性が向上します。
 •自動更新:監視者は、被観測者からの通知を受けることで、自動的に最新の状態に基づいた処理を行います。これにより、手動での状態管理や同期の必要が減ります。

設計

ディレクトリ構成
~/develop/changenotifier_counter (master)$ tree lib 
lib
├── main.dart
├── models
│   └── counter.dart
├── providers
│   └── counter_provider.dart
├── screens
│   └── home.dart
└── widgets
    └── custom_button.dart

5 directories, 5 files

成果物

counterアプリを作成します。
counter.gif

プロダクトコード

lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/counter_provider.dart';
import 'screens/home.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<CounterProvider>(
      create: (_) => CounterProvider(),
      child: MaterialApp(
        title: 'ChangeNotifier Counter',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: HomeScreen(),
      ),
    );
  }
}

ここでは利用予定のProviderを設定しています。

lib/screens/home.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/counter_provider.dart';
import '../widgets/custom_button.dart'; // CustomButtonをインポート

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counterProvider = Provider.of<CounterProvider>(context); //状態管理を行うためにProviderパッケージを使用する

    return Scaffold(
      appBar: AppBar(
        title: Text('Counter: ${counterProvider.counter}'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${counterProvider.counter}',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          CustomButton(
            label: 'Increment',
            onPressed: counterProvider.increment,
          ),
          SizedBox(width: 10), // ボタン間のスペース
          CustomButton(
            label: 'Decrement',
            onPressed: counterProvider.decrement,
          ),
        ],
      ),
    );
  }
}

→画面全体を表示する画面です。ここでは、状態管理Providerの設定、CustomButtonを配置しています。

lib/widgets/custom_button.dart
import 'package:flutter/material.dart';

class CustomButton extends StatelessWidget {
  final String label;
  final VoidCallback onPressed;

  CustomButton({required this.label, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: Text(label),
    );
  }
}

→カスタムボタンを設定しています。

lib/providers/counter_provider.dart
// lib/providers/counter_provider.dart
import 'package:flutter/material.dart';
import '../models/counter.dart';

class CounterProvider extends ChangeNotifier {
  final Counter _counter = Counter();

  int get counter => _counter.count;

  void increment() {
    _counter.increment();
    notifyListeners(); // 値の変更を通知
  }

  void decrement() {
    _counter.decrement();
    notifyListeners(); // 値の変更を通知
  }
}

→値の変更を通知しています。ここでは通知とcounterの値を更新する処理をしています。
Counterの状態管理のincrementやdecrementなどのビジネスロジックは、Counterで定義しています。

lib/models/counter.dart
class Counter {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
  }

  void decrement() {
    _count--;
  }
}

→ここではカウンターに関する状態と状態を変更する動作が実装されています。

テストコードの実装

ディレクトリ構成
~/develop/changenotifier_counter$ tree test 
test
├── providers
│   └── counter_provider_test.dart
├── screens
│   └── home_test.dart
└── widgets
    └── custom_button.dart
test/screens/home_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:changenotifier_counter/providers/counter_provider.dart';
import 'package:changenotifier_counter/screens/home.dart';

void main() {
  testWidgets('HomeScreenのウィジェットテスト increment decrement',
      (WidgetTester tester) async {
    // Arrange: CounterProviderを提供し、HomeScreenをビルド
    await tester.pumpWidget(
      ChangeNotifierProvider(
        create: (_) => CounterProvider(),
        child: MaterialApp(
          home: HomeScreen(),
        ),
      ),
    );

    // Assert: 初期状態でカウンターの値が「0」であることを確認
    expect(find.text('0'), findsOneWidget);
    expect(find.text('You have pushed the button this many times:'),
        findsOneWidget);

    // Act: Incrementボタンをタップ
    await tester.tap(find.text('Increment'));
    await tester.pump(); // ウィジェットをリビルド

    // Assert: カウンターの値が「1」になることを確認
    expect(find.text('1'), findsOneWidget);

    // Act: Decrementボタンをタップ
    await tester.tap(find.text('Decrement'));
    await tester.pump(); // ウィジェットをリビルド

    // Assert: カウンターの値が再び「0」になることを確認
    expect(find.text('0'), findsOneWidget);
  });
}
test/widgets/custom_button.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:changenotifier_counter/widgets/custom_button.dart';

void main() {
  testWidgets('CustomButtonのウィジェットテスト', (WidgetTester tester) async {
    // Arrange
    var wasPressed = false;
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: CustomButton(
            label: 'Test Button',
            onPressed: () {
              wasPressed = true;
            },
          ),
        ),
      ),
    );

    // Act
    await tester.tap(find.text('Test Button'));
    await tester.pump(); // ウィジェットをリビルド

    // Assert
    expect(wasPressed, isTrue); // ボタンが押されたかどうかを確認
  });
}
test/providers/counter_provider_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:changenotifier_counter/providers/counter_provider.dart';

void main() {
  group('CounterProvider Tests', () {
    test('初期値が0であることを確認', () {
      // Arrange
      final counterProvider = CounterProvider();
      // Assert
      expect(counterProvider.counter, 0);
    });

    test('increment時、1になることを確認', () {
      // Arrange
      final counterProvider = CounterProvider();

      // Act
      counterProvider.increment();

      // Assert
      expect(counterProvider.counter, 1);
    });

    test('decrement時、-1になることを確認', () {
      // Arrange
      final counterProvider = CounterProvider();
      // Act
      counterProvider.decrement();

      // Assert
      expect(counterProvider.counter, -1);
    });
  });
}

感想

ObserperパターンはReactやバックエンドのコードでは書いていたんですが、Flutterでは初でした。
勉強になりました!!!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?