今回はscoped modelについて書いてみようと思います。
Dart Meetup Tokyo #5での発表の中でもちょっと触れているやつです。ちなみにその時のスライドはこちらです。
FlutterとAngularDartを DIとClean Architectureでいい感じにする
#Scoped Model
Scoped Modelとは
Scoped Modelの特徴はざっくりまとめると下記のような感じです。
- 親Widgetから子孫のWidgetにModelを簡単に渡す事が出来るようになる
- Modelが更新された場合に、そのModelを参照しているWidgetを再レンダリングする
- StatefullWidgetとStateを使用する必要が無くなる
個人的にはStatefullWidgetとStateを使う必要がないのが大きいです(Scoped Modelの内部では使用されていますが、そのあたりを自然な形で隠蔽してくれています)。Flutterのアーキテクチャーを考えれば必要なのは良くわかるのですが、ぶっちゃけあれって面倒ですよね?w
なのでScoped Modelを使う事で全てのWidgetをStatelessにする事が出来るので個人的に凄く書きやすくなると思っています。
Fuchsiaで取り入れられている
日本では話題になることが少ないですが、現在Googleが開発しているFuchsiaというマイクロカーネルのOSがあります。そのFuchsiaではFlutterでGUIが開発されているのですが、その中でScoped Modelの考えが使われています。ちなみにScoped ModelのライブラリーをFuchsia側で使っているわけではなくFuchsia側のソースをそのまま切り出してScoped Modelのプラグインにしています。ソースコードはコメントを入れても300行くらいです。
実際の使い方
android studio等でflutterのプロジェクトを作成した際にデフォルトで作成されている「ボタンをクリックすると数字がカウントアップするアプリ」を例にScoped Modelを利用した場合にどうなるか見ていきます。READMEに書いてあるやつそのままですw
####依存関係の追加
flutterのプラグインとして提供されています。なのでpubspec.yamlに依存関係を追加すれば使えるようになります。
dependencies:
flutter:
sdk: flutter
scoped_model: ^0.3.0 #これを追加
####Modelの作成
親Widgetと子孫Widgetで共有するWidgetを作成します。
import 'package:scoped_model/scoped_model.dart';
class CounterModel extends Model {
int _counter = 0;
int get counter => _counter;
void increment() {
_counter++;
//状態を変更したらnotifyListenersを呼ぶ。
notifyListeners();
}
}
Modelというクラスをextendsする形で独自のModelクラスを定義します。このModelはカウンターの数字情報を保持しつつその数字をincrementする処理を持ちます。
また、状態(_counter)を変更した場合にWidgetの再レンダリングを実行するためにnotifyListenersを呼び出します。これを呼び忘れると再レンダリングは実行されないので注意が必要です。
####Scoped Modelの定義
Scoped Modelを定義すると指定したmodelがその子孫Widget内にて容易に参照出来るようになります。
void main() => runApp(new MyApp(CounterModel()));
class MyApp extends StatelessWidget {
final CounterModel counterModel;
MyApp(this.counterModel);
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Scoped Model Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
//共通でmodelを使用したいルート(親)WidgetにてWidgetをWrapするように定義する
home: ScopedModel<CounterModel>(
model: counterModel,
child: new MyHomePage('Scoped Model Demo')
)
);
}
}
コンストラクタの model
には子孫Widgetにて参照したいModelを指定します。また child
には通常のWidgetを指定します。一瞬「??」となるかもしれないですがScopedModel<T>
は単なるStatelessWidgetです。単に使いたいWidgetをSceopedModelでWrapすると考えれば大丈夫です。
####子孫WidgetにてModelを参照する
子孫WidgetにてModelを参照するにはScopedModelDescendant<T>
を使用します。
class MyHomePage extends StatelessWidget {
final String title;
MyHomePage(this.title);
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(title),
),
body: new Center(
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Text(
'You have pushed the button this many times:',
),
//Modelの情報を参照したいwidgetをScopedModelDescendantにてWrapし、builder関数を定義する
ScopedModelDescendant<CounterModel>(
builder: (context, child, model) =>
new Text(
'${model.counter}',
style: Theme.of(context).textTheme.display1,
)
)
],
),
),
//Modelの情報を参照したいwidgetをScopedModelDescendantにてWrapし、builder関数を定義する
floatingActionButton:ScopedModelDescendant<CounterModel>(
builder: (context, child, model) {
return new FloatingActionButton(
//Modelのincrementを呼び出してカウントアップする。
onPressed: () => model.increment(),
tooltip: 'Increment',
child: new Icon(Icons.add),
);
}
)
);
}
}
CounterModelのカウント情報をText Widgetに表示しますが、ここでScopedModelDescendant<T>
を使用します。
そしてbuilder
という名前付き引数を指定します。ここで指定するのは下記定義に沿った関数です。
//scoped modelより抜粋
typedef Widget ScopedModelDescendantBuilder<T extends Model>(
BuildContext context,
Widget child,
T model,
);
Widgetを戻り値とし、第一引数にはBuildContext、第二引数にはScopedModelDescendantBuilderのコンストラクタにオプションで指定するWidget(これの使い道がよくわかっていないです・・・)、第三引数にはScopedModelで指定したModel(今回はCounterModel)を引数として受け取る関数となります。今回でいうと下記の2箇所です。
ScopedModelDescendant<CounterModel>(
builder: (context, child, model) =>
new Text(
'${model.counter}',
style: Theme.of(context).textTheme.display1,
)
)
floatingActionButton:ScopedModelDescendant<CounterModel>(
builder: (context, child, model) {
return new FloatingActionButton(
//Modelのincrementを呼び出してカウントアップする。
onPressed: () => model.increment(),
tooltip: 'Increment',
child: new Icon(Icons.add),
);
}
)
どうでしょうか。1つWrapするための記述(ScopedModelとScopedModelDescendant)が必要になりますがStatefullWidgetとStateの記述がなくなり、より本質的なUIに専念しやすくなっていると思います。
####ScopedModelDescendantを使わずにModelを参照する
ScopedModelDescendantはbuilder関数を定義しますが、第一引数のBuildContextや第二引数のWidgetを使わない事も多いと思います。その場合はもっとダイレクトにModelを取得するやり方がScoped Modelにはあります。上記カウント情報を参照している部分は下記のようになります。
children: <Widget>[
new Text(
'You have pushed the button this many times:',
),
new Text(
'${ScopedModel.of<CounterModel>(context, rebuildOnChange: true).counter}',
style: Theme.of(context).textTheme.display1,
)
]
ScopedModelDescendantの記述が無くなったのでよりすっきりしたコードになったと思います。
ScopedModelにstaticなof
メソッドが定義されていているのでそれを呼び出す形です。Genericsの指定とrebuildOnChange
にtrueを指定する必要があります。
もしくは下記のようにCounterModel内にstaticメソッドを指定する事も可能で、その場合はrebuildOnChange
にtrueを指定する必要はありません。
class CounterModel extends Model {
int _counter = 0;
int get counter => _counter;
void increment() {
_counter++;
notifyListeners();
}
//このofメソッドを追加
static CounterModel of(BuildContext context) => ScopedModel.of<CounterModel>(context);
}
new Text(
'${CounterModel.of(context).counter}',
style: Theme.of(context).textTheme.display1,
)
とりあえず今回は以上になります。Dart Meetupの時にも話をしたのですがScoped ModelとDIと組み合わせる事でClean Architectureでプログラミングするのがとても容易になるのが自分が好きなポイントです。大きなアプリを作った時にどうなのかという点はあるかと思いますが、その点はそもそもFuchsiaで使用されているので上手く行かなかったら自分のスキルが足りないという事にしたいと思いますw。
Flutterの理解がある程度あればScoped Modelを使うのも難しくないと思うのでそのあたりも良いポイントかなと思います。