5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Flutter のステート管理4パターン比較

Last updated at Posted at 2020-07-13

概要

Flutter のアプリアーキテクチャ、ステート管理について学ぶために、メモ帳アプリを作っていくつかのステート管理パターンを実装しました。

どのパターンにおいても基本的には同じことをしています。

  • 上位の Widget から Provider でモデル層を注入する
  • 下位の Widget を Builder で生成する。Builder はモデルの変化を監視して更新する
  • ユーザー入力などをモデル層に伝えるときは context からモデルを取得する

内容

  • ChangeNotifier
  • BLoC (rxdart のみ)
  • BLoC (flutter_bloc を使用)
  • state_notifier

ソースコード

ChangeNotifier

公式のドキュメント Simple app state management 通りの実装です。

Model

ChangeNotifier のサブクラスとして Model を実装し、状態が変化したときに notifyListeners() を呼びます。

class Note {
  final int id;
  final String text;
  final DateTime createdAt = DateTime.now();

  Note(this.id, this.text);
}

class NoteModel extends ChangeNotifier {
  final List<Note> _notes = [];

  UnmodifiableListView<Note> get notes => UnmodifiableListView(_notes);

  void add(Note note) {
    _notes.add(note);
    notifyListeners();
  }

  Note create() {
    final uid = DateTime.now().millisecondsSinceEpoch;
    final note = Note(uid, "");
    add(note);
    return note;
  }

  void remove(int id) {
    _notes.removeWhere((note) => note.id == id);
  }

  void update(int id, String text) {
    remove(id);
    add(Note(id, text));
  }
}

Provider

ChangeNotifierProvider で Model を注入します。

main.dart
void main() {
  runApp(
      ChangeNotifierProvider(create: (context) => NoteModel(), child: MyApp()));
}

Widget

Consumer を使って main() で注入した Model を取得します。

class NoteList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<NoteModel>(builder: (context, noteModel, child) {
      final cells = noteModel.notes.map((e) => NoteCell(e)).toList();
      return ListView(children: cells);
    });
  }
}

Event

Provider.of でモデルを取得し、モデルのメソッドを呼びます。

class AddButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoButton(
      onPressed: () {
        final noteModel = Provider.of<NoteModel>(context, listen: false);
        noteModel.create();
      },
      child: Text("Add"),
    );
  }
}

BLoC

RxDart を使います。ChangeNotifier と違って変更の通知は Rx の仕組みを使うので BLoC 自体は親クラスがありません。

diff https://github.com/ryohey/flutter_memo/pull/2/files

BLoC

本来は NoteRepository を用意して実装を隠蔽すべきだと思いますが、冗長なので BLoC に実装しています。
入力は Sink、出力は Stream として公開します。入力に対する処理は NoteBloc 自身が入力のストリームを監視して行います。ここではコンストラクタで実装しています。

class NoteBloc {
  final _notes = BehaviorSubject<List<Note>>.seeded([]);
  final _addNoteController = PublishSubject<Note>();
  final _updateNoteController = PublishSubject<Note>();
  final _removeNoteController = PublishSubject<int>();

  NoteBloc() {
    _addNoteController.stream.listen((event) {
      _notes.add(_notes.value + [event]);
    });

    _updateNoteController.stream.listen((event) {
      final newNotes =
          _notes.value.map((e) => e.id == event.id ? event : e).toList();
      _notes.add(newNotes);
    });

    _removeNoteController.stream.listen((id) {
      final newNotes = _notes.value.where((note) => note.id != id).toList();
      _notes.add(newNotes);
    });
  }

  void dispose() {
    _notes.close();
    _addNoteController.close();
    _updateNoteController.close();
    _removeNoteController.close();
  }

  Sink<Note> get addNote => _addNoteController.sink;
  Sink<Note> get updateNote => _updateNoteController.sink;
  Sink<int> get removeNote => _removeNoteController.sink;
  Stream<List<Note>> get notes => _notes.stream;
}

Provider

provider パッケージProvider を使います。

void main() {
  runApp(Provider(create: (context) => NoteBloc(), child: MyApp()));
}

Widget

Provider.of を使って BLoC を取得します。StreamBuilder を使って、BLoC が提供するストリームに応じて Widget が更新されるようにします。StreamBuilder の builder コールバックは最初に null が入ってくるので気をつけます。なんでこうなるのか誰か教えて下さい。

class NoteList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final noteBloc = Provider.of<NoteBloc>(context, listen: false);
    return StreamBuilder<List<Note>>(
        stream: noteBloc.notes,
        builder: (context, notes) {
          final cells = (notes?.data ?? []).map((e) => NoteCell(e)).toList();
          return ListView(children: cells);
        });
  }
}

Event

Provider.of で BLoC を取得します。
Model と違って直接メソッドを呼び出さず、入力として公開されている Sink の add メソッドを呼びます。

class AddButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoButton(
      onPressed: () {
        final noteBloc = Provider.of<NoteBloc>(context, listen: false);
        final note = Note.create();
        noteBloc.addNote.add(note);
      },
      child: Text("Add"),
    );
  }
}

flutter_bloc

flutter_bloc パッケージを使って、上記の BLoC パターンをさらに書き換えます。

BLoC

Bloc クラスのサブクラスとして実装します。Bloc クラスは、先程の例と違ってそれ自身が単一の入力、出力ストリームのようになっています。自分でプロパティを記述する必要はなく、ステートの型 (List<Note>) を記述するだけでよくなります。
入力も単一のストリームになるので、入力の種類を判別するためにイベントの型 (NoteEvent) を定義します。
イベントのハンドリングは mapEventToState で行います。event の型を判別して、更新されたステートを生成して返します。

abstract class NoteEvent {}

class AddNote extends NoteEvent {
  final Note note;
  AddNote(this.note);
}

class UpdateNote extends NoteEvent {
  final Note note;
  UpdateNote(this.note);
}

class RemoveNote extends NoteEvent {
  final int id;
  RemoveNote(this.id);
}

class NoteBloc extends Bloc<NoteEvent, List<Note>> {
  NoteBloc() : super([]);

  @override
  Stream<List<Note>> mapEventToState(NoteEvent event) async* {
    if (event is AddNote) {
      yield state + [event.note];
    } else if (event is UpdateNote) {
      yield state.map((e) => e.id == event.note.id ? event.note : e).toList();
    } else if (event is RemoveNote) {
      yield state.where((note) => note.id != event.id).toList();
    }
  }
}

Provider

flutter_bloc パッケージの BlocProvider を使います。provider パッケージの Provider を使った場合は次のエラーになります。 Tried to use Provider with a subtype of Listenable/Stream (NoteBloc).

void main() {
  runApp(BlocProvider(create: (context) => NoteBloc(), child: MyApp()));
}

Widget

BlocBuilder を使います。ちょうど Consumer と同じような書き方になりますが、コールバックには BLoC そのものではなくステートが入ってくるので使いやすいですね。

class NoteList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<NoteBloc, List<Note>>(builder: (context, notes) {
      final cells = notes.map((e) => NoteCell(e)).toList();
      return ListView(children: cells);
    });
  }
}

Event

context.read() で BLoC を取得します。
BLoC への入力はイベントのクラスを生成して add で渡します。

class AddButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoButton(
      onPressed: () {
        final note = Note.create();
        context.read<NoteBloc>().add(AddNote(note));
      },
      child: Text("Add"),
    );
  }
}

state_notifier

StateNotifier

flutter_bloc のように State を型として定義します。ChangeNotifier を使った実装のように、状態を変えるメソッドを公開します。

import 'package:state_notifier/state_notifier.dart';

class Note {
  final int id;
  final String text;
  final DateTime createdAt = DateTime.now();

  Note(this.id, this.text);
}

class NoteState {
  List<Note> notes;
  NoteState(this.notes);
}

class NoteModel extends StateNotifier<NoteState> {
  NoteModel() : super(NoteState([]));

  void add(Note note) {
    state = NoteState(state.notes + [note]);
  }

  Note create() {
    final uid = DateTime.now().millisecondsSinceEpoch;
    final note = Note(uid, "");
    add(note);
    return note;
  }

  void remove(int id) {
    state = NoteState(state.notes.where((n) => n.id != id).toList());
  }

  void update(int id, String text) {
    remove(id);
    add(Note(id, text));
  }
}

Provider

StateNotifierProvider を使います。

void main() {
  runApp(StateNotifierProvider<NoteModel, NoteState>(
      create: (context) => NoteModel(), child: MyApp()));
}

Widget

context.watch や Consumer を使って State を取得します。State の一部しか利用しない場合は context.select を使うのがよいでしょう。

class NoteList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cells =
        context.watch<NoteState>().notes.map((e) => NoteCell(e)).toList();
    return ListView(children: cells);
  }
}

Event

ChangeNotifier を使ったときの実装そのままで動作しますが、ネストを減らせるので context.read を使います。

class AddButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoButton(
      onPressed: () {
        final noteModel = context.read<NoteModel>();
        final note = noteModel.create();
        Navigator.push(
          context,
          MaterialPageRoute(builder: (context) => NoteDetailRoute(note)),
        );
      },
      child: Text("Add"),
    );
  }
}

所感

どの書き方をしても大きな違いはなく、Rx や Stream を活用したいか、書き方を統一したいかといった観点で使い分けるのが良さそうです。そうなった理由については、かつてはモデル層を注入する標準的な方法がなく、様々な方法が試されていたが、provider パッケージが出来て平和が訪れたので、細かい違いしか残っていないと理解しています。私が学習を始めた2020年7月現在からさかのぼって過去の記事を読んだりして想像しているので正確ではないかもしれませんが。最初は Scoped Model などにも触れようと思っていたのですが、provider がある現在、あえて取り組む必要はないと判断しました。
また、BLoC の実装方法に関しても記事によってかなりマチマチで、DartConf 2018 で提案されたデザインガイドラインに沿っていないものも多く見られます。文章ではこちらの issue によくまとまっていました。何か理由があるのか、私が当時の流れを知らないため分かりませんが、BLoC の書き方についてチームで共通の理解を作るために、flutter_bloc などのパッケージを導入すると良さそうです。
個人的には、ChangeNotifier を使った実装が素直で良いと思います。もしくは flutter_bloc を使います。素の BLoC はボイラープレートが多く、また入出力の作法を強制させることができないためチーム開発にはつらい感じがしました。どちらにしても、シンプルな Provider/Consumer (Builder) の構成を保ち、不要な再描画を防ぐためにひとつひとつの BLoC は小さくしておくのが良さそうです。

追記1

state_notifier を使った実装を追記しました。とてもシンプルでボイラープレートも少なく、Rx を使わないので敷居も低いですね。ぜひオススメしたいパターンです。

5
7
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
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?