概要

バージョン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

Dart 言語

Flutterアプリケーションを記述するにはDart言語を使用します。

2021年3月現在、Dart代替言語は存在しないため、現在のところは他に選択肢がありません。

Dartは以下のような特徴を持ったプログラミング言語です。

void main() {
  final String a = "hello world";
  print(a);
}

Flutter開発ができると何が良いのか

以下の点でエンジニアとしてのキャリアを積む上でプラスになります。

他のプログラミング言語を学習したことがあり、手続き型オブジェクト指向プログラミングに理解があることを前提としています。

基本文法

Dart Language Tutorにほぼすべて書かれています。長いので、とりあえず以下の項目を呼んで雰囲気だけでも掴んでください。

その他の項目も理解しておくと役に立ちます。読んでみてください。

有志が作成したDartのNull Safety対応前の記事の日本語訳などもあります。英語が読めない場合に活用しても良いかもしれません。ただし内容が古いので最新かつ公式のドキュメントを読めるようになることを強くおすすめします。

エコシステム

Node.jsにおけるnpm Registory、JavaにおけるMaven Centralのように、Dartのパッケージ管理システムpubが使用する公開パッケージリポジトリがあります。

後述するpubspec.yamlファイルに記述したパッケージの依存関係解決には、デフォルトで上記のpub.devが使用されます。

パッケージソースを明示した場合は、上記以外のパッケージリポジトリや任意のGitリポジトリからパッケージを取得することが可能です。

コードスタイル

言語ごとに、コミュニティや会社が定めたコーディングスタイルガイドがあることがあります。そうしたスタイルガイドに則ったコードを書くことで、他の人にも読みやすく理解しやすいコードになります。

Dartは公式がスタイルガイドを公開しているため、これがデファクトスタンダードとなっています。

また、こうしたスタイルガイドに則ったコードになっているか確認するために、Dartの静的解析ツールが使用できます。

予め厳し目のルールをプリセットにしたパッケージもあります。

今回は詳しく説明しません。事前にSDKのインストールからアプリが実行可能であることまでは確認済みであることを期待します。

SDKのインストール

OSごとに異なります。

基本的には以下のことをするのみです。

  1. 公式サイトからSDKのzipアーカイブをダウンロード
  2. 解凍したアーカイブを適当な場所に配置
  3. Pathを通す
  4. 動作確認

macOSの場合

https://flutter.dev/docs/get-started/install/macos

上記ページから最新のSDKのアーカイブをダウンロードし、ダブルクリックするなどしてアーカイブを展開します。

展開されてできたフォルダを適当な場所に配置します。特にこだわりがなければ ~/sdk/flutter などに配置すると良いでしょう。

配置したら、CLIからコマンドを呼び出せるようにPathを通します。普段使いのシェルの設定ファイル末尾にPATHの設定を記述します。以下はbash / zshの例です。他のシェルを使用している場合は適切に設定するようにしてください。

PATH=$PATH:$HOME/sdk/flutter/bin

Windowsの場合

事前に以下のプログラムがインストールされ、使用可能になっていることを確認してください。

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アプリの基本的な構成を解説します。

ソースコード(libディレクトリ)

Flutterに限らず、Dartのプロジェクトではソースコードをlib (プロジェクトの中核をなすコード群を保管)ないしbin (コマンドラインでの実行可能ファイルを生成するプロジェクトの場合、エントリーポイントを保管)に格納します。Flutterのプロジェクトでは、lib/main.dart がエントリーポイント(プログラムが実行される起点。main関数があるファイル)です。なお、ファイル名は自由に変更できます。

プロジェクトの構成データ(pubspec.yaml)

pubspec.yaml がプロジェクトのメタデータを司ります。pubspec.yaml自体はDartプロジェクトで一般的に使われるもので、Flutterに特有のものではありません。

NPMプロジェクトのpackage.jsonや、iOSプロジェクトの.xcodeprojファイル、Androidプロジェクトのbuild.gradle(.kts)などにあたるものです。

pubspec.lock$ flutter pub get コマンドで依存ライブラリを解決した結果が記録されるものです。NPMプロジェクトのpackage.lockにあたるものです。

項目名

詳細

name

プロジェクト名
Flutterプロジェクトの場合は特にアプリに反映されない

description

アプリの説明
Flutterプロジェクトの場合は特にアプリに反映されない

publish_to

Dartパッケージのリポジトリ pub.dev に公開するかどうかの設定

Flutterプロジェクトは基本的に公開しないため none にしておく

version

バージョン名+バージョン番号

+以前はアプリのバージョン名として使用される
+以後はAndroidアプリのビルド番号として使用される

dependencies

依存しているパッケージやプラグイン

dev_dependencies

dependenciesと同じだが、他のプロジェクトから依存されたときには使用しないもの

flutter

Flutterプロジェクト特有の設定
アプリに含めるアセットファイルやフォントの指定が可能

ビルド対象(android, ios, web, macos, windows, linux)

各ビルド対象のプロジェクトテンプレートなどが格納されています。

Flutter 2.0時点ではandroid, ios, webの3つが生成されます。macos, windows, linuxは各プラットフォームの機能を有効にしないと生成されません。

アプリのアイコンなどの設定は、このディレクトリ内のアプリプロジェクトを変更することで行います。中身は単純なAndroid / iOSプロジェクトだったり、ただのindex.htmlやfaviconが置かれたディレクトリなので、詳しくは各ターゲットの設定方法を調べてください。

自動生成されるファイル(build, .metadataなど)

コマンドによって削除されたり更新されたりするファイルなので、基本的に編集することはありません。

生成される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のツリーが存在している)ことを理解しておくと、後の理解が深まります。

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の種類

たくさんの種類があり、ここですべてを紹介するのは不可能です。公式サイトにカタログや索引があるので、そちらを見てみてください。

Widget catalog - Flutter

Flutter widget index - Flutter

また、週替りでWidgetの特徴や使い方を紹介する動画がYoutubeにアップロードされています。音声は英語ですが、日本語字幕も出せるので、こちらも参照してください。

Flutter Widget of the Week

TODOアプリを作ってみることで、基本的なFlutterアプリケーションの作り方を掴んでみてください。

要件

以下の要件を満たすアプリを目指します。

完了したTODOを未完了に戻すであるとか、完了したTODOを削除するとかは考えないでおきます。後ほど自分で機能追加してみてください。

なお、出来上がりのサンプルは下記のリンクのとおりです。

TODOアプリサンプル

Sound Null Safety対応する

以降のDartのソースコードはDart 2.12のNull安全に対応した際に使用可能な文法で書かれています。サンプルの通りに記述するには、Null安全を有効にする必要があります。pubspec.yamlenvironment -> sdkの下限を2.12.0に設定します。

environment:
 sdk: ">=2.12.0 <3.0.0"

TODOを表現するデータ構造を作る

lib/todo.dartを作成し、TODOを表現するクラスを作成します。

class Todo {
 final String title;
 final bool done;

 Todo({required this.title, this.done = false});
}

TODOリストを作成する

まずは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であれば完了/未完了を表現するチェックボックスがあるとよいので、今回はtrailingCheckboxを配置します。

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.dartMyHomePageを削除し、代わりに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を作成するためのダイアログを用意する

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することにします。decorationInputDecorationを指定することで、見た目のカスタマイズができます。

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しか作成できないので、ユーザーの入力値を使えるようにします。_EditDialogStateTextEditingControllerを保持させ、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();
 }
}

TODOを編集できるようにする

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で生成しているListTileonLongPressコールバックから、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.yamldependencies直下にパッケージ名と使用するバージョンの制約を記入します。

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パターン

  • Flutter標準のStatefulWidetを使用した状態管理手法
  • 単純かつ実装が容易
  • ビジネスロジックと表示のコードが一つのクラスに混じっているので、いわゆるFat Controller問題を招きやすい
  • build関数の再実行によって、再ビルドがかかるWidget以下のWidgetツリーをすべて再構成することになり、パフォーマンス的に不利

BLoCパターン

(Business Logic of Component)

  • 元はAngular DartプロジェクトとFlutterプロジェクトでのロジックコード共用を目的として考案されたもの
  • 表示をWidget系のクラスに任せ、ビジネスロジックを別のクラス(BLoCクラス)に分離する
  • BLoCクラスの入出力はすべてStreamで統一する
  • 出力をStreamBuilderでWidgetツリーに反映する
  • BLoCインスタンスの管理方法までは規定されていない
  • BLoCクラスは純Dartプロジェクトとの共用が可能
  • Reactive Extensionsの概念に慣れていればrx_dartの強力なAPIを使用するなどが可能
  • 入出力をStreamに統一するのが面倒
  • StreamBuilderは考慮すべき状態が多く、記述が面倒
  • 初学者にはReactive Extensionsが難解
  • Flutterプロジェクトでしか使わないのなら採用する理由はあまりない

ScopedModelパターン

  • 表示をWidget系のクラスに任せ、ビジネスロジックを別のクラス(ScopedModelクラス、あるいは単にModelクラス)に分離する
  • 後述するInheritedWidgetを用いてWidgetツリーに変更を反映する
  • ScopedModelインスタンスはInheritedWidgetで管理する
  • いわゆるMVVMパターンの変形
  • 派生形が多い
  • 比較的単純
  • ビジネスロジックと表示のコードを明快に分離できる
  • インスタンスの管理についていちいち考える必要がない
  • Flutterプロジェクト外では使用できない
  • 2021年初旬現在、SocpedModelパターンの元になったscoped_modelパッケージの知名度が低いためか、この名称が一般的ではない(Providerパターンと呼ぶ人もいる)

現在はScopedModelの派生形が使われることが多いようです。

状態管理の実装方法

FlutterにはStatefulWidget以外にもInheritedWidgetを使った状態の変更通知の手段があります。一般に、InheritedWidgetを使った手法の方がパフォーマンス的に有利だったり、状態を変更しないインスタンスをWidgetツリー上位から注入するなど依存性注入にも使用できて便利です。

使用するクラス

メリット

デメリット

StatefulWidget + State

  • 単純明快
  • ビジネスロジックと表示のコードが一つのクラスに混じっているので、いわゆるFat Controller問題を招きやすい
  • build関数の再実行によって、再ビルドがかかるWidget以下のWidgetツリーをすべて再構成することになり、パフォーマンス的に不利

InheritedWidget + Listenable

  • ビジネスロジックと表示のコードを明快に分離できる
  • 特定のbuild関数のみを再実行でき、パフォーマンス的に有利
  • InheritedWidgetをDIのツールとしても使用可能
  • Widgetに対応するElementという概念の理解がある程度必要
  • 注入したいクラスに合わせていちいち新しいクラスを用意しないといけないので準備が面倒

が、InheritedWidgetをそのまま使用した状態変更通知は面倒なので、これを使いやすくしたサードパーティ製のパッケージがいくつか公開されています。

パッケージ名

特徴

provider

  • providerで注入したインスタンスならどのクラスでもProvider.of()メソッドで取得可能
  • 同作者のstate_notifierfreezedとも組み合わせると、ビジネスロジックのコアロジックと状態表現も明快に分離できる

riverpod

  • riverpod自体はFlutterに依存しないので純Dartプロジェクトでも使用可能
  • (Flutter用のflutter_riverpodが内部でInheritedWidgetを使っている)
  • providerと同じくstate_notifierfreezedとも組み合わせるとコアロジックと状態表現を分離しやすい
  • providerと同じ作者によるもの(Widgetツリー上部に依存対象が無いまま依存対象を呼び出して起きるエラーを無くすために考案された)

よく使われる手法

Flutter自体が比較的新しいフレームワークであること、宣言的UIフレームワークはビジネスロジックと表示のコードの分離が比較的簡単であることから、あまり手法を命名して開発者間で認識の統一を図ることが積極的には行われていないようです。

2021年初旬現在、一般的に使われているように思われるのは、provider + state_notifier + freezedもしくはriverpod + state_notifier + freezedのどちらかを使用した、ScopedModelパターン(の変形)です。

ただ、riverpodはまだメジャーリリースされていないため、実際の開発現場で採用する際はAPIの破壊的変更に注意する必要があります。

TODOアプリをproviderで再構築してみる

TodoListPageで管理しているTODOのリストと、そのリストに変更を加える操作はビジネスロジックとその状態に当たります。これをstate_notiferfreezedで再構成し、providerを使って管理するように再構成してみましょう。

その過程で前章のTodoListPageを3つのクラスに分割します。

名称

役割

TodoListPage

TodoListStateをUIという形で表示する。今回は状態管理を行わない

TodoListState

TODO一覧画面の状態を表現する

TodoListController

TODO一覧画面が行うビジネスロジックを表現する。また、TodoListStateを保持する

注意しなければならないのは、TodoListState / TodoListControllerTodoListPageよりも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.dartMyAppでは今作成したメソッドを使って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に変更します。代わりに注入したTodoListStateTodoListControllerを使用するようにします。

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);
         }
       },
     ),
   );
 }
}

実行して、以前と変わりなく動作しているのを確認しましょう。

TODOアプリをriverpodで再構築してみる

基本はproviderと全く同じです。TodoListStateTodoListControllerをそのまま流用して、依存性注入の方法として、providerの代わりにriverpodを使用します。

riverpodProviderと呼ばれる、依存対象を注入するものを指し示すインスタンスをグローバルスコープに保持します(poviderProviderとは全くの別物なので注意)。実際に注入されるインスタンスはProviderScopeというWidgetが保持するため、ProviderScopeProviderが使われるWidgetよりも上位に存在していなければなりません(通常は最上位に置きます)。

pubspec.yamlに依存関係を追加します。(providerflutter_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

TodoListControllerProviderを作成します。lib/todo_list_controller.dartのグローバルスコープに以下のコードを追加します。StateNotifierProviderflutter_state_notifierパッケージではなく、riverpodパッケージのものを使用するようにimportの際は気をつけてください。

final todoListControllerProvider = StateNotifierProvider(
 (ref) => TodoListController(repository: TodoRepository()),
);

作成したtodoListControllerProviderTodoListPageから使用できるようにします。TodoListPageConsumerWidgetを継承するように変更し、提供されるwatch関数を使ってtodoListControllerProviderが提供するTodoListStateTodoListControllerを使用します。

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.dartrunApp直下にProviderScopeを配置して、Providerに対応するインスタンスを保持できるようにします。

void main() {
 runApp(ProviderScope(
   child: MyApp(),
 ));
}

以前と変わりなく動作していることを確認してください。

前章までに、ローカル環境で動作するTODOアプリを作成しました。基本的なWidgetの使い方や状態管理方法を理解していただけたと思います。

今までの知識をベースに応用的なアプリケーションの作成も可能です。ご自身でWidgetを調査する、ロジックを考えるなどしてみてください。以下にレベルアップにちょうど良さそうな題材を用意しておきます。参考にしてください。

初級

現在のアプリの構成のまま実装が可能と思われる範囲

中級

現在のアプリから少々構成を変更する必要が出てくる範囲

上級

現在のアプリからかなり構成を変更する必要が出てくる範囲