バージョン2.0現在はモバイルアプリケーションを主なターゲットとした、マルチプラットフォームUIフレームワークです。
以下のような特徴があります。
主にGoogleが開発し、アメリカや中国を中心に導入が進んでいます。
近年は日本の大手IT企業でも採用事例があるなど、使用される機会が増えています。
2021年3月4日にFlutterのバージョン2がリリースされました。
このバージョンからWebアプリを生成する "Flutter for Web" が安定版に取り込まれ、iOS / Androidアプリケーションに加え、Webアプリケーションを生成できるようになりました。
また、現在はWindows / macOS / LinuxのデスクトップOS用のアプリケーションを生成する "Flutter Desktop" も開発が進んでおり、Ubuntu(Linuxディストリビューションの一つ)をメンテナンスしているCanonical社がUbuntu用アプリケーションの作成にFlutterを使用することを表明しています。
https://twitter.com/ubuntu/status/1367063203600031746
Flutterアプリケーションを記述するにはDart言語を使用します。
2021年3月現在、Dart代替言語は存在しないため、現在のところは他に選択肢がありません。
Dartは以下のような特徴を持ったプログラミング言語です。
void main() {
final String a = "hello world";
print(a);
}
以下の点でエンジニアとしてのキャリアを積む上でプラスになります。
他のプログラミング言語を学習したことがあり、手続き型オブジェクト指向プログラミングに理解があることを前提としています。
Dart Language Tutorにほぼすべて書かれています。長いので、とりあえず以下の項目を呼んで雰囲気だけでも掴んでください。
その他の項目も理解しておくと役に立ちます。読んでみてください。
※有志が作成したDartのNull Safety対応前の記事の日本語訳などもあります。英語が読めない場合に活用しても良いかもしれません。ただし内容が古いので最新かつ公式のドキュメントを読めるようになることを強くおすすめします。
Node.jsにおけるnpm Registory、JavaにおけるMaven Centralのように、Dartのパッケージ管理システムpubが使用する公開パッケージリポジトリがあります。
後述するpubspec.yamlファイルに記述したパッケージの依存関係解決には、デフォルトで上記のpub.devが使用されます。
パッケージソースを明示した場合は、上記以外のパッケージリポジトリや任意のGitリポジトリからパッケージを取得することが可能です。
言語ごとに、コミュニティや会社が定めたコーディングスタイルガイドがあることがあります。そうしたスタイルガイドに則ったコードを書くことで、他の人にも読みやすく理解しやすいコードになります。
Dartは公式がスタイルガイドを公開しているため、これがデファクトスタンダードとなっています。
また、こうしたスタイルガイドに則ったコードになっているか確認するために、Dartの静的解析ツールが使用できます。
予め厳し目のルールをプリセットにしたパッケージもあります。
今回は詳しく説明しません。事前にSDKのインストールからアプリが実行可能であることまでは確認済みであることを期待します。
OSごとに異なります。
基本的には以下のことをするのみです。
https://flutter.dev/docs/get-started/install/macos
上記ページから最新のSDKのアーカイブをダウンロードし、ダブルクリックするなどしてアーカイブを展開します。
展開されてできたフォルダを適当な場所に配置します。特にこだわりがなければ ~/sdk/flutter
などに配置すると良いでしょう。
配置したら、CLIからコマンドを呼び出せるようにPathを通します。普段使いのシェルの設定ファイル末尾にPATHの設定を記述します。以下はbash / zshの例です。他のシェルを使用している場合は適切に設定するようにしてください。
PATH=$PATH:$HOME/sdk/flutter/bin
事前に以下のプログラムがインストールされ、使用可能になっていることを確認してください。
git
コマンドが使用可能であれば大丈夫ですhttps://flutter.dev/docs/get-started/install/windows
上記ページから最新のSDKのアーカイブをダウンロードし、圧縮フォルダユーティリティを使用するなどしてアーカイブを展開します。
展開されてできたフォルダを適当な場所に配置します。特にこだわりがなければ C:\sdk\flutter
などに配置すると良いでしょう。
配置したら、CLIからコマンドを呼び出せるようにPATHを通します。環境変数の設定ダイアログからPATHを設定してください。Windowsのバージョンによって設定の方法が異なるので、自身が使用しているWindowsに合わせて設定を変更してください。
参考リンク:環境変数の設定方法
PATHには (path to sdk)\flutter\bin
を設定します。上記の例どおりであれば C:\sdk\flutter\bin
になります。
以下以降すべてコマンドラインでの説明になります。ターミナルやPowerShellから実行してください。
flutter doctor コマンドを実行すると、Flutterを動作させるのに必要な準備が整っているか確認することができます。以下のように No issues found!
と表示されれば何も問題ありません。
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 2.0.3, on macOS 11.2.3 20D91 darwin-x64, locale ja-JP) [✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2) [✓] Xcode - develop for iOS and macOS [✓] Chrome - develop for the web [✓] Android Studio (version 4.1) [✓] IntelliJ IDEA Community Edition (version 2020.3.3) [✓] VS Code (version 1.54.2) [✓] Connected device (2 available) • No issues found!
問題があれば以下のように ! マークが表示され、解決方法が提案されます。提案された方法を一通り試してください。
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 2.0.3, on macOS 11.2.3 20D91 darwin-x64, locale ja-JP) [!] Android toolchain - develop for Android devices (Android SDK version 29.0.2) ! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses [!] Xcode - develop for iOS and macOS ✗ CocoaPods installed but not working. You appear to have CocoaPods installed but it is not working. This can happen if the version of Ruby that CocoaPods was installed with is different from the one being used to invoke it. This can usually be fixed by re-installing CocoaPods. To re-install see https://guides.cocoapods.org/using/getting-started.html#installation for instructions. [✓] Chrome - develop for the web [✓] Android Studio (version 4.1) [✓] IntelliJ IDEA Community Edition (version 2020.3.3) [✓] VS Code (version 1.54.2) [✓] Connected device (2 available) ! Doctor found issues in 2 categories.
また、Flutterのプロジェクトを作成して適当なターゲットのアプリケーションがビルド可能か確認してください。
$ flutter create launch_test Creating project launch_test... (中略) All done! In order to run your application, type: $ cd launch_test $ flutter run To enable null safety, type: $ cd launch_test $ dart migrate --apply-changes Your application code is in launch_test/lib/main.dart. $ cd launch_test $ flutter run
本章では最初に生成されるプロジェクトをもとに、Flutterアプリの基本的な構成を解説します。
Flutterに限らず、Dartのプロジェクトではソースコードをlib
(プロジェクトの中核をなすコード群を保管)ないしbin
(コマンドラインでの実行可能ファイルを生成するプロジェクトの場合、エントリーポイントを保管)に格納します。Flutterのプロジェクトでは、lib/main.dart
がエントリーポイント(プログラムが実行される起点。main関数があるファイル)です。なお、ファイル名は自由に変更できます。
pubspec.yaml
がプロジェクトのメタデータを司ります。pubspec.yaml自体はDartプロジェクトで一般的に使われるもので、Flutterに特有のものではありません。
NPMプロジェクトのpackage.jsonや、iOSプロジェクトの.xcodeprojファイル、Androidプロジェクトのbuild.gradle(.kts)などにあたるものです。
pubspec.lock
は $ flutter pub get
コマンドで依存ライブラリを解決した結果が記録されるものです。NPMプロジェクトのpackage.lockにあたるものです。
項目名 | 詳細 |
| プロジェクト名 |
| アプリの説明 |
| Dartパッケージのリポジトリ pub.dev に公開するかどうかの設定 Flutterプロジェクトは基本的に公開しないため none にしておく |
| バージョン名+バージョン番号 +以前はアプリのバージョン名として使用される |
| 依存しているパッケージやプラグイン |
| dependenciesと同じだが、他のプロジェクトから依存されたときには使用しないもの |
| Flutterプロジェクト特有の設定 |
各ビルド対象のプロジェクトテンプレートなどが格納されています。
Flutter 2.0時点ではandroid, ios, webの3つが生成されます。macos, windows, linuxは各プラットフォームの機能を有効にしないと生成されません。
アプリのアイコンなどの設定は、このディレクトリ内のアプリプロジェクトを変更することで行います。中身は単純なAndroid / iOSプロジェクトだったり、ただのindex.htmlやfaviconが置かれたディレクトリなので、詳しくは各ターゲットの設定方法を調べてください。
コマンドによって削除されたり更新されたりするファイルなので、基本的に編集することはありません。
生成されるAndroid / iOSアプリケーションが期待通りではないときに覗いてみるとよいかもしれません。
ボタンを押すと数字がカウントアップされるアプリケーションが生成されています。そのソースコードをベースにFlutterアプリケーションのソースコードの構造を説明します。
Flutterにおいては、「すべてがWidget」です。アプリケーションもレイアウトもアニメーションもWidgetで表現します。
void main() {
// runAppは引数のWidgetを根としたFlutterアプリケーションを起動します
runApp(MyApp());
}
// Widget自体が状態を持たない場合はStatelessWidgetを継承したWidgetクラスを作成する
class MyApp extends StatelessWidget {
// buildメソッドで、このWidgetが表現する子Widgetを返す
@override
Widget build(BuildContext context) {
// MaterialAppはMaterial Designに基づいた色調やタイポグラフィ設定、画面遷移機能、文字列表示における方向設定(ltrなど)などをまとめて提供する、標準提供されているユーティリティ。
// これがWidgetツリーの上位に存在しないと使用できない機能がたくさんある
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
// 画面遷移の起点として表示されるものをhomeとして設定
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
runAppに指定されたWidgetを根とした木構造になっている(Widgetのツリーが存在している)ことを理解しておくと、後の理解が深まります。
ReactやSwiftUIで言うところのComponent、JavaScript界隈で言われるところの仮想DOMです。
ReactやSwiftUIをご存じない方は、「画面上に表示される要素が、どのように構成されているか」という構成情報を表現するオブジェクトだと思ってください。
例えばText Widgetは引数に与えられた文字列を画面に表示します。
Center Widgetは、子Widgetを中央に配置します。
MaterialApp Widgetは、子Widgetからなるアプリケーションを表現します。
Flutterにおいては、画面に表示される具体的要素も、アニメーションも、アプリ自体もWidgetです。
Flutterも仕組みとしては仮想DOMの概念を用いたUIフレームワーク(ReactやVue.jsなど)と同様に、裏で画面に表示する実体となるオブジェクトを管理しています。が、今回の資料ではそうしたものは扱いません。
Widgetは状態を持つことができ、またその状態によって表示を変えることもできます。
// 状態を持つWidgetはStatefulWidgetとStateの2つのクラスで構成します
class MyHomePage extends StatefulWidget {
// Widgetのプロパティはコンストラクタ引数で渡すのが基本です
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
// Stateの状態はフィールドで保持することが多いです。
int _counter = 0;
void _incrementCounter() {
// setStateがbuildメソッドを再実行します(正確には再実行の予約をする)。
// 状態の更新はsetState引数の匿名関数内で行うのが一般的です
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
// タイトルバーや背景色などを表示する枠組みを提供するWidgetがScaffold。
// 「画面」に相当する役割をするWidgetでは使用することが多い
return Scaffold(
appBar: AppBar(
// State.widgetで関連するStatefulWidgetのインスタンスを参照できます
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
// 状態を参照したコードを書くことが可能です。
// setStateがbuildメソッドを再実行することで描画が更新されます
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
StatefulWidget & Stateを使用した方法の他にも状態管理の方法はあります。(後述)
たくさんの種類があり、ここですべてを紹介するのは不可能です。公式サイトにカタログや索引があるので、そちらを見てみてください。
また、週替りでWidgetの特徴や使い方を紹介する動画がYoutubeにアップロードされています。音声は英語ですが、日本語字幕も出せるので、こちらも参照してください。
TODOアプリを作ってみることで、基本的なFlutterアプリケーションの作り方を掴んでみてください。
以下の要件を満たすアプリを目指します。
完了したTODOを未完了に戻すであるとか、完了したTODOを削除するとかは考えないでおきます。後ほど自分で機能追加してみてください。
なお、出来上がりのサンプルは下記のリンクのとおりです。
以降のDartのソースコードはDart 2.12のNull安全に対応した際に使用可能な文法で書かれています。サンプルの通りに記述するには、Null安全を有効にする必要があります。pubspec.yaml
のenvironment
-> sdk
の下限を2.12.0に設定します。
environment:
sdk: ">=2.12.0 <3.0.0"
lib/todo.dartを作成し、TODOを表現するクラスを作成します。
class Todo {
final String title;
final bool done;
Todo({required this.title, this.done = false});
}
まずはTODOを表示するリストを持った画面を作成しましょう。TODOは増えたり状態が変わったりするため、画面はStatefulWidgetとして作成します。
lib/todo_list.dartを作成し、TodoListPageという名前でStatefulWidget継承クラスを作成します。TODOリストはListで保持します。
import 'package:flutter/material.dart';
import 'package:nullsafe_todo/todo.dart';
class TodoListPage extends StatefulWidget {
@override
_TodoListPageState createState() => _TodoListPageState();
}
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
Widget build(BuildContext context) {
return Scaffold();
}
}
リストを表示するためにはListView
を使用します。ListView
にはコンストラクタがいくつかありますが、Listの内要素に合わせてWidgetを変更する場合はListView.builder
コンストラクタを使用するのが便利です。
またListView
の中身にはListTile
系のWidgetを使うとよくあるリストを作ることができます。TODOであれば完了/未完了を表現するチェックボックスがあるとよいので、今回はtrailing
にCheckbox
を配置します。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
setState(() {
_todos[index] = Todo(title: todo.title, done: checked ?? false);
});
},
),
);
},
),
);
}
}
起動時にこれを表示できるようにします。
lib/main.dart
のMyHomePage
を削除し、代わりにTodoListPage
を表示するように変更します。
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TODOアプリ',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: TodoListPage(),
);
}
}
ただ、この時点では画面は真っ白で、何も表示されません。_todos
に予め適当なインスタンスを追加しておきましょう。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [
Todo(title: "ほげ"),
Todo(title: "ふが"),
Todo(title: "ぴよ"),
];
Hot ReloadまたはHot Restartで表示確認できます。リストのアイテムをクリックするとチェックボックスの表示が切り替わるのも確認しておきましょう。
TODOを追加する際、または編集する際に内容を入力するための画面を用意します。今回はそれをダイアログとして表示することにします。
入力されたテキストをWidgetの外部に取り出すためには、TextEditingController
が必要です。また、TextEditingController
は管理しているWidgetの破棄時に一緒に破棄する必要があるため、このWidgetはStatefulWidget
として作成します(StatelessWidget
にはライフサイクルメソッドがないため、破棄されるタイミングを知ることができない)。
lib/edit_component.dart
を作成して、StatefulWidget
継承クラスのEditDialog
を作成します。
import 'package:flutter/material.dart';
class EditDialog extends StatefulWidget {
@override
_EditDialogState createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return Container();
}
}
テキストの入力を受け付けるWidgetはTextField
です。まずはこれをreturnすることにします。decoration
にInputDecoration
を指定することで、見た目のカスタマイズができます。
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return TextField(
decoration: InputDecoration(hintText: "TODO"),
);
}
}
これをダイアログとして表示できるようにします。AlertDialog
が一般的なダイアログの形式を提供してくれるのでこれを使用します。
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
content: TextField(
decoration: InputDecoration(hintText: "TODO"),
),
);
}
}
これでダイアログとしての形はできましたが、いわゆるモーダルダイアログにはなりません。モーダル表示をするためにはshowDialog
関数を使って表示する必要があります。純粋にshowDialog
を使うといちいちbuilder
にWidgetの生成関数を書かないといけないので、ダイアログクラスのstaticメソッドとして表示用のメソッドを用意すると便利です。今回はshow
という名前で表示用メソッドを作成します。
class EditDialog extends StatefulWidget {
static Future<void> show(BuildContext context) {
return showDialog(
context: context,
builder: (context) => EditDialog(),
);
}
@override
_EditDialogState createState() => _EditDialogState();
}
表示の準備ができたので表示を試してみましょう。TodoListPage
から表示できるようにします。FloatingActionButton
を追加して、画面右下にボタンを表示してみます。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [
Todo(title: "ほげ"),
Todo(title: "ふが"),
Todo(title: "ぴよ"),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
// 中略
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
EditDialog.show(context);
},
),
);
}
}
入力値を使ってTODOを追加できるようにしましょう。編集を確定するボタンを用意して、ダイアログが閉じたときにTodo
インスタンスを生成するようにしましょう。
表示用便利メソッドのshow
メソッドはFuture
を返すように変更します。また、showDialog
の戻り値はNavigator.pop
の第2引数に渡すことで指定できます。
class EditDialog extends StatefulWidget {
static Future<Todo?> show(BuildContext context) {
return showDialog(
context: context,
builder: (context) => EditDialog(),
);
}
@override
_EditDialogState createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
content: TextField(
decoration: InputDecoration(hintText: "TODO"),
),
actions: [
ElevatedButton(
onPressed: () {
Navigator.pop(
context,
Todo(title: "後でここに入力値を入れます"),
);
},
child: Text("保存"),
),
],
);
}
}
TodoListPage
でも、EditDialog
の結果を受け取って状態として反映する処理を追加します。「保存」ボタン以外の手段でダイアログが閉じられた場合はnullが返るので、nullでなかったときのみ反映するようにします。
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final todo = await EditDialog.show(context);
if (todo != null) {
_setState(() {
_todo.add(todo);
});
}
},
),
このままだと固定された内容のTODOしか作成できないので、ユーザーの入力値を使えるようにします。_EditDialogState
にTextEditingController
を保持させ、TextField
に渡したり、ElevatedButton
が押されたときに値を取り出したりするのに使用できるようにします。また、ライフサイクルに合わせて破棄したりします。
class _EditDialogState extends State<EditDialog> {
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
content: TextField(
controller: _textEditingController,
decoration: InputDecoration(hintText: "TODO"),
),
actions: [
ElevatedButton(
onPressed: () {
Navigator.pop(
context,
Todo(title: _textEditingController.text),
);
},
child: Text("保存"),
),
],
);
}
@override
void dispose() {
_textEditingController.dispose();
super.dispose();
}
}
EditDialog
を編集にも使えるようにします。ダイアログを開く際に内容のデフォルト値を渡せるようにしましょう。
class EditDialog extends StatefulWidget {
static Future<Todo?> show(BuildContext context, [Todo? base]) {
return showDialog(
context: context,
builder: (context) => EditDialog(base: base),
);
}
EditDialog({this.base}):super();
final Todo? base;
@override
_EditDialogState createState() => _EditDialogState();
}
class _EditDialogState extends State<EditDialog> {
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController(text: widget.base?.title);
}
// 後略
既存のTODOを長押ししたときに編集ダイアログが出るようにします。_TodoListState
で生成しているListTile
のonLongPress
コールバックから、EditDialog
を表示するようにします。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [
Todo(title: "ほげ"),
Todo(title: "ふが"),
Todo(title: "ぴよ"),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
setState(() {
_todos[index] = Todo(title: todo.title, done: checked ?? false);
});
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
setState(() {
_todos[index] = result;
});
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
setState(() {
_todos.add(result);
});
}
},
),
);
}
}
現在のままでは、アプリを終了すると作成したTODOやその状態は揮発してしまいます。揮発を防ぐには状態の保存(永続化)が必要です。
Flutterアプリケーションでデータの保存を行う方法はいくつもあります。今回は端末への保存が手軽に行えるshared_preferences
を使用します。
shared_preferences
は、プラットフォームが標準で提供するKVS(iOSならUserDefaults、AndroidならSharedPreferences、WebならWindow.localStorage)にデータを記録するAPIを提供してくれます。
pubspec.yaml
のdependencies
直下にパッケージ名と使用するバージョンの制約を記入します。
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.0.5
flutter pub get
コマンドで記入した依存パッケージを取得します。使用しているIDEやエディタの同様のコマンドで取得しても構いません。
$ flutter pub get
これでshared_preferences
の機能が使用可能になります。
これをState系クラスで直接使用しても良いのですが、TODOの読み込みと保存方法を抽象化しておくと後の改造が楽になるので、それらを司るクラスを作成しておきます。特にshared_preferences
は基本的なデータ構造しか保存できないため、TODOのリストを保存しようと思ったらJSON文字列など何かしらの保管可能なデータ構造にシリアライズする必要がありますので、今回はJSON文字列との変換もこのクラスで行うこととします。
lib/repository.dart
を作成し、TodoRepository
クラスを以下の内容で作成します。
class TodoRepository {
Future<List<Todo>> loadAllTodo() async {
final pref = await SharedPreferences.getInstance();
final raw = pref.getString("todos");
if (raw != null) {
final decoded = json.decode(raw) as List;
return decoded
.cast<Map<String, dynamic>>()
.map(
(e) => Todo(
title: e["title"],
done: e["done"],
),
)
.toList();
}
return [];
}
Future<void> saveAllTodo(List<Todo> todos) async {
final pref = await SharedPreferences.getInstance();
final values = todos
.map((e) => {
"title": e.title,
"done": e.done,
})
.toList();
final raw = json.encode(values);
await pref.setString("todos", raw);
}
}
TodoListPage
からTodoRepository
を使用できるようにします。
class TodoListPage extends StatefulWidget {
final TodoRepository repository;
TodoListPage(this.repository);
@override
_TodoListPageState createState() => _TodoListPageState();
}
MyAppで、コンストラクタにTodoRepositoryにインスタンスを渡すようにします。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TODOアプリ',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: TodoListPage(TodoRepository()),
);
}
}
_TodoListPageState
の初期化時にTodoRepository
からデータを読み出すようにします。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
void initState() {
super.initState();
widget.repository.loadAllTodo().then((todos) => setState(() {
_todos = todos;
}));
}
// 後略
_TodoListPageState
の状態変更時にTodoRepository
にも現状を保存するようにします。
class _TodoListPageState extends State<TodoListPage> {
List<Todo> _todos = [];
@override
void initState() {
// 省略
}
void _replaceTodo(int index, Todo newTodo) {
setState(() {
_todos[index] = newTodo;
});
widget.repository.saveAllTodo(_todos);
}
void _appendTodo(Todo newTodo) {
setState(() {
_todos.add(newTodo);
});
widget.repository.saveAllTodo(_todos);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
_replaceTodo(
index,
Todo(title: todo.title, done: checked ?? false),
);
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
_replaceTodo(index, result);
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
_appendTodo(result);
}
},
),
);
}
}
TODOを追加、変更してからアプリを再起動し、結果が保持されていることを確認しましょう。
「TODOアプリを作る」の章ではStatefulWidget
を使った状態管理の手法を紹介しました。
一般に以下のパターンが知られています。
名称 | 特徴 | メリット | デメリット |
StatefulWidgetパターン |
|
|
|
BLoCパターン (Business Logic of Component) |
|
|
|
ScopedModelパターン |
|
|
|
現在はScopedModelの派生形が使われることが多いようです。
FlutterにはStatefulWidget
以外にもInheritedWidget
を使った状態の変更通知の手段があります。一般に、InheritedWidget
を使った手法の方がパフォーマンス的に有利だったり、状態を変更しないインスタンスをWidgetツリー上位から注入するなど依存性注入にも使用できて便利です。
使用するクラス | メリット | デメリット |
StatefulWidget + State |
|
|
InheritedWidget + Listenable |
|
|
が、InheritedWidget
をそのまま使用した状態変更通知は面倒なので、これを使いやすくしたサードパーティ製のパッケージがいくつか公開されています。
パッケージ名 | 特徴 |
| |
|
Flutter自体が比較的新しいフレームワークであること、宣言的UIフレームワークはビジネスロジックと表示のコードの分離が比較的簡単であることから、あまり手法を命名して開発者間で認識の統一を図ることが積極的には行われていないようです。
2021年初旬現在、一般的に使われているように思われるのは、provider
+ state_notifier
+ freezed
もしくはriverpod
+ state_notifier
+ freezed
のどちらかを使用した、ScopedModelパターン(の変形)です。
ただ、riverpod
はまだメジャーリリースされていないため、実際の開発現場で採用する際はAPIの破壊的変更に注意する必要があります。
TodoListPage
で管理しているTODOのリストと、そのリストに変更を加える操作はビジネスロジックとその状態に当たります。これをstate_notifer
とfreezed
で再構成し、provider
を使って管理するように再構成してみましょう。
その過程で前章のTodoListPage
を3つのクラスに分割します。
名称 | 役割 |
|
|
| TODO一覧画面の状態を表現する |
| TODO一覧画面が行うビジネスロジックを表現する。また、 |
注意しなければならないのは、TodoListState
/ TodoListController
はTodoListPage
よりもWidgetツリーの上位に注入しないといけない、ということ。これは、Widgetツリーは上から順に構築されるため、ツリーの下位のWidgetインスタンスを参照できないという制約によるものです。
まずは必要な依存関係を追加します。pubspec.yaml
に以下のように依存関係を追加して、flutter pub get
で依存関係を取得します。
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.0.5
# 以下4つを追加
provider: ^5.0.0
state_notifier: ^0.7.0
flutter_state_notifier: ^0.7.0
freezed_annotation: ^0.14.1
dev_dependencies:
flutter_test:
sdk: flutter
# 以下2つを追加
build_runner: ^1.12.2
freezed: ^0.14.1+1
lib/todo_list_state.dart
を作成し、freezed
によるビジネスロジックの状態表現クラスを作成します。
freezed
はいわゆる値クラスの作成を自動コード生成によって行うツールです(Dart 2.12現在、DartにはJavaのrecordやKotlinのdata class、SwiftのEquatableプロトコル準拠structに相当する値クラスに使用できるデータ構造がないため、それと同じような挙動を実現するにはクラスに比較メソッドなどを自前で実装する必要がある)。
自動生成されるファイル名の指定のため、part
の記述が必須です。ファイル名は、現在のファイル名の拡張子の手前に.freezed
を付与したものになります(自動生成系ツールによって変わりますので、ツールのREADMEなどをよく読んでください)。
import 'package:freezed_annotation/freezed_annotation.dart';
part 'todo_list_state.freezed.dart';
@freezed
class TodoListState with _$TodoListState {
const factory TodoListState({
@Default(const []) List<Todo> todos,
@Default(true) bool loading,
}) = _TodoListState;
}
作成したらflutter pub run build_runner build
コマンドでコード生成を行います。
$ flutter pub run build_runner build [INFO] Generating build script... [INFO] Generating build script completed, took 392ms [INFO] Initializing inputs [INFO] Reading cached asset graph... [INFO] Reading cached asset graph completed, took 51ms [INFO] Checking for updates since last build... [INFO] Checking for updates since last build completed, took 433ms [INFO] Running build... [INFO] 1.5s elapsed, 0/1 actions completed. [INFO] Running build completed, took 1.8s [INFO] Caching finalized dependency graph... [INFO] Caching finalized dependency graph completed, took 31ms [INFO] Succeeded after 1.8s with 1 outputs (1 actions)
実行後、lib/todo_list_state.freezed.dart
が生成されていることを確認してください。
次はlib/todo_list_controller.dart
を作成し、ビジネスロジックのコアを持つTodoListController
クラスを作成します。
class TodoListController extends StateNotifier<TodoListState> {
final TodoRepository _repository;
TodoListController({
required TodoRepository repository,
}) : _repository = repository,
super(TodoListState()) {
_repository.loadAllTodo().then((todos) {
state = state.copyWith(
todos: todos,
loading: false,
);
});
}
void replaceTodo(int index, Todo newTodo) {
final todos = state.todos;
todos[index] = newTodo;
state = state.copyWith(todos: todos);
_repository.saveAllTodo(todos);
}
void appendTodo(Todo newTodo) {
final todos = [...state.todos, newTodo];
state = state.copyWith(todos: todos);
_repository.saveAllTodo(state.todos);
}
}
TodoListPage
より(Widgetツリーの)上位で、TodoListController
を注入するようにします。画面Widgetと対になるクラスなので、画面Widgetクラスのstaticメソッドとして注入用のメソッドを用意します。
class TodoListPage extends StatefulWidget {
static Widget create() {
return StateNotifierProvider<TodoListController, TodoListState>(
create: (context) => TodoListController(repository: TodoRepository()),
child: TodoListPage(),
);
}
// 後略
lib/main.dart
のMyApp
では今作成したメソッドを使ってTODOの一覧画面を作成するようにします。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: TodoListPage.create(),
);
}
}
TodoListPage
から状態管理のコードをなくし、StetelessWidget
に変更します。代わりに注入したTodoListState
とTodoListController
を使用するようにします。
class TodoListPage extends StatelessWidget {
static Widget create() {
return StateNotifierProvider<TodoListController, TodoListState>(
create: (context) => TodoListController(repository: TodoRepository()),
child: TodoListPage(),
);
}
@visibleForTesting
TodoListPage() : super();
@override
Widget build(BuildContext context) {
final state = Provider.of<TodoListState>(context);
// 代わりにcontext.watch()でもよい。
// final state = context.watch<TodoListState>();
return Scaffold(
body: state.loading
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
Provider.of<TodoListController>(context, listen: false)
.replaceTodo(
index,
Todo(title: todo.title, done: checked ?? false),
);
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
// Provider.of(context, listen: false)の代わりにcontext.read()でも良い
context
.read<TodoListController>()
.replaceTodo(index, result);
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
context.read<TodoListController>().appendTodo(result);
}
},
),
);
}
}
実行して、以前と変わりなく動作しているのを確認しましょう。
基本はprovider
と全く同じです。TodoListState
とTodoListController
をそのまま流用して、依存性注入の方法として、provider
の代わりにriverpod
を使用します。
riverpod
はProvider
と呼ばれる、依存対象を注入するものを指し示すインスタンスをグローバルスコープに保持します(povider
のProvider
とは全くの別物なので注意)。実際に注入されるインスタンスはProviderScope
というWidgetが保持するため、ProviderScope
はProvider
が使われるWidgetよりも上位に存在していなければなりません(通常は最上位に置きます)。
pubspec.yaml
に依存関係を追加します。(provider
とflutter_state_notifier
の依存は、今節では使用しないので削除しても構いません)
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.0.5
provider: ^5.0.0
state_notifier: ^0.7.0
flutter_state_notifier: ^0.7.0
freezed_annotation: ^0.14.1
# 以下の2つを追加
riverpod: ^0.13.1
flutter_riverpod: ^0.13.1+1
TodoListController
のProvider
を作成します。lib/todo_list_controller.dart
のグローバルスコープに以下のコードを追加します。StateNotifierProvider
はflutter_state_notifier
パッケージではなく、riverpod
パッケージのものを使用するようにimportの際は気をつけてください。
final todoListControllerProvider = StateNotifierProvider(
(ref) => TodoListController(repository: TodoRepository()),
);
作成したtodoListControllerProvider
をTodoListPage
から使用できるようにします。TodoListPage
がConsumerWidget
を継承するように変更し、提供されるwatch
関数を使ってtodoListControllerProvider
が提供するTodoListState
やTodoListController
を使用します。
class TodoListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, ScopedReader watch) {
final state = watch(todoListControllerProvider.state);
return Scaffold(
body: state.loading
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return ListTile(
title: Text(todo.title),
trailing: Checkbox(
value: todo.done,
onChanged: (checked) {
watch(todoListControllerProvider).replaceTodo(
index,
Todo(title: todo.title, done: checked ?? false),
);
},
),
onLongPress: () async {
final result = await EditDialog.show(context, todo);
if (result != null) {
watch(todoListControllerProvider)
.replaceTodo(index, result);
}
},
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final result = await EditDialog.show(context);
if (result != null) {
watch(todoListControllerProvider).appendTodo(result);
}
},
),
);
}
}
lib/main.dart
のrunApp
直下にProviderScope
を配置して、Provider
に対応するインスタンスを保持できるようにします。
void main() {
runApp(ProviderScope(
child: MyApp(),
));
}
以前と変わりなく動作していることを確認してください。
前章までに、ローカル環境で動作するTODOアプリを作成しました。基本的なWidgetの使い方や状態管理方法を理解していただけたと思います。
今までの知識をベースに応用的なアプリケーションの作成も可能です。ご自身でWidgetを調査する、ロジックを考えるなどしてみてください。以下にレベルアップにちょうど良さそうな題材を用意しておきます。参考にしてください。
現在のアプリの構成のまま実装が可能と思われる範囲
現在のアプリから少々構成を変更する必要が出てくる範囲
現在のアプリからかなり構成を変更する必要が出てくる範囲