Flutterを触ってみましたので、理解したアーキテクチャーを図1枚で説明してみます。なお、Flutter1.5のデフォルトスケルトンとなっているソースをもとにしています。また、参考のために、デフォルトスケルトンソースについて、コメントを日本語訳(意訳)しました。
「Flutterで東京公共交通オープンデータを触ってみた」で記載したソースも、同じアーキテクチャーですので、参考にしてください。
2019/5/26更新: 図を全面的に見直してみました。
1. 図
見直しのポイント:
見直し前はMVCモデル(Cは明示されずにフレームワークの裏で動いている)で理解していたのですが、FlutterはMVC/MVVMモデルなのか?という点がずっと腹落ちしませんでした。インスパイア元とされるReactのモデルをいろいろ調べた結果、MVC/MVVMモデルと対比されるものではなく、”Just the UI”という概念でV(VVM)の複雑性に対処したものである、という理解に至りました。よって、Flutterも”Just the UI”であるとして、MVCモデルではなく、React/Flux(Redux)モデルに即したアーキ図に修正しました。そもそも、UIクラスである_MyHomePageStateに全てコード記述がされていますので、”Just the UI”のほうがしっくりきます。また、さらにUIが複雑化したときのBLoCアーキへの拡張必要性も、容易に理解できるようになります。
そもそも、Webアプリの複雑性を制御するアーキとしてMVCモデルが推奨されてきました。しかし、モバイルファーストでネイティブアプリやSPA(+MBaaS)が主流になった現在、最も複雑性の制御が必要な箇所がUIに移りました。VをV+VMに分離したMVVMモデルが出てきたのもこの流れですが、さらに行き着いた結果として、”Just the UI”モデルが開発者の支持を集めたものと思います。
なお、Flutter ≒ Reactという議論はよくみかけますが、Flutter = "Just the UI"モデルであるとした議論は見たことがありません。このあたりの解釈は、ぜひ皆さんのコメントをいただきたいものです。
2022/12/28追記: 記事「FlutterとReact Native(とXamarin)の正しい(?)比較」において、FlutterとReactを同じ「Reactiveモデル」と位置付け、Xamarinと明確に区別していました。
2. 解説
_MyHomePageStateと横のSystem以外は、ほとんど気にする必要はありません。共通のおまじないです。StatelessWidget/StatefulWidgetなども、一般のFlutter解説記事には、ほぼ必ず記載がありますが、簡単なアプリを作る上では気にする必要はありません。
2.1 変動パラメータ Store
_MyHomePageState内で共通に利用する変数(の集合体)です。
スコープは_MyHomePageState内ですが、ほとんど全てのコードは_MyHomePageState内に書かれるため、実質的にはグローバル変数のように扱われます。
2.2 Model Action
ビジネスおよびプレゼンテーションロジック部分です。基本的には、以下で説明するイベントに応じてコールバックの形でビジネスおよびプレゼンテーションロジックが動きますので、関数が複数並んでいる形に実装されます。
ActionとStoreの分担としては、ロジックはActionで完結させて、Storeは単純作業に撤するべきである、というのが、Reactでの推奨パターンであるようです。
2.3 View
画面描画を行う部分です。非常に多くの種類があるWidgetという要素を、親子関係で階層構成として組み合わせることで、画面を定義します。Widgetに表示させるテキストなどの画面描画の内容は、変動パラメータをもとに決まります。
Widgetのルートは、Scaffold(足場)というアプリケーションバーとボディを作るWidgetであることが(必ずではありませんが)多いです。Widgetを再描画するためのbuild()関数の中で返り値に設定されています。Scaffoldを起点に、子Widget、孫Widgetが定義されていきます。
非常に簡単なロジックはViewの中に記載はできるのですが、少し複雑なロジックは記載できないようになっているため、Model Actionとの役割分担が必要となります。
2.4 Model ActionとViewの連携
ここがFlutterの最大のポイントです。
まず、ビジネス プレゼンテーションロジックをもとに画面再描画をしたい場合は、setState()を呼び出します。setState()の内部で変動パラメータを変更するとともに、Viewのbuild()を呼び出して再描画を指示します。
なお、本来は、setState()はStore側に配置しておくと、オブジェクト指向としては正しいのですが、FlutterのサンプルコードではStoreは変数として実装されており、オブジェクトにはなっていません。そのため、図ではsetState()をAction側に配置しています。
また、UIでのタッチ等のイベントは、Widget側で簡単なワンステートメント(?:を利用するif_then_elseの簡易構文を含む)は処理できますが、少し複雑なロジックは記述できないようになっています。その場目、Model Action側で関数として実装しておき、Widget側ではイベントに応じたコールバック指定します。
より複雑なアプリケーションの場合、実質グローバル変数な変動パラメータを多数管理したり、setState()があちこちに実装されて見通しが悪くなったり、それらが非同期で呼び出されることでタイミングに起因するバグが発生(未確認ですが)したり、build()によって配下階層のWidgetが全て更新されて遅くなる、といった課題がでてきます。その場合、ModelとViewの連携を一方向ストリームで実装する 複数のActionとStoreとのやりとりを一方向ストリームで中継するためのBLoCを配置したBLoCアーキテクチャー(Pub/Subモデルに近い考え方)を採用するとよいそうです。
2.5 System システム
スマホのハードウェア部分です。基本的にはFlutter/Dartの標準ライブラリや、外部パッケージによって隠蔽されています。外部パッケージでは不足する場合、自分でKotlin等のネイティブプログラムを編集する必要がありますが、稀でしょう。
System システムのイベントも、コールバックという形で実装されます。ただし、System システムへの設定/情報取得/イベントの全てにおいて、非同期であることを明示的に意識する必要がある場合が多いです。そのため、Dartでのasync/awaitなど、非同期プログラミングに慣れることが必要です。dart:asyncという標準パッケージを直接または間接(外部パッケージの中で使われる)にインポートすることで、非同期プログラミングが可能になります。
なお、UIイベントも本質的には非同期ですが、うまく隠蔽されているため、非同期プログラミングは意識する必要はありません。
3. 終わりに
2019年のGoogle I/Oにて、Flutterのマルチデバイス展開を加速させる宣言がされていました。Web/スマホ/PC/組み込みUI全てがFlutterで書ける時代になると予測され、今回説明した基本的なアーキテクチャーの応用範囲が、広がっていくことでしょう。
4. おまけ
Flutterのデフォルトスケルトンソースについて、コメントを日本語訳(意訳)したものを、参考までに示します。アーキ図と見比べてみてください。
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// このウィジェットはアプリケーションのルートです。
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// アプリケーションのテーマです。
//
// "flutter run"によりアプリケーションを実行すると、青いツールバーが確認できます。
// アプリケーションを実行したまま、primarySwatchを、Colors.greenに変更して、
// "hot reload"("flutter run"を実行したコンソールで"r"ボタンを押す、もしくは
// Flutter IDEで"hot reload"により変更をセーブする)をしてみてください。
// ツールバーの色が変わることが確認できます。
// アプリケーションが再起動するわけではないので、カウンターはリセットされません。
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
// このウィジェットはアプリケーションのホームページです。
// 状態を持つステートフルウィジェットであり、UIに関する値(継承クラスのビルドメソッドに
// おいて状態の一部として使われます)を、親クラスとして保持します。
// ここで扱う値は、final(変更不可)として扱われるものに限ります。
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// setState()は、Flutterフレームワークに、状態の変更を伝え、後にあるビルドメソッド
// を呼び出して、新しい値をもとに画面を描き換えます。
// 仮に、setState()を使わずに_counterを更新しても、ビルドメソッドは呼ばれずに
// 画面は描き変わりません。
_counter++;
});
}
@override
Widget build(BuildContext context) {
// このビルドメソッドはsetState()が呼ばれる度に、動作します。具体的には、上の
// _incrementCounterメソッドによって動作します。
//
// Flutterフレームワークは、ビルドメソッドが何度も呼ばれ高速に処理できることを目指し、
// 最適化されています。そのため、ウィジェット個々に処理しなくても、更新が必要なものは
// 全て対応されます。
return Scaffold(
appBar: AppBar(
// ここには、App.buildメソッドで作られたMyHomePageオブジェクトの値が入り、
// アプリケーションバーのタイトルになります。
title: Text(widget.title),
),
body: Center(
// Centerは、レイアウトウィジェトの1つです。
// 子ウィジェットを1つたけ持ち、それを、親ウィジェトの中心に配置します。
child: Column(
// Columnは、レイアウトウィジェトの1つです。
// 複数の子ウィジェットのリストを持ち、それらを垂直に配置します。
// デフォルトでは、子ウィジェットを横いっぱい、かつ親ウィジェットの高さいっぱいに
// 配置します。
//
// "debug painting"を呼び出す(コンソールで"p"ボタンを押すか、Android
// StudioのFlutter Inspectorで"Toggle Debug Paint"を実行するか、
// Visual Studio Codeで"Toggle Debug Paint"コマンドを実行するかで)
// ことにより、各オブジェクトのワイヤーフレームを可視化できます。
//
// Columnは、多くのパラメータにより、サイズや子ウィジェットの配置を制御できます。
// ここでは、mainAxisAlignmentにより、子ウィジットを上下中央に配置します。
// Columnsは垂直に配置されるため、mainAxisは垂直方向を意味します。
// (cross axisが水平方向を意味します)
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // この最後のカンマは、ビルドメソッドの自動フォーマットを有効にします。
);
}
}