最近自分の周りでFlutterを始める人が多く、ありがたいことにFlutterに関する質問を個人的にもらうことが増えてきましたが、 特にその中でもアプリ全体の設計をどうするべきかのについてよく聞かれます。
2019年の12月に書いたアドベントカレンダーの中でBloc
,Redux
,MobX
の3つのアーキテクチャを紹介しましたが、
現在は、それらを使わずにアプリ設計をしています。
Flutterのアプリ設計(Bloc)
https://itome.team/blog/2019/12/flutter-advent-calendar-day21/
Flutterのアプリ設計(Redux)
https://itome.team/blog/2019/12/flutter-advent-calendar-day22/
Flutterのアプリ設計(Mobx)
https://itome.team/blog/2019/12/flutter-advent-calendar-day23/
そこで2020年5月現在、自分が最善だと思うFlutterアプリの実装パターンをまとめておきます。
TL;DR
Collection if/for
や拡張メソッドを使うためにDart2.7~を有効にしておく- 状態管理にはStateNotifierを使う
- LocatorMixinを使った依存性の注入
- Modelクラスの定義にはfreezedを使う
StateNotifierについて
StateNotifier
はProviderパッケージの作者の方による状態管理のためのパッケージです。
Providerパッケージをベースに作られているので、使ったことがない方は下の記事を読んでProviderパッケージについて理解しておくと スムーズだと思います。
FlutterのProviderパッケージを使いこなす
https://itome.team/blog/2019/12/flutter-advent-calendar-day7/
Flutterには値の変更を通知するChangeNotifier
クラスと、
ChangeNotifier
の持てる値を一つだけに限定したValueNotifier
クラスがあります。
ProviderパッケージにはChangeNotifierProvider
という、
ChangeNotifier
の変更を子孫Widgetに通知するProviderがありますが、
StateNotifierProvider
の基本的な理解はそのValueNotifier
版です。
カウンターの値を管理するためのクラスをChangeNotifier
とStateNotifier
で書き比べてみると以下のようになります。
// ChangeNotifierを使った実装
class CounterController extends ChangeNotifier {
int count = 0;
void increment() {
count++;
notifyListeners();
}
}
// StateNotifierを使った実装
class CounterController extends StateNotifier<int> {
CounterController(): super(0)
void increment() {
state++;
}
}
ChangeNotifier
ではカウントをcount
変数で管理しているのに対して、
StateNotifier
では持てる唯一の値であるstate
変数にカウントを割り当てています。
今度はカウントに加えて、カウンターのボタンが有効化されているかどうかも管理するようにしてみましょう。
// ChangeNotifierを使った実装
class CounterController extends ChangeNotifier {
int count = 0;
bool isEnabled = true;
void increment() {
count++;
notifyListeners();
}
void disableCounter() {
isEnabled = false;
notifyListeners();
}
}
// StateNotifierを使った実装
class CounterState {
CounterState({
this.count = 0,
this.isEnabled = true,
});
int count;
bool isEnabled;
}
class CounterController extends StateNotifier<CounterState> {
CounterController(): super(CounterState())
void increment() {
state = state..count++;
}
void disableCounter() {
state = state..isEnabled = false;
}
}
単なる変数で状態を管理しているChangeNotifier
は値の変更を通知するために
notifyListeners()
を明示的に呼ぶ必要がありますが、代わりに変数を増やすことで複数の状態を管理することができます。
一方StateNotifier
はstate
変数にしか状態を持てないため、
CounterState
のようなモデルクラスをstate
変数にいれて複数の状態を管理することになります。
上の例ではCounterState
を直接書き換えていますが、CounterState
はデータを保持するだけのクラスなので、
変更不可(immutable)なクラスにしたいです。
@immutable
class CounterState {
CounterState({
this.count = 0,
this.isEnabled = true,
});
final int count;
final bool isEnabled;
}
class CounterController extends StateNotifier<CounterState> {
CounterController(): super(CounterState())
void increment() {
state = CounterState(
count: state.count + 1,
isEnabled: state.isEnabled,
);
}
void disableCounter() {
state = CounterState(
count: state.count,
isEnabled: false,
);
}
}
これでCounterState
はimmutableなクラスになりましたが、
今度は値を変更するたびにCounterState
を作成しなくてはいけなくなり、
書き換えたい値以外まで毎回指定する冗長なコードになってしまいました。
freezed
パッケージを使うことでこれを解決することができます。
詳しい説明を後回しにして結論だけ書いてしまうと、以下のように書き換えることができます。
@freezed
class CounterState {
CounterState({
int count,
bool isEnabled,
});
}
class CounterController extends StateNotifier<CounterState> {
CounterController(): super(CounterState(count: 0, isEnabled: true))
void increment() {
state = state.copyWith(count: state.count + 1);
}
void disableCounter() {
state = state.copyWith(isEnabled: false);
}
}
かなりすっきり書けるようになりました。
freezedパッケージについて
こちらもProvider
パッケージと同じ作者のもので、
コード生成によってimmutableなデータクラスを作成してくれるライブラリです。
以下のようなメソッドを自動生成してくれます。 (実際にはもっと複雑コードが生成されていますが、イメージがわかりやすいように簡略化しています。)
// generated
CounterState copyWith({
int count,
bool isEnabled,
}) {
return CounterState(
count: count == null ? this.count ? count,
count: isEnabled == null ? this.isEnabled ? isEnabled,
);
}
copyWith
の自動生成以外にも、
- Unionクラス(Kotlinのsealed classやTypescriptのUnion Types相当)が生成できる
@required
をつけることでnullを弾くことができる(将来的に導入されるNNBD相当の機能が使える)json_serializable
と連携してJsonのパースができるコードを生成できる==
が生成されるtoString()
が生成される@late
でフィールドの遅延初期化が生成される
などの便利機能が自動生成されます。今回はcopyWith
以外の機能は使わないので詳しく紹介しませんが、
興味があればfreezedのドキュメントに目を通してみてください。
Widgetでstateを受け取る
CounterController
の状態(CounterState
)を受け取るために、
まずCounterState
を表示したい画面より上位でStateNotifierProvider
を使って、
CounterController
を下流に流します。
StateNotifierProvider<CounterContrller, CounterState>(
create: (context) => CounterContrller(),
child: const CounterView(),
)
これで、CounterPage
でProvider.of<CounterState>(context)
を呼ぶことでCounterState
を、
Provider.of<CounterController>(context)
を呼ぶことでCounterController
を、
それぞれ取得することができるようになります。
class CoutnerView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Text(
Provider.of<CounterState>(context, listen: true).count,
),
IconButton(
icon: Icon(Icons.add),
onPressed: () {
Provider.of<CounterController>(context, listen: false).increment(),
}
),
],
),
);
}
}
Provider.of
のlisten:
引数にtrue
を渡すと、対象のクラスに変更があった場合に、
第一引数に渡したcontext
の範囲全てが再描画されます。
今回の場合はCounterState
に変更があった場合に引数に渡したCounterView
全体が更新されます。
ちなみに、Dart2.7の機能を使えるようにした上でProvider
のdev
版を使うと、
以下のように書き換えることができるようになります。
// pubspec.yml
// Dartの2.7の機能を使えるようにする
environment:
sdk: ">=2.7.0 <3.0.0"
...
dependencies:
...
// Providerのdev版を指定する
provider: 4.1.0-dev+2
// Provider.of<CounterController>(context, listen: false);
context.read<CounterController>();
// Provider.of<CounterState>(context, listen: true).count;
context.watch<CounterState>().count;
// Provider.of<CounterState>(context, listen: true).count;
context.select<CounterState, int>((state) => state.count);
context.select
とcontext.watch
はどちらも同じコードの書き換えができますが、
context.watch
はCounterState
が変更されたときに毎回再描画が走るのに対して、
context.select
はCounterState
の中でもcounter
が変更されたときにだけ再描画されます。
どちらでも使える場合はパフォーマンスに優れたcontext.select
の方を積極的に使ったほうがいいです。
LocatorMixinでDIする
DIという概念にそもそも馴染みがない方もいると思うので、少し遠回りですが順番に説明していきます。
まず、さきほどのCounterController
クラスに、APIから現在のカウントを取得するような機能をつけたいとします。
APIから値を取得するためコードはRepository
クラスに隠蔽されていることにして以下のように書くことができます。
@freezed
class CounterState {
CounterState({
int count,
});
}
class CounterController extends StateNotifier<CounterState> {
CounterController(): super(CounterState(count: 0))
final repository = CouterRepository()
Future<void> fetchCount() async {
state = state.copyWith(
count: await repository.getCount();
);
}
void increment() {
state = state.copyWith(count: state.count + 1);
}
}
しかし、これではCounterRepository
の実装に依存してしまうので、テストのときに通信をモックすることができません。
そこでCounterRepository
をコンストラクタで受け取るようにしましょう。
// main.dart
StateNotifierProvider<CounterController, CounterState>(
create: (context) => CounterController(CounterRepository()),
child: const CounterView(),
),
// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
CounterController(this.repository): super(CounterState(count: 0))
final CouterRepository repository;
...
}
これで、テストのときにモックを外から注入できるようになりました。
Dartはclass
を定義すると暗黙的にそのinterface
も定義されるのでテスト用に別でinterface
を
作成する必要はありません。
上のコードではCounterController
のインスタンス化のときにCounterRepository
もインスタンス化していますが、
他のStateNotifier
でもCounterRepository
を使いまわしたいことがあると思います。
CounterRepository
を使い回すために、StateNotifierProvider
の上流にProvider
を置いて、そこから
CounterRepository
を取得するようにします。
// main.dart
Provider(
create: () => CounterRepository(),
child: ...
...
StateNotifierProvider<CounterController, CounterState>(
create: (context) => CounterController(context.read<CounterRepository>()),
child: const CounterView(),
),
StateNotifierProvider<AnotherController, AnotherState>(
create: (context) => AnotherController(context.read<CounterRepository>()),
child: const AnotherView(),
),
)
こうすることで、複数のStateNotifier
で同じCounterRepository
を使うことができるようになりました。
さて、ここでCoutnerContrller
の中で、UserRepository
という別のRepository
を使いたくなりました。
// main.dart
MultiProvider(
providers: [
Provider(create: (_) => CounterRepository()),
Provider(create: (_) => UserRepository()),
],
child: ...
...
StateNotifierProvider<CounterController, CounterState>(
value: (context) => CounterController(
context.read<CounterRepository>(),
context.read<UserRepository>(),
),
child: const CounterView(),
),
)
// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
CounterController(
this.counterRepository,
this.userRepository,
): super(CounterState(count: 0))
final CouterRepository counterRepository;
final UserRepository userRepository;
...
}
これでももちろんきちんと動きますが、依存しているRepositoryが増えるごとに、 コンストラクタがどんどん肥大化していってしまいます。
それを防ぐために、いっそのことcontext
をCounterContrller
に渡してしまえば、
CounterController
内部でcontext.read
を呼ぶことで
UserRepository
もCounterRepository
も取得できるようになります。
// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
CounterController(this.context): super(CounterState(count: 0))
final BuildContext context;
CouterRepository get counterRepository => context.read<CounterRepository>();
UserRepository get userRepository => context.read<UserRepository>();
...
}
これで正常に動きはしますが、context
はScaffold.of(context)
やNavigator.of(context)
を使って多くのUI要素に
直接アクセスできてしまうので、乱用を避けるためにできるだけWidget以外のクラスで保持するべきではありません。
実際にはcontext.read
だけが使えれば十分なはずなので、
context
を渡す代わりにcontext.read
関数だけをCounterContrller
に渡すことにしましょう。
// main.dart
MultiProvider(
providers: [
Provider(create: (_) => CounterRepository()),
Provider(create: (_) => UserRepository()),
],
child: ...
...
StateNotifierProvider<CounterController, CounterState>(
value: (context) => CounterController(context.read),
child: const CounterView(),
),
);
// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
CounterController(this.read): super(CounterState(count: 0))
final T Function<T>() read;
CouterRepository get counterRepository => read<CounterRepository>();
UserRepository get userRepository => read<UserRepository>();
...
}
ジェネリクスが入って少しわかりづらくなりましたが、コンストラクタのときにcontext.read
関数のみを受け取って、
それを使ってCounterContrller
内部からRepository
を取得しているだけです。
上のコードでread
の方として使われているT Function<T>()
という型は
Provider
パッケージでLocator
として定義されているので、
以下のように書き換え可能です。
class CounterController extends StateNotifier<CounterState> {
CounterController(this.read): super(CounterState(count: 0))
// final T Function<T>() read;
final Locator read;
CouterRepository get counterRepository => read<CounterRepository>();
UserRepository get userRepository => read<UserRepository>();
...
}
これで、依存する~Repository
が増えてもコードをほとんど追加せずに取得できるようになりました。
このLocator
パターンをさらにシンプルに書けるようにしたのがLocatorMixin
です。
LocatorMixin
を使うと、StateNotifierProvider
と組み合わせて使ったとき限定ですが
context.read
が自動的にフィールドに追加されます。
そのためコンストラクタにcontext.read
を渡すコードもフィールドにLocator
を定義するコードも省略できます。
// main.dart
MultiProvider(
providers: [
Provider(create: (_) => CounterRepository()),
Provider(create: (_) => UserRepository()),
],
child: ...
...
StateNotifierProvider<CounterController, CounterState>(
value: (context) => CounterController(),
child: const CounterView(),
),
);
// counter_controller.dart
class CounterController extends StateNotifier<CounterState> with LocatorMixin {
CounterController(): super(CounterState(count: 0))
CouterRepository get counterRepository => read<CounterRepository>();
UserRepository get userRepository => read<UserRepository>();
...
}
LocatorMixin
を使うことで親以上のProvider
の値を簡単に取得できるようになりました。
このように、依存するオブジェクトを外部から(暗黙的に)注入することはDI(依存性注入)と呼ばれています。
以下のようにWidgetツリーの構造が直接依存関係グラフになっているところが、 他のプラットフォームにおけるDIと比べて特徴的で面白いです。
Provider(
create: (_) => CounterApiClient(),
child: Provider(
(context) => CounterRepository(context.read), // 内部でCounterApiClientを使うことができる
StateNotifierProvider<CounterController, CounterState>(
value: (context) => CounterController(), // 内部でCounterRepositoryを使うことができる
child: const CounterView(),
),
),
)
シンプルに書ける以外にもLocatorMixin
には以下のようなメリットがあります。
- テスト時にモックを注入しやすい
final mockCoutnerRepository = MockCoutnerRepository();
final mockUserRepository = MockUserRepository();
final contrlller = CounterController()
..debugMockDependency<CounterRepository>(mockCounterRepository);
..debugMockDependency<MockUserRepository>(mockUserRepository);
initState
が使える
StateNotifier
の初期化時に読み込みをここで行うことができます。
class CounterController extends StateNotifier<CounterState> with LocatorMixin {
CounterController(): super(CounterState(count: 0))
@override
void initState() {
fetchCount();
}
Future<void> fetchCount() async {
state = state.copyWith(
count: await read<CounterRepository>().getCount();
);
}
}
update
が使える
依存している別のオブジェクトの変更監視ができます。(context.watch相当)
class CounterController extends StateNotifier<CounterState> with LocatorMixin {
CounterController(): super(CounterState(count: 0))
@override
void update(Locator watch) {
state = state.copyWith(
count: watch<SettingContrller>().defaultCount;
);
}
}
まとめ
provider
state_notifier
freezed
という3つのパッケージを使ったFlutterのアプリ設計を紹介しました。
現在は実際の仕事も含めて自分が携わっているFlutterプロジェクトのほとんどはこの構成になっています。
3つのパッケージを開発されたRemi Rousseletさんは、 他にも便利なパッケージをいくつも作っていたり、 TwitterでFlutterのTipsを教えてくれたりするので、是非フォローしておくことをおすすめします。
本来はController
クラスで画面遷移やエラーの表示などのイベントを発火させる方法を更に紹介するつもりでしたが、
LocatorMixin
の説明を詳しくしていたらやたら長い記事になってしまったので別の記事にします。