Flutterでproviderを使ったMVVM開発
==追記===
今でも結構いいねがつきますが、僕はちょっと遊びでFlutterを触ったくらいでこの記事を書いてからもほぼ触っていません。
こちらの記事も参考にしていただくのはとても嬉しいですが、もしかしたらMVVMは推奨されていないのかもしれないし、案外スタンダートに使われているのかもしれません。Flutter界隈のキャッチアップもほぼしていない状態なので、そういう前提を持ってこの記事を読んでいただければと思います。
(追記終わり)
まるでいつもそうやって開発しているかのようなタイトルですが、全くそんなことはありません😇
Androidエンジニアなので普段はFlutterを書いてはいませんが、最近Flutterでアプリを作り始めました。
Android開発ではViewModelとLiveData、data bindingを使ってMVVMで開発をしています。FlutterでもMVVMで開発したいなと思って最近人気のproviderとStreamを使って実現してみました🙋♂️
Flutterのアーキテクチャとprovider
Flutterのアーキテクチャといえば、BLoCパターンやReduxなどがあります。
普通に何も使わずにFlutterを書いていくと状態管理が複雑になったり、コンストラクタへの引き渡しがカオスなことになっていくことがあるため、これらのアーキテクチャを導入するケースが多いです。
GoogleでもBLoCパターンを推奨していましたが、アプリの規模に対して学習コストが高かったり、ここまで大きなアーキテクチャが本当に必要なのか論などがあり、最近はproviderというライブラリを使った状態管理アーキテクチャを推奨しています。
providerはシンプルな状態管理ライブラリで、DIを使って状態をWidgetにインジェクションしていきます。DIなので各Widgetのコンストラクタに値を引き渡す必要がなくなります。
また、ChangeNotifierを使ってWidgetにデータを通知することができ、StatelessWidgetでも画面を更新することができます。これにより状態とロジックをWidgetから分離することができます。
さらに、このproviderで更新した場合は、Bindingしている部分だけが更新されるようになっているためパフォーマンスの向上も図れるようです。
providerを使ってMVVM開発をする
MVVMの特徴は、Viewから状態やロジックをViewModelに分離することです。また、ViewModelはViewへの参照を一切持たないという特徴があります。
providerを使えば、viewへの参照を一切持つことがなくview側に変更通知をすることができます。
サンプルコードを作ってみました。ログイン画面があり、擬似的にログイン処理(2秒だけwaitしてログイン成功にする)をしてホーム画面に遷移するサンプルです。
ホーム画面はFlutterのデフォルトのサンプルコードですが、これもproviderを使って、状態とIncrementの関数をViewModel側に持たせています。
noteにどうしてもGIfが貼れなかったので、GithubのReadmeに添付してあるGif動画を見てもらえると動きのイメージしやすいです😍
アーキテクチャ図はシンプルにこんな感じです。このサンプルではなんちゃってログイン処理なので実際にはRepository層はありません。
WidgetからView Modelの関数は普通に呼び出します。結果はnotifyListenerによって自動反映されます。
ログインの成功などのイベントの通知はproviderとChangeNotifierではできないのでStreamを使って通知します。これはまたあとで説明します。
ディレクトリの構成とファイル名はこんな感じにしてみました🙋♂️
ChangeNotifierでViewへの自動反映
ホーム画面(Flutterで最初にプロジェクトを作った時に自動生成されてるサンプル画面)は右下のFloatingButtonをタップすると数字がインクリメントされていくという画面になっています。
これをproviderを使った処理にすると、HomeViewModelはこんな感じになっています。
class HomeViewModel extends ChangeNotifier {
int _counter = 0;
int get counter => _counter;
void incrementCounter() {
this._counter++;
notifyListeners();
}
}
ChangeNotifierを継承すると、notifyListeners()関数が使えるようになります。ChangeNotifierは実装が必要なabstract関数などもないのでとてもシンプルに使うことができます。
counter変数はWidgetの方にあるのではなくこっちのViewModelの方に書きます。また、ビジネスロジックもこっちに書くので、incrementCounter()が実装されています。
incrementCounter()では、counterをインクリメントしたあと、notifyListeners()を呼んでいます。これを呼ぶとInjectionされているWidgetに通知が送信されて各Widgetがリビルドされます。
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
Provider.of<HomeViewModel>(context).counter.toString(),
style: Theme.of(context).textTheme.display1,
),
],
),
);
}
Providr.of<HomeViewModel>でInjectionしています。このInjectionされているTextだけがリビルドされるのでパフォーマンスがいいみたいです。
なお、DIなのでクラスの生成処理は上位のWidgetで実装している必要があります。
自分は各ページWidgetのScaffoldの上の要素で必要なViewModelを生成するようにしています。HomeViewModelがこのHomePage Widget配下でどこでもInjectionすることができるようになります。
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Injects HomeViewModel into this widgets.
ChangeNotifierProvider(create: (_) => HomeViewModel()),
],
child: Scaffold(
appBar: AppBar(
title: Text("Home"),
),
body: HomePageBody(),
floatingActionButton: _HomePageFloatingActionButton(),
),
);
}
}
最後に、incrementCounter()を呼び出すFloatingActionButtonの実装です。
class _HomePageFloatingActionButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed:
Provider.of<HomeViewModel>(context, listen: false).incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
);
}
}
状態は管理をWidget側でしていないので、StatelessWidgetになっているのがポイントです。また、providerで簡単にInjectionすることができるのでコンストラクタも使う必要がありません。
Provider.ofのlistern: falseはnotifyListerners()が呼ばれてもリビルドされないことを指定しています。ここのように、関数を呼び出すだけでWidgetのリビルドが必要ない場合はlisten: falseを指定するようにしてください。
Event通知をどうするか問題
今度はログイン画面を見ていきますが、ここでproviderを使ってどうやって画面遷移しよう、という問題に直面しました。
ChangeNotifierを使って画面の更新や値のバインディングはできますが、Eventの通知はできません。
ViewModelは画面の情報は一切持たないのでViewModelで画面遷移することもできません。
そこでAndroidのLiveDataのように、DartのStreamを使ってイベントを通知するようにしてみました。
LoginViewModelはログイン処理に成功したあとは、成功したよ!という通知をStreamに流して終わりです。そのStreamを誰がみていてどう使うのかは関心を持ちません。
逆にWidget側ではLoginViewModelのStreamをlistenしていて、成功のイベントが流れてきたらホーム画面に画面遷移します。
Widget側は、どういうロジックで、どういうタイミングでログイン成功のイベントが発行されているかは知りませんが、流れてきたら画面遷移します。
これでViewとViewModelで互いの参照を一切持つことなく画面の更新とイベント通知ができます。
class LoginViewModel extends ChangeNotifier {
var _loginSuccessAction = StreamController<String>();
StreamController<String> get loginSuccessAction => _loginSuccessAction;
void login() {
// 1.5秒delayしてなんちゃってログインを表現
Future.delayed(Duration(milliseconds: 1500)).then((_) {
// ログインに成功した!
// イベントを通知して終わり
_loginSuccessAction.sink.add("");
});
}
@override
void dispose() {
// LoginViewModelがdisposeされたタイミングで必ずStreamはcloseする
_loginSuccessAction.close();
super.dispose();
}
}
Streamの部分だけ抜粋したコードです。StreamControllerでStreamを生成していて、sink.addで値を流すことができます。
login()ではログインに成功したあとはこのStremaに値を流して終了です。今回はイベントを流すだけなので空文字を流しています。
class _LoginPageBodyState extends State<_LoginPageBody> {
@override
void initState() {
super.initState();
// Listen events by view model.
var viewModel = Provider.of<LoginViewModel>(context, listen: false);
viewModel.loginSuccessAction.stream.listen((_) {
Navigator.of(context).pushReplacementNamed("/home");
});
}
@override
Widget build(BuildContext context) {
return Center(
child: _LoginButton(),
);
}
}
Widget側はLoginViewModelをinitState()でInjectionして、Streamをlistenしています。
loginSuccessActionに値が流れてきたら画面遷移してます。これでproviderを使ってイベントを通知も実現できました。
余談ですが、今回のような値が必要ないイベント通知をするのに空文字を流すのも気持ちが悪いので、Eventというクラスを作ってそれを流すようにしています。
class Event {}
var _loginSuccessAction = StreamController<Event>();
StreamController<Event> get loginSuccessAction => _loginSuccessAction;
StreamControllerの型をEventにします。
_loginSuccessAction.sink.add(Event());
値を流すときはこんな感じ。
まとめ
providerとStreamを使ったイベント通知を使えばFlutterでAndroid開発のようにMVVM開発ができそうです。
シンプルにView(Widget)とロジック/状態を分離できるので、しばらくはこの書き方でFlutterを書いてみたいと思います🦋