Flutterに入門してみた
サードパーティパッケージを使わずに状態を管理する
「状態管理」、Flutterに限らずアプリケーション開発において考えなくてはならないトピックです。
Flutterのアプリ開発ではRiverpodなどの状態管理パッケージを使うことが多いと思いますが、Flutterの公式ドキュメント、First week experience of Flutter の State management にFlutterが標準で提供している状態管理について書かれていました。
理解を深めたいと思い、読んだり自分でもコードを書いたりしてみました。
StatefulWidgetを使う
状態を管理する一番シンプルな方法です。
以下が実現できています。
- 状態のカプセル化
-
MyCounter
を使うウィジェットからはMyCounter
で管理しているStateは見えず、変更もできません
-
- ライフサイクル
-
_MyCounterState
オブジェクトはMyCounter
ウィジェットが初めて構築された時に生成され、画面から取り除かれるまで存在する
-
非常にシンプルですね。
class MyCounter extends StatefulWidget {
const MyCounter({super.key});
State<MyCounter> createState() => _MyCounterState();
}
class _MyCounterState extends State<MyCounter> {
int count = 0;
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $count'),
TextButton(
onPressed: () {
setState(() {
count++;
});
},
child: const Text('Increment'),
)
],
),
);
}
}
Widget間でStateを共有する
複数のWidget間でStateを共有したい場合、複数のアプローチがあります。
コンストラクタ引数でStateを渡す
まず、真っ先に思いつくのはStateをそれを必要としているWidgetに渡すことです。
コンストラクタ引数でStateを受け取るようにして内部で保持すればbuild
メソッドの中でも使えます。
class MyCounter extends StatelessWidget {
final int count;
const MyCounter({super.key, required this.count});
Widget build(BuildContext context) {
return Text('$count');
}
}
Widgetを使う側もコンストラクタ引数にStateを渡すだけなのでわかりやすいです。
Column(
children: [
MyCounter(
count: count,
),
MyCounter(
count: count,
),
TextButton(
child: Text('Increment'),
onPressed: () {
setState(() {
count++;
});
},
)
],
)
でもこれって、Reactでいうpropsで情報を渡してるだけだなと思っていたら
sometimes called "prop drilling" in other frameworks
との記載がありました。やはりそのようです。
いわゆるバケツリレーなので、Widgetの階層が深くなるとReactのコンポーネントでも起きたのと同じような以下の問題が起きることになります。
- Stateを使うWidgetに届けるまでに同じようなコードを書かないといけない
- Stateを使うWidgetだけでなく中間のWidgetもムダに
build
メソッドが呼ばれる
コードの保守性の観点からもパフォーマンスの観点からも良いとは言えないです。
そこでFlutterはInheritedWidget
というウィジェットを提供しています。
InheritedWidgetを使う
InheritedWidget
を使うと、ウィジェットの上位階層で保持しているStateを
階層を超えてStateを利用したいWidgetに通知することができます。
InheritedWidget
を継承したクラスを作り、Stateを保持します。
staticなof
メソッドを定義し、BuildContext
のdependOnInheritedWidgetOfExactType
メソッドを使い、その結果を返すようにしておきます。
dependOnInheritedWidgetOfExactType
メソッドは引数のcontextから見てツリー上の祖先で直近のInheritedWidget
を検索・取得するAPIです。
また、Stateが変化した時に購読者(of
メソッドを呼んだウィジェット)に通知できるようにupdateShouldNotify
メソッドをオーバーライドします。古いStateと今のStateが異なっていたらtrue
を返すことで購読者に通知されます。
class MyState extends InheritedWidget {
const MyState({
super.key,
required this.count,
required super.child,
});
final int count;
static MyState of(BuildContext context) {
// This method looks for the nearest `MyState` widget ancestor.
final result = context.dependOnInheritedWidgetOfExactType<MyState>();
assert(result != null, 'No MyState found in context');
return result!;
}
// This method should return true if the old widget's data is different
// from this widget's data. If true, any widgets that depend on this widget
// by calling `of()` will be re-built.
bool updateShouldNotify(MyState oldWidget) => count != oldWidget.count;
}
InheritedWidget
を継承したMyState
を使う側は以下のようになります。
MyState
コンストラクタでStateと子ウィジェットを渡します。
渡しているのはChild1
ですが、Stateを使っているのはChild2
です。
Child2
のbuild
メソッド中のMyState.of(context).count;
でStateを取得しています。
Stateが変わるたびにChild2
のbuild
メソッドが呼び出されUIが更新されます。
Stateを使うChild2
ではStateを保持していません。
また、Child2
の親のChild1
にはStateに関するコードはありません。
バケツリレーが無くなりました 😄
class MyCounter extends StatefulWidget {
const MyCounter({super.key});
State<MyCounter> createState() => _MyCounterState();
}
class _MyCounterState extends State<MyCounter> {
int count = 0;
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyState(count: count, child: const Child1()),
TextButton(
onPressed: () {
setState(() {
count++;
});
},
child: const Text('Increment'),
)
],
),
);
}
}
class Child1 extends StatelessWidget {
const Child1({super.key});
Widget build(BuildContext context) => const Child2();
}
class Child2 extends StatelessWidget {
const Child2({super.key});
Widget build(BuildContext context) {
final count = MyState.of(context).count;
return Text('Count: $count');
}
}
コールバックで親ウィジェットにStateを渡す
これまで親から子にStateを共有する方法を見てきました。
子がStateを管理していて、親に通知したい場合にはコールバックを使います。
ValueChanged
型の定義は
typedef ValueChanged<T> = void Function(T value);
となっていて、指定した型の値を1つ受け取れる関数型です。
class Parent extends StatelessWidget {
const Parent({super.key});
Widget build(BuildContext context) {
return MyCounter(
// コールバック経由で値を受け取る
onChanged: (newCount) => {
debugPrint('New count: $newCount'),
});
}
}
class MyCounter extends StatefulWidget {
const MyCounter({super.key, required this.onChanged});
final ValueChanged<int> onChanged;
State<MyCounter> createState() => _MyCounterState();
}
class _MyCounterState extends State<MyCounter> {
int count = 0;
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $count'),
TextButton(
onPressed: () {
final newCount = count + 1;
setState(() {
count = newCount;
});
// コールバック関数を呼ぶ
widget.onChanged(newCount);
},
child: const Text('Increment'),
)
],
),
);
}
}
状態管理をWidgetから切り離す
ここまでStateをWidget間で共有する方法を見てきましたが、あくまでStateの管理はStatefulWidget内で行ってきました。
同じStateを複数のWidgetで共有するなら特定のWidgetではなく、それ用のオブジェクトに状態管理を任せた方が良さそうです。Stateを更新するロジックもそのオブジェクトに実装できるとWidgetからState更新のロジックが無くなってスッキリしそうです。
ChangeNotifierを使う
ChangeNotifier
を使うと、Stateを保持し、Stateの更新を購読者に通知することができます。
class CounterNotifier extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
final counterNotifier = CounterNotifier();
使う側はListenableBuilder
のパラメータにcounterNotifier
を渡し、
Stateが通知された時に呼ばれるbuilder
関数でWidgetを返します。
class MyCounter extends StatelessWidget {
const MyCounter({super.key});
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ListenableBuilder(
listenable: counterNotifier,
builder: (context, child) {
return Text('counter: ${counterNotifier.count}');
}),
TextButton(
child: const Text('Increment'),
onPressed: () {
counterNotifier.increment();
},
),
],
));
}
}
ValueNotifierを使う
ValueNotifier
はChangeNotifier
をシンプルにしたものです。
その名の通り、1つの値を保持し、更新を通知できます。
使う側はChangeNotifier
の時と同様ListenableBuilder
も使うことができますが、
ValueListenableBuilder
を使うとbuilder
関数の引数で更新された値を直接受け取ることができます。
final ValueNotifier<int> counterNotifier = ValueNotifier(0);
class MyCounter extends StatelessWidget {
const MyCounter({super.key});
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ValueListenableBuilder(
valueListenable: counterNotifier,
builder: (context, value, child) {
return Text('counter: $value');
}),
TextButton(
child: const Text('Increment'),
onPressed: () {
counterNotifier.value++;
},
),
],
));
}
}
アプリケーションアーキテクチャとしてMVVMを使う
これまでに、Stateの更新やWidgetへの通知の仕組みを見てきました。
これらの仕組みをMVVMに適用する例が載っていたのでご紹介します。
Model
まずはModel部分の定義です。
ModelはHTTP通信などのローレベルの処理を担当します。
Flutterに依存しないような作りにすることでモックへの差し替えがしやすく、テストしやすい作りになっています。
import 'package:http/http.dart';
class CounterData {
CounterData(this.count);
final int count;
}
class CounterModel {
Future<CounterData> loadCountFromServer() async {
final uri = Uri.parse('https://myfluttercounterapp.net/count');
final response = await get(uri);
if (response.statusCode != 200) {
throw ('Failed to update resource');
}
return CounterData(int.parse(response.body));
}
Future<CounterData> updateCountOnServer(int newCount) async {
// ...
}
}
ViewModel
ChangeNotifier
を継承したクラスをViewModelとして定義します。
Stateを内部に持ち、notifyListeners
で通知します。
Viewからのイベントは increment
メソッドを通じて実行します。
ガイドではViewModelはレストランのウェイターのようなものだと解説されています。
Modelがキッチン、Viewが顧客で、キッチンと顧客の間を仲介するのがViewModelです。
確かにレストランで顧客がキッチンと直接やりとりすることはないですよね。
import 'package:flutter/foundation.dart';
class CounterViewModel extends ChangeNotifier {
final CounterModel model;
int? count;
String? errorMessage;
CounterViewModel(this.model);
Future<void> init() async {
try {
count = (await model.loadCountFromServer()).count;
} catch (e) {
errorMessage = 'Could not initialize counter';
}
notifyListeners();
}
Future<void> increment() async {
var count = this.count;
if (count == null) {
throw('Not initialized');
}
try {
await model.updateCountOnServer(count + 1);
count++;
} catch(e) {
errorMessage = 'Count not update count';
}
notifyListeners();
}
}
View
最後にViewです。
ViewModelはChangeNotifier
なのでStateが更新された時にUIの更新も行うことができます。
Viewはレストランで言う顧客ですね。顧客がすることはなんでしょうか?
ウェイターに注文して(ViewModelへの依頼)、提供された料理を食べます(Stateを使ってUIを構築する)ね。
ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Column(
children: [
if (viewModel.errorMessage != null)
Text(
'Error: ${viewModel.errorMessage}',
style: Theme.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.red),
),
Text('Count: ${viewModel.count}'),
TextButton(
onPressed: () {
viewModel.increment();
},
child: Text('Increment'),
),
],
);
},
)
MVVMを適用することでViewはUIの構築だけを考えればよくなりました。
ViewModelはModelとViewの仲介役として機能し、ViewのためのStateを管理したり、Viewからイベントを受けとり、Modelを使ってデータの取得などを行います。
Modelは例ではHTTP通信などのローレベルな処理だけでしたが、アプリによってはビジネスロジックを担当する部分となります。
おわりに
First week experience of Flutter の State management を読んで、Flutterが標準で提供している状態管理の仕組みを理解しました。
実際のアプリケーション開発の現場ではRiverpodなどの状態管理パッケージを使うことが多いと思いますが、Flutterが標準で提供している状態管理の仕組みの理解が深まりました。
Flutterのスキルをつけたいのでその情報をまとめる。
書籍はこれが一番良さそう。
Flutter実践開発 ── iPhone/Android両対応アプリ開発のテクニック
第1章 環境構築
Flutter SDKのインストール
Flutter 3.24.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 80c2e84975 (3 weeks ago) • 2024-07-30 23:06:49 +0700
Engine • revision b8800d88be
Tools • Dart 3.5.0 • DevTools 2.37.2
Xcodeのインストール
Android Studioのインストール
flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.0, on macOS 14.2.1 23C71 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.1)
[✓] IntelliJ IDEA Ultimate Edition (version 2023.2.2)
[✓] VS Code (version 1.92.2)
[✓] Connected device (3 available)
! Error: Browsing on the local area network for iPhone. Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly. (code -27)
[✓] Network resources
• No issues found!
Connected device のところでエラーが出ているが一旦無視して進める。
必要が生じたら以下記事を参考にエラーを解消する。
fvm
プロジェクトごとにFlutterのバージョンを切り替えることができるツール。
fvmを使うことで、複数のFlutterバージョンの切り替えが容易になる。
Homebrewを使ってインストール。
brew tap leoafarias/fvm
brew install fvm
fvm --version
3.1.7
fvm releases
でインストール可能なFlutterの一覧が表示されるので、
stable版を fvm install <バージョン番号>
でインストール。
fvm list
でfvmでインストールしたFlutterの一覧を表示できる。
自分の環境ではシェルにfishを使っているので、~/.config/fish/config.fish
に以下のエイリアスを追加し、
fvm
コマンドを省略できるようにした。
# fvm
alias flutter='fvm flutter'
Android StudioでFlutterプロジェクトを作成
本にはFlutter SDK PathにfvmでインストールしたFlutter SDKを選択とあったので選択したが、
「The Flutter SDK installation is incomplete」と表示されエラーとなった。
/Users/<ユーザー名>/fvm/versions/3.24.0/bin/flutter --version
を実行してFlutter SDKをダウンロードすることで先に進むことができた。
プロジェクト直下で fvm use 3.24.0
を実行してプロジェクトで使うFlutterバージョンを指定。
その後、本にはAndroid Studioが参照するFlutter SDKのパスを設定する手順が書かれているが、
既に設定済みであった。
プロジェクト作成時にfvmでインストールしたFlutter SDKを選択したところでエラーになって解消したところ、おそらくその手順を実行する必要はなく、fvm外でインストールしたflutter sdkを一旦設定して進めばよかったのだと思う。
作成したプロジェクトをiOS, Androidで動かす
iOSはiOSシミュレーター、Androidはエミュレーターを選択して実行ボタンをクリックすることでアプリが起動する。
Androidの場合、エミュレーターが作成されていなければ事前に作成する。
FlutterがサポートしているAPIレベルは以下ページで確認可能。
第2章 Dartの言語仕様
finalとconst
定数を宣言する方法にfinal
とconst
がある。
それぞれ以下のように使用できる。
// 型注釈
final int value1 = 1;
// 型推論
final value2 = 2;
// 型注釈
const int value3 = 3;
// 型推論
const value4 = 4;
finalとconstの違い
-
const
はコンパイル時定数なので、クラス変数などはconstが使えない -
final
で宣言されたクラスのフィールドは変更可能 -
const
で宣言されたクラスのフィールドは変更不可
遅延初期化
グローバル変数の初期化など、コンパイラが初期化を正しく判断できない場合がある。
例えば、以下の場合、Error: Field 'str' should be initialized because its type 'String' doesn't allow null.
とエラーになってしまう。main
の中で初期化しているが認識されていないようだ。
String str;
// late String str; // lateをつけることでコンパイラによるチェックを回避
void main() {
str = "value";
print(str);
}
この場合は late
を用いて late String str;
とすることでコンパイラによる初期化チェックを回避できる。
また、late
を使うと初期化処理を遅延させることができる。
例えば以下の場合、変数str
にアクセスした時に初めてsomeFunc
が実行される。
使用頻度が少ない変数や使用されるかわからない変数、初期化処理のコストが高い変数に用いるのが効果的。
late String str = someFunc();
late
は便利だが、コンパイラによる初期化チェックを回避するので
プログラマ側が変数を使用する段階で初期化済みであることを意識、保証しなければならない。
もし初期化せずにアクセスした場合、当然だが実行時エラーとなる。
組み込み型
整数型
-
int
とdouble
の2つ。どちらもスーパークラスnum
を継承
String
- 文字列型
-
""
と''
どちらにも対応 - 文字列リテラルに変数の値を挿入する場合は
$変数
、式の結果を挿入する場合は${式}
- 隣接する文字列リテラルは自動で連結される。
+
演算子で連結を明示することも可能。const str = "Hello" ' World!'; print(str); // Hello World!
- 複数行の文字列の場合は
"""
または'''
が便利。 - 文字列リテラルの前に
r
をおけば改行文字などの特殊文字を無効にできるvoid main() { const str = r"Hello\nWorld!"; print(str); // Hello\nWorld!と表示される(改行文字が文字としてそのまま表示される) }
bool
- 論理型
-
true
とfalse
List
- 配列(順序付きコレクション)
const list = [1, 2, 3, 4]; // 型推論 const list = <int>[1, 2, 3, 4]; // 型注釈
- 可変長と固定長がある
- リテラルで作られるのは可変長
-
List.unmodifiable()
(名前付きコンストラクタ)を使うと固定長- 固定長は要素数を変更しようとすると実行時エラーになる
Set
- 順序が保持されない重複しないコレクション
const values = {"Bob", "Tom", "Taro"}; // 中括弧({})で囲うとSetになる const values = <String>{"Bob", "Tom", "Taro"}; // 型注釈
Map
- 連想配列(key-valueペア)
const users = {1: "Bob", 2: "Tom", 3: "Taro" }; // 型推論 const users = <int, String>{1: "Bob", 2: "Tom", 3: "Taro" }; // 型注釈
-
const setOrMap = {};
のように値が何も無い場合はMap
として推論される
Record
- 複数の値を集約した不変の匿名型
- 他の言語でのタプル型に似ている
const record = ("Bob", 30); // 型推論
const (String, int) record = ("Bob", 30); // 型注釈
// 名前付きフィールド
const ({String name, int age}) record = (name: "Bob", age: 30);
// 名前付きフィールドは.(ドット)でアクセスできる
print("${record.name}は${record.age}歳です"); // Bobは30歳です
// 位置フィールド(型注釈の中で名前をつけることができるが、その名前でのアクセスはできない)
const (String name, int age) record = ("Bob", 30);
// 位置フィールドは.$引数の順番を表す数字でアクセスできる
print("${record.$1}は${record.$2}歳です"); // Bobは30歳です
// 名前付きフィールドと位置フィールドが混在する場合
const record = (price: 300, name: 'cake', 99);
// 型注釈では位置フィールドが常に先頭になる
const (int count, {String name, int price}) newRecord = record;
Object
- 全てのクラスのスーパークラス
const list = ["Bob", 30];
print(list.runtimeType); // List<Object>型に推論される
dynamic
- コンパイル時に型チェックが行われない特殊な型
- 存在しないメソッドを呼び出すコードであってもコンパイルエラーにならない
- nullチェックもされない
- 実行時エラーの可能性が高まるので明確な理由がなければ、
Object
あるいはObject?
を利用した方が良い
const list = <dynamic>["Bob", 30];
print(list.runtimeType); // List<dynamic>型
ジェネリクス
ジェネリッククラス
class Foo<T> { // 型パラメータ名としてTを与える
T _value; // Tを実際の型名のように使える
Foo(this._value);
T getValue() { // Tを実際の型名のように使える
return _value;
}
}
final intFoo = Foo(10);
print(intFoo.getValue()); // 10
final stringFoo = Foo("I'm Foo!");
print(stringFoo.getValue()); // I'm Foo!
ジェネリック関数
// `T?`はT型またはnullを表す
T? firstOrNull<T>(List<T> list) {
if (list.isEmpty) {
return null;
}
return list[0];
}
print(firstOrNull([5, 4, 3])); // 5
print(firstOrNull([])); // null
演算子
ほとんど他の言語と同じなので、大事なところだけピックアップして書いていく。
比較演算子
-
==
による比較のデフォルトの動作は参照の比較。オーバーライドして同値性を指定することも可能。両方がnull
の場合はtrue
、片方だけnull
の場合はfalse
になる。
カスケード記法
- オブジェクトのメソッドやプロパティに
..
でアクセスするとそのオブジェクトそのものが戻り値となる機能 - 同じオブジェクトに対して繰り返しアクセスする場合に便利
final sb = StringBuffer()
..write('Hello') // ..でアクセスすることでStringBufferのインスタンスが返る
..write(', ')
..write('World');
print(sb.toString()); // Hello, World
コレクションのオペレータ
-
List
、Set
、Map
のリテラルでのみ利用できる
// Spread演算子
final list1 = [0, 1, 2, 3];
final list2 = [-1, ...list1]; // list1をlist2の要素として展開
print(list2); // [-1, 0, 1, 2, 3]
// 制御構文演算子
// コレクションのリテラル内でifやforが使える
// 要素を追加する際の条件や他のコレクションを追加する時の前処理が行える
var needs3 = false;
final list3 = [1, 2, if (needs3) 3, 4];
print(list3); // [1, 2, 4]
final list4 = [for (var i in list1) i * 2]; // list1の要素を2倍したものを追加
print(list4); // [0, 2, 4, 6]
制御構文
他の言語と同じ部分も多いので特筆すべきところだけ書いていく
if-case文
- パターンマッチングと変数への分解を同時に行う
-
when
の後に条件式が書ける
final (String?, int?) response = ("OK", 200);
if (response case (String message, int statusCode) when statusCode >= 200 && statusCode < 300) {
// messageがnullでなく、statusCodeが200番台
print("$message, $statusCode");
} else if (response case (String message, int statusCode)) {
// messageがnullでなく、statusCodeが200番台以外
print("$message, $statusCode");
} else {
// message, statusCode少なくともいずれかがnull
print("Either value is null");
}
switch文
-
case
に一致するとswitch文を抜ける -
break
を使うとswitch文を抜ける -
case
の処理が空の場合は次のcase
の処理が行われる(フォールスルー)final String color = "green"; switch (color) { case "red": print("color is red"); case "green": // "green"の時の処理が何もないため次のcase "yellow"の処理が行われ、 コンソールには color is yellowが出力される case "yellow": print("color is yellow"); default: print("color is unexpected"); }
-
continue
文とラベルを使って任意のケースの処理にフォールスルー可能final String color = "red"; switch (color) { case "red": print("color is red"); continue error; // ラベルを指定することでそのラベル内caseの処理を実行できる case "yellow": print("color is yellow"); error: case "error": throw "This is an error"; // "red"と"error"の時に実行される default: print("color is unexpected"); }
-
switch文も
case
の後のwhen
の後に条件式が書けるfinal int? statusCode = null; switch (statusCode) { case (int statusCode) when 100 <= statusCode && statusCode < 200: print('informational'); case (int statusCode) when 200 <= statusCode && statusCode < 300: print('successful'); case (int statusCode) when 300 <= statusCode && statusCode < 400: print('redirection'); case (int statusCode) when 400 <= statusCode && statusCode < 500: print('client error'); case (int statusCode) when 500 <= statusCode && statusCode < 600: print('server error'); case (null): print('no response received.'); default: print('unknown status code'); }
-
switch
を式として扱うことができる。つまりswitch
の値を評価して値を返せる。final int statusCode = 201; final message = switch (statusCode) { >= 100 && < 200 => 'informational', >= 200 && < 300 => 'successful', >= 300 && < 400 => 'redirection', >= 400 && < 500 => 'client error', >= 500 && < 600 => 'server error', _ => 'unknown status code', }; print(message); // successful
ループ処理
-
for
文は普通に使える -
Iterable
を継承していればfor-in
やforEach
が使える -
while
やdo-while
も普通に使える -
break
でループを抜け、continue
で次のループにスキップする
パターン構文
- オブジェクトのマッチング(オブジェクトが特定の形式であるかを判断)
- 分解宣言(オブジェクトを変数に分解する機能)
一致判定
- コレクションの一致判定ではリテラルに
const
を付ける必要がある。
const Object value = {"key": 0};
switch (value) {
case const [0, 1, 2]:
print('list');
case const {0, 1, 2}:
print('set');
case const {'key': 0}:
print('map');
}
Listで分解宣言
- 分解先と分解元の要素数が一致している必要がある。
// ...は任意の長さにマッチさせ、先頭2要素と最後の要素を変数として取り出す
final [a, b, ..., c] = [0, 1, 2, 3, 4, 5];
print('a = $a, b = $b, c = $c'); // a = 0, b = 1, c = 5
Mapで分解宣言
- 分解先と分解元の要素数が一致している必要はないので、必要な要素だけを取り出せる。
// キーが一致するとvalueが変数(successfulやnotFound)にバインドされる
final {200: successful, 404: notFound} = {
200: 'OK',
404: 'Not Found',
500: 'Internal Server Error',
};
print('200 -> $successful, 404 -> $notFound'); // 200 -> OK, 404 -> Not Found
Recordで分解宣言
- 全ての構造が一致している必要がある。
- 名前付きフィールドはパターンにもフィールド名を含める必要がある。
final record = (name: 'cake', price: 300);
// パターンにもフィールド名を含めているのでマッチする
final (name: n, price: p) = record;
print('This $n is $p yen.'); // This cake is 300 yen.
// フィールド名を変数名で推論させる
final (:name, :price) = record;
print('This $name is $price yen.'); // This cake is 300 yen.
Objectで分解宣言
-
List
、Map
、Record
以外のクラスをマッチさせることが可能 - オブジェクト全体と一致する必要はなく、変数へのバインドを省略してクラスの一致だけでマッチさせることも可能
class SomeClass {
const SomeClass(this.x);
final int x;
}
void main() {
final someInstance = SomeClass(123);
final SomeClass(x: number) = someInstance;
print('x = $number'); // x = 123
// ゲッタ名を変数名で推論
final SomeClass(:x) = someInstance;
print('x = $x'); // x = 123
}
パターンを補助する構文
- 分解宣言する時に
as 型名
でキャストできる。キャストに失敗すると実行時エラー。 - nullチェック
- 非nullかどうかのチェックは変数名の後ろに
?
を付ける - nullの場合の処理が必要
- 非nullかどうかのチェックは変数名の後ろに
- nullアサーション
- 変数名の後ろに
!
を付けることで非nullであることを前提として処理が可能 - 非nullが前提なのでnullの場合の処理が不要
- ただし、nullだった場合に実行時エラー
- 変数名の後ろに
- ワイルドカード
-
_
を使うと変数にバインドさせることなくプレースホルダとして機能する - ワイルドカードパターンに型注釈を付与すると、クラスの一致だけでマッチさせることも可能
-
例外処理
-
throw
で投げることができる例外の型は2つ、Error
とException
- 実はこの2つ以外に任意のオブジェクトをスローすることも可能だが製品レベルコードでは非推奨
-
Error
- プログラムのエラー(関数の使い方が間違っているなど)
- 呼び出し元で捕捉する必要はない
-
Exception
- 呼び出し元で捕捉されることを目的とした例外
-
on 例外の型
で捕捉する例外の型を指定する -
catch(e, st)
で例外オブジェクトとスタックトレースが受け取れる -
rethrow
で例外を再スローできる - 他の言語と同様
finally
で例外発生有無に関わらずに処理を行える -
assert(bool値)
で開発中にバグがないかチェックできる。- bool値がfalseの場合はプログラムの実行を中断する
- Debugビルドの時のみ有効
- Flutterはフレームワークレベルで例外を捕捉するので、アプリ開発者が例外を捕捉しなくてもアプリがクラッシュすることはない
- フレームワークは2つの例外ハンドラ(
FlutterError.onError
とPlatformDispatcher.instance.onError
)を提供する -
FlutterError.onError
ではフレームワークがトリガーするコールバックで発生した例外を捕捉する -
PlatformDispatcher.instance.onError
はそれ以外の例外を捕捉する(例外を処理した場合はtrue
を返す)
null安全
-
??=
は 変数がnull
の時にだけ右辺を代入する
ライブラリと可視性
- Dartでは基本的に1つのDartファイルをライブラリと呼ぶ
- 外部ライブラリを使う場合は
import
を使用する - publicやprivateなどの可視性をコントロールするキーワードは無い。基本publicで他のファイルからアクセス可能
-
_
をクラス名や関数名の先頭につけるとprivateになり他のファイルからアクセスできなくなる
関数
- 関数の引数宣言には省略可能引数と名前付き引数がある
省略可能引数
-
[]
で囲った引数は関数を呼ぶときに省略できる - 引数リストの末尾に置く必要がある
- 省略されると
null
が渡る - デフォルト値を設定した場合は非null許容型にすることができる
名前付き引数
- 関数呼び出し時に引数の名前を指定させる仕組み
- 引数リストを
{}
で囲う - 名前付き引数はデフォルトで省略可能。必須にする場合
required
キーワードを使う。デフォルト値を設定することもできる - 引数リストの順番通りでなく好きな順に引数を指定して呼び出せる
- 引数リストの末尾に置く必要があるが、呼び出し時は位置引数を末尾に置いてもよい
関数の省略記法
- 関数が単一式の場合は
=>
を使って定義可能。return
キーワードも不要。
第一級関数と匿名関数
- 関数を変数に代入したり、関数の引数として受け取ることが可能
- 関数オブジェクトの型は
戻り値の型 Function(引数リストの型)
- 匿名関数は
(引数リスト){関数の本体}
で定義する - クロージャの性質を持ち、関数の戻り値として引数をキャプチャした匿名関数を返すことができる
クラス
初期化
- コンストラクタの後ろの
:
に続けて書く部分を初期化リストと呼ぶ - 初期化リストではパラメータのアサーションが可能
class Point { Point(this.x, this.y) : assert(x >= 0), assert(y >= 0); final int x; final int y; }
ゲッタとセッタ
-
全てのインスタンス変数が暗黙的にゲッタを持つ
-
final
が付いていないインスタンス変数は暗黙的にセッタを持つ -
カスタムなゲッタは
get
、セッタはset
キーワードで定義可能class User { User(this.id, this._password); final int id; String _password; // カスタムゲッタ String get password => "*****"; // パスワードをマスキング // カスタムセッタ set password(String newPassword) { _password = hash(newPassword); // パスワードをハッシュ化 } }
コンストラクタ種類
- constantコンストラクタ
- 名前付きコンストラクタ
- factoryコンストラクタ
constantコンストラクタ
- コンストラクタの先頭に
const
を付けたコンストラクタ - クラスインスタンスをコンパイル時定数(
const
)として扱うためのもの - インスタンス変数は全て
final
にする必要がある
class Point {
const Point(this.x, this.y);
final int x;
final int y;
}
const p = Point(1, 2); // constにできる
- constantコンストラクタは常にコンパイル時定数になるとは限らない
final p1 = const Point(1, 2);
const p2 = Point(1, 2);
final p3 = Point(1, 2);
// コンストラクタの前にconstを付けたインスタンス と const変数に代入されたインスタンスは同じインスタンス
print(p1 == p2); // true
// p3はconstを使っていないのでp1やp2とは別のインスタンス
print(p1 == p3); // false
print(p2 == p3); // false
名前付きコンストラクタ
- 複数のコンストラクタがある場合に、特別な意味を持つインスタンスを生成する場合に便利
- クラス名.識別子で使う
class Point {
const Point(this.x, this.y);
const Point.zero(): x = 0, y = 0; // 名前付きコンストラクタ
final int x;
final int y;
}
const pZero = Point.zero();
- コンストラクタから別のコンストラクタを呼ぶこともできる
const Point.zero(): this(0, 0); // 名前付きコンストラクタから別のコンストラクタを呼ぶ
factoryコンストラクタ
- キャッシュの利用など、必ずしも新しいインスタンスを生成しない場合や初期化リストに記述できないロジックがある場合に使う
- コンストラクタに
factory
キーワードを付けて、コンストラクタからインスタンスを返すreturn
文を記述する
class UserData {
static final Map<int, UserData> _cache = {};
UserData() {}
factory UserData.fromCache(int userId) {
// キャッシュを探す
final cache = _cache[userId];
if (cache != null) {
// キャッシュがあったので返す
return cache;
}
// キャッシュがなかったので新しいインスタンスを生成して返す
final newInstance = UserData();
_cache[userId] = newInstance;
return newInstance;
}
}
void main() {
final user1 = UserData.fromCache(1);
final user2 = UserData.fromCache(1);
print(user1 == user2); // true
}
クラス継承
- Dartの公式ドキュメントでは「Extend a class」(拡張)と呼んでいるもの
-
extends
スーパークラス名 で継承可能 - スーパークラスを参照するには
super
を使う
class Animal {
String greet() => "hello";
}
class Dog extends Animal {
String sayHello() => super.greet();
}
void main() {
final dog = Dog();
print(dog.greet()); // hello
print(dog.sayHello()); // hello
}
- スーパークラスのメソッドをオーバーライドできる
// スーパークラスのメソッドをオーバーライドする場合は @overrideアノテーションを付けることが推奨されている
String greet() => "bowwow";
- メソッドのオーバーライドの条件
- 戻り値の型が同じかそのサブクラス
- 引数の型が同じかそのスーパークラス
- 位置パラメータの数が同じ
- 非ジェネリックをジェネリックに、ジェネリックを非ジェネリックにはできない
スーパークラスのコンストラクタ
- サブクラスのコンストラクタではスーパークラスのデフォルトコンストラクタが自動的に呼ばれる
- スーパークラスにデフォルトコンストラクタが無い場合は、明示的にスーパークラスのコンストラクタを呼び出す必要がある
暗黙のインタフェース
- Dartでは全てのクラスに暗黙的にインタフェースが定義されている
- 暗黙的なインタフェースとは、「クラスの全ての関数とインスタンスメンバを持ったインタフェース」
-
implements
キーワードに続けてインタフェースとして実装する型名を指定する - 継承と違い、
implements
の場合は全てのメソッドとインスタンスメンバをオーバーライドしなければならない
拡張メソッド
- 既存のクラスにメソッドやゲッタ、セッタを追加できる
-
extension 拡張名 on 拡張元の型
- 拡張名が無い場合は同一ファイル内でのみ参照可能
- static関数は拡張メソッドとしては定義できないが、拡張メソッドから呼び出すことはできる
mixin
- Dartは多重継承できないが、それに似ているもの
-
mixin 名前
で定義する -
mixin
を使う側はwith
の後に使用するmixin名を指定する - クラスのようにメソッドやフィールドを宣言できる
- クラスとの違い
- インスタンス化できない(コンストラクタを持てない)
-
extends
キーワードで他のクラスから継承できない
- mixin定義時に
on 使用可能なクラス
とすることで特定のクラスのみ使えるように制限をかけることもできる- この制限により、特定のクラスで使われることが保証できるため、制限したクラスの機能(メソッドなど)がmixin内で使える
-
mixin class
で定義するとmixinとしても使えるし、classとしても使える- mixinとしての制限を持つし、classとしての制限も持つ(
on
キーワードが使えない) - class, mixin, or mixin class?
- mixinとしての制限を持つし、classとしての制限も持つ(
Enum
-
enum
キーワードで宣言 - コンストラクタやフィールド、メソッドを持ったEnum(enhanced enums)も定義可能
-
クラスに似ているが以下の条件がある
- 少なくとも1つ以上のenumインスタンスが先頭で定義されていなくてはいけない
- インスタンス変数は
final
でなければならない(mixin
で追加されるものも同様) - コンストラクタはconstantコンストラクタ あるいは factoryコンストラクタが宣言可能
- 他のクラスを継承できない
-
index
、hashCode
、==
演算子のオーバーライドができない -
values
という名前のメンバは宣言できない
クラス修飾子
- クラスやmixinに付与してインスタンス化や継承に制限を与えるもの
- タイプ1とタイプ2に分類できる
タイプ1
- インスタンス化、継承や実装に制限を付ける
タイプ2
- タイプ1以外の効果をもつ(タイプ1の効果を併せ持つものもある)
まとめ
タイプ | インスタンス化 | extendsによる継承 | implementsによる実装 | その他 | |
---|---|---|---|---|---|
abstract | 1 | ✖️ | ○ | ○ | abstract関数を定義できる 実装を持った関数も定義できる |
base | 1 | ○ | ○ | ✖️ | 左記の制限は自身が宣言されたライブラリ以外での制限 |
interface | 1 | ○ | ✖️ | ○ | 左記の制限は自身が宣言されたライブラリ以外での制限 |
abstract かつ interface | 1 | ✖️ | ✖️ | ○ | 左記の制限の「extendsによる継承の禁止」は自身が宣言されたライブラリ以外での制限 実装を持たない純粋なインタフェースの定義に使う |
final | 1 | ○ | ✖️ | ✖️ | 左記の制限は自身が宣言されたライブラリ以外での制限 |
mixin class | 2 | ✖️ | ✖️ | ○ | 左記の継承の制限は自身が宣言されたライブラリ以外での制限 クラスなのでインスタンス化可能。 mixinなのでextendsが使えず、コンストラクタも持てない |
sealed class | 2 | ✖️ | ✖️ | ✖️ | 左記の継承と実装の制限は自身が宣言されたライブラリ以外での制限 サブタイプをEnumのように扱える。 自身が宣言されたライブラリ以外ではすべてのサブタイプ化を禁止する意味ではfinalと同じだが、 クラス自身が暗黙的にabstract classになる。 switch文で全てのサブタイプが網羅されない場合はコンパイラが警告を出す。 |
非同期処理
Future
- 非同期処理の結果を扱う型
-
async
とawait
と組み合わせて同期的なコードのように記述可能 -
await
はasync
を付与したメソッド内でしか使えない -
async
を付与したメソッドの戻り値は暗黙的にFuture
クラスでラップされる - エラーハンドリングは
then-catchError
を使うか、try-catch
を使うかのどちらか-
try-catch
でエラーを捕捉するにはasync-await
を使う必要がある
-
- 例外発生時に代替の値を返す場合は
then
メソッドの引数onError
で処理する方法もある
Stream
- 非同期に連続した値を扱う型
-
async
とawait for
と組み合わせて同期的なコードのように記述可能 -
listen
メソッドで購読、データた通知された時のコールバックを登録する -
listen
メソッドの戻り値であるStreamSubscription
型のcancel
メソッドで購読をキャンセルできる- キャンセルすることでStreamでリソースの解放処理が発生する場合がある
- 解放処理の完了や例外を検知するために
cancel
メソッドの戻り値がFuture
型になっている
-
pause
メソッドで購読を一時停止、resume
メソッドで購読を再開できる -
Stream
型を返す関数を実装するにはasync*
を使う。関数が呼び出されるとStreamが生成され、購読されると関数の中身が実行されるStream<String> languages() async* { await Future.delayed(const Duration(seconds: 1)); yield 'Dart'; await Future.delayed(const Duration(seconds: 1)); yield 'Kotlin'; await Future.delayed(const Duration(seconds: 1)); yield 'Swift'; await Future.delayed(const Duration(seconds: 1)); yield* Stream.fromIterable(['JavaScript', 'C++', 'Go']); } void main() async { final languageStream = languages(); languageStream.listen((data) { print(data); // Dart, Kotlin, Swift, JavaScript, C++, Goが順番に出力される(最後の3つは同じタイミングで出力される) }); }
- Stream終了時に処理をするには
listen
メソッドのonDone
にコールバックを渡す -
async-await for
の場合、Streamが終了するとawait for文を抜けるので、Stream終了時に処理をしたい場合はfor文の後に書けばOK - Streamにはキャンセルしない限り終了しないものもある
- ex.)
Stream.periodic
- 一定の間隔で繰り返し値を通知するStream
- 終了しないStreamでawait for文の後に処理を書いても実行されないので注意
- ex.)
- エラーハンドリングは
listen
メソッドのonError
にコールバックを渡すか、try-catch
を使うかのどちらか-
try-catch
でエラーを捕捉するにはasync-await for
を使う必要がある
-
-
listen
メソッドの引数cancelOnError
で例外が発生した場合は購読をキャンセルするかどうかを指定できる- デフォルトは
false
(=購読は継続される)
- デフォルトは
StreamControllerクラス(以下SC)
-
async*
関数よりも簡単にStreamを生成する方法 -
add
メソッドで外部からイベント(値)を送信できる -
addError
で例外を送信する -
hasListener
プロパティで購読されているかどうかがわかる -
async*
との違い-
async*
は購読されるまで関数の本体が実行されないが、SCは購読されていなくてもadd
メソッドで値を送信でき、バッファリングされる(購読の一時停止時も同様にバッファリングされる) - バッファリングされた値は購読された時に一斉に通知される
- 用途によりメモリを消費する可能性があるので注意が必要
-
ブロードキャスト
- 1つのStreamに対して複数回購読すると例外が発生する
- 複数の購読者にイベントを通知するには
asBroadcastStream
メソッドを使う - ブロードキャストタイプのStreamは最初に購読されたタイミングで元のStreamの購読を開始する
- 2つ目以降の購読を開始するとその時以降のイベントは通知されるが、それまでの値は通知されない。
Streamを変更する
- Streamを変更するメソッドの代表格は以下
-
map
(Streamの値を変換) -
where
(Streamの値をフィルタ) -
take
(Streamの値を何個取るかを指定)
-
Zone
- 非同期処理のコンテキストを管理する仕組み
- 機能の1つに非同期処理で捕捉されなかった例外のハンドリングがある
- ただし、FlutterのエラーハンドリングはZoneではなく
PlatformDispatcher
を使うことが一般的
import 'dart:async'; // 戻り値がFuture型、例外をスローする関数 Future<String> fetchUserName() { var str = Future.delayed(const Duration(seconds: 1), () => throw 'User not found.'); return str; } void main() { // `runZonedGuarded`は第一引数で受け取った処理を自身のZoneで実行する // 第二引数に自身のZoneで発生した例外をハンドリングするコールバックを渡す runZonedGuarded(() { fetchUserName().then((data) { print(data); }); }, (error, stackTrace) { print('Caught: $error'); // Caught: User not found. }); }
- Zoneには他にも、print関数の動作を変更する機能や非同期コールバックの登録を捕捉する機能などがある
アイソレート
- スレッドやプロセスのような仕組み
- 専用のヒープメモリを持つ
- 専用の単一スレッドを持ち、イベントループを実行する
- アイソレート間でのメモリ共有はできない
- すべてのDartプログラムはアイソレートの中で実行される
- Flutterアプリを作るうえでアイソレートを意識することはほとんどない
- メインアイソレートが自動的に起動し、その中でDartプログラムが実行される
第3章 フレームワークの中心となるWidgetの実装体験
- FlutterアプリはUIをWidgetの階層構造で作っていく
- ほとんどのWidgetは
StatelessWidget
とStatefulWidget
分類できる -
runApp
関数は引数で指定したWidgetを画面全体に適用する関数
StatelessWidget
- 状態を持たない
- オーバーライドした
build
メソッドでUIを構成する - 自身で表示を更新する仕組みがない
- コンストラクタのKey引数は、フレームワークがWidgetのライフサイクルを判断するために使う
- 多くのケースで省略(
null
)で問題ない
- 多くのケースで省略(
- Widgetのコンストラクタは名前付き引数で、第一引数をKey型とするのが慣習
import 'package:flutter/material.dart';
void main() {
runApp(
Column(
children: [
AnimalView(text: "mouse", color: Colors.yellow),
AnimalView(text: "lizard", color: Colors.red),
],
),
);
}
class AnimalView extends StatelessWidget {
const AnimalView({super.key, required this.text, required this.color});
final String text;
final Color color;
Widget build(BuildContext context) {
return Container(
color: color,
width: 100,
height: 100,
child: Center(
child: Text(
text,
textDirection: TextDirection.ltr,
)
)
);
}
}
StatefulWidget
- 状態を持ち、自身で表示の更新ができる
- 状態変化時に表示を更新したい場合に使う
-
build
メソッドを持たない。代わりにcreateState
メソッドをオーバーライドしてStateオブジェクトを返す -
build
メソッドはState
クラスの方に実装する -
State
クラスのsetState
メソッドを呼ぶとbuild
メソッドが呼び出されてUIが更新される - 状態を変化させる時は
setState
の引数コールバック内で行う
import 'package:flutter/material.dart';
void main() {
runApp(
const Center(
child: Counter(),
),
);
}
class Counter extends StatefulWidget {
const Counter({super.key});
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
print('tapped!');
// `setState`を呼ぶことで`build`メソッドが呼び出される
setState((){
_count += 1;
});
},
child: Container(
color: Colors.red,
width: 100,
height: 100,
child: Center(
child: Text(
'$_count',
textDirection: TextDirection.ltr,
),
),
),
);
}
}
第4章 アプリの日本語化対応、アセット管理、環境変数
- パッケージはDartのライブラリ、アプリ、リソース等を含んだディレクトリ
- 多くのパッケージは
pubspec.yaml
に所定の記述をしてコマンド実行することで導入できる - pub.devでたくさんのパッケージが公開されている
- 多くのパッケージは
パッケージの導入方法
pubspec.yamlの中身
-
dependencies
- アプリが依存するパッケージ
-
dev_dependencies
- 開発時のみ利用するパッケージ(テスト関連パッケージなど)
コマンドでパッケージを導入
- dependenciesに追加
flutter pub add <パッケージ名>
- dev_dependenciesに追加する場合は
--dev
オプションを付けるflutter pub add --dev <パッケージ名>
- addコマンドでパッケージをpubspec.yamlに追加したら
get
でインストールflutter pub get
パッケージバージョンの指定方法
- 以下のようにバリエーションがあるが、通常は
^
を指定したキャレット構文が推奨されている - 最終的に決定したバージョン番号は
pubspec.lock
ファイルに記録される- チーム開発を行う時はlockファイルを共有することでバージョンを揃えることができる
# 2.1.0以上、互換性のある限り最新のバージョン(この場合は3.0.0未満)を利用する
shared_preferences: ^2.1.0
# 2.1.0以上 3.0.0未満のバージョンを利用する
shared_preferences: '>=2.1.0 <3.0.0'
# 2.1.0以下のバージョンを利用する
shared_preferences: '<=2.1.0'
# 2.0.0より新しいバージョンを利用する
shared_preferences: '>2.0.0'
# バージョンを2.1.1に固定する
shared_preferences: 2.1.1
# 指定なし(すべてのバージョンを許可)
shared_preferences: any
# 未指定(anyを指定したのと同じ意味になる)
shared_preferences:
パッケージバージョンの更新方法
-
flutter pub outdated
でパッケージの最新バージョンや更新の可否が確認できる - コマンド出力結果の意味
- Current
- pubspec.lockに記載されている現在のバージョン
- Upgradable
- pubspec.yamlに記載されたバージョンの範囲内の最新バージョン
- Resolvable
- pubspec.yamlの制約を考慮しない場合の最新バージョン
- Latest
- 最新の安定リリースバージョン
- Current
- Upgradableのバージョンに更新するには
flutter pub upgrade <パッケージ名>
- Resolvableのバージョンに更新するには
flutter pub upgrade --major-versions <パッケージ名>
- pubspec.yamlも自動的に更新される
- ResolvableのバージョンがLatestと異なる場合は
flutter pub deps
で依存関係を調査する。競合する依存関係によって最新バージョンを利用できない場合がある。
アプリを日本語に対応させる
- Flutterはデフォルト言語が英語なので日本語対応をしないと意図せず英語が表示されたり、英語圏の日付フォーマットが適用されたりする
- 日本語をサポートするには日本語対応が必須
- デフォルトではコンテキストメニューも英語になる
- デフォルトでは
DateFormat
で日付をフォーマットした場合も英語になる
アプリを日本にローカライズする
フレームワークが提供する表示文字列を日本語化する
- フレームワークが提供する表示文字列の翻訳情報は
flutter_localizations
パッケージとして提供されている -
flutter pub add flutter_localizations --sdk=flutter
で当該パッケージを導入
import "package:flutter/material.dart";
import "package:flutter_localizations/flutter_localizations.dart";
import "package:intl/intl.dart";
void main() {
runApp(
/* ◆ MaterialApp
マテリアルデザインに準拠したテーマの提供や
画面遷移の機能を内包したWidget */
const MaterialApp(
localizationsDelegates: [
// テキストの方向を扱う
GlobalWidgetsLocalizations.delegate,
// マテリアルデザインに準拠したウィジェットで扱う翻訳情報
GlobalMaterialLocalizations.delegate,
// iOSスタイルのウィジェットで扱う翻訳情報
GlobalCupertinoLocalizations.delegate
],
supportedLocales: [
Locale("ja", "JP"), // IANA言語サブタグレジストリ準拠の言語と地域を指定する
],
home: HomeScreen(),
),
);
}
コンテキストメニューを日本語化できた
日付フォーマットを日本語化する
-
intl
パッケージのAPIは独自のデフォルトロケールで動作し、このデフォルトは`Intl.defaultLocalePで取得・設定ができる
Widget build(BuildContext context) {
// 端末の言語設定をベースに`MaterialApp`ウィジェットの`supportedLocales`パラメータで渡したロケールの中から最適なロケールが選択される
// 今回は`Locale('ja', 'JP')`のみ設定しているので端末の言語設定によらず`ja_JP`となる
Intl.defaultLocale = Localizations.localeOf(context).toString();
// 以下省略
日付も日本語になった
iOSアプリの対応言語を設定する
- App Storeに表示されるアプリの対応言語を設定する
-
ios/Runner/Info.plist
- アプリの構成情報を記述するXML形式のファイル
- CFBundleLocalizationsキーにサポートする言語を記述する。
MaterialApp
ウィジェットのsupportedLocales
パラメータで渡したロケールと同じ言語を設定することが推奨されている。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <!-- 省略 --> <key>CFBundleLocalizations</key> <array> <string>ja</string> </array> </dict> </plist>
メッセージをローカライズする
- 日本語しか扱わない場合でも以下の方法でメッセージを扱うと管理がしやすく、後から複数言語に対応する場合のコストも下がる
- メッセージのローカライズも
intl
パッケージが提供している -
arbファイル
というJSON形式のファイルにメッセージを記述、コードジェネレータを使ってDartのコードに変換する
-
pubspec.yaml
のflutter
セクションのgenerate: true
でコードジェネレータを有効にする -
生成されたコードをプロジェクトから参照できるよう
flutter pub get
を実行する -
ローカライズの構成ファイルを作成する
- プロジェクトルートに
l10n.yaml
というファイルを作成して以下を記載する
template-arb-file: app_ja.arb // arbファイル名 output-class: L10n // ローカライズクラスのクラス名 nullable-getter: false // ローカライズクラスのゲッタがnull許容型かどうか。可能であればfalseにする。
- プロジェクトルートに
-
arbファイルを作成する
-
lib/l10n
ディレクトリを作成し、その中にapp_ja.arb
ファイルを作成する
{ "helloWorld": "こんにちは世界!", // キーに対して日本語メッセージを設定する "@helloWorld": { // キーの先頭に@をつけると属性を記述するキーとして扱われる "description": "お決まりの挨拶" } }
-
-
flutter gen-l10n
でDartのコードを生成する-
.dart_tool/flutter_gen/gen_l10n
ディレクトリにDartファイルが生成される
-
-
自動生成したコードを使う
const MaterialApp(
localizationsDelegates: L10n.localizationsDelegates, // localizationsDelegatesをL10nに置き換える
supportedLocales: L10n.supportedLocales, // supportedLocalesをL10nに置き換える
home: HomeScreen(),
),
Widget build(BuildContext context) {
・・・ 省略
// ローカライズクラスを取得。キー名のプロパティで文字列が取得できる
Text(L10n.of(context).helloWorld)
・・・ 省略
}
arbファイルで設定したテキストが表示された
arbファイルの扱い方
- arbファイルのプレースホルダ機能を使えば動的にメッセージを変えられる
- 変わる部分は
{単語名}
で記述する - メソッドの引数で受け取る
- 属性で変わる部分の型を指定できる
- 変わる部分は
- 単数系と複数形でメッセージを変えたい場合
- プレースホルダ名の後に
plural
と記述し、その後に条件(値)と表示したい文字列を記述する
{ "numOfSearchResult": "{count, plural, =0{There is no result} =1{1 result found} other{{count} results found}}", "@numOfSearchResult": { "description": "検索結果件数", "placeholders": { "count": { "type": "int" } } } }
- プレースホルダ名の後に
- 複数言語に対応する場合は言語ごとのarbファイルを用意する
- ファイル名の
_
と拡張子の間の文字列で対応する言語が決定する- 例えば、日本語の場合は
app_ja.arb
、英語の場合はapp_en.arb
- 例えば、日本語の場合は
-
@@locale
キーをarbファイルに記述して言語を決定することも可能{ "@@locale": "ja", "helloWorld": "こんにちは世界!", "@helloWorld": { "description": "お決まりの挨拶" } }
- ファイル名の
プロジェクトにアセットを追加する
アプリに画像を追加する
- アセット用のディレクトリを作成する
- 一般的にプロジェクトルートに
assets
という名前のディレクトリを作る -
pubspec.yaml
にアセットのパスを記述する-
flutter
セクションにサブセクションassets
を追加して - ファイルパスの指定もできるが、
/
で終わればディレクトリパスも指定可能 - ただし、ディレクトリは再帰的にファイルを探索してくれないので注意
flutter: assets: - assets/circle.png # ファイルで指定 - assets/ #ディレクトリで指定
-
Image
ウィジェットを使って画像を表示できる- 後述する
flutter_gen
パッケージを使うと文字列で画像のパスを指定する必要がなくなる
- 後述する
Image.asset('assets/circle.png')
-
- 一般的にプロジェクトルートに
端末の解像度に応じて画像を切り替える
-
数字x
という名前でディレクトリを作りと、解像度に応じて対応した画像を読み込んでくれる - 例えば、iOS用に基準となる画像サイズ, 2x, 3x の画像を用意する場合は以下のように画像を配置する
~/project_root
└── assets
├── 2x
│ └── circle.png
├── 3x
│ └── circle.png
└── circle.png
flutter_gen
- 型安全にアセットを扱うパッケージ
- アセットにアクセスするコードを自動生成してくれる
- ローカライズで生成したコードが格納されるディレクトリとこのパッケージは関係ない
- 以下のコマンドを実行してパッケージを導入する
flutter pub add --dev build_runner flutter_gen_runner
-
build_runner
はソースコード生成ツール -
flutter_gen_runner
はflutter_gen
のコードジェネレータ
-
- 以下のコマンドでコードを生成する(
/lib/gen/assets.gen.dart
が出力される)flutter packages pub run build_runner build
flutter_svg
- SVG画像を描画するウィジェットを提供しているパッケージ
- 以下のコマンドでパッケージを導入
flutter pub add flutter_svg
-
flutter_gen
にflutter_svg
用のオプションがある。オプションを有効にするにはpubspec.yaml
に以下を追加する。flutter_gen: integrations: flutter_svg: true
- SVG画像を配置したら以下のコマンドでコードを生成する
flutter packages pub run build_runner build
-
flutter_gen
でコードの自動生成でエラーになる場合などは 公式をチェック
環境変数(dart-define-from-file)
- APIのエンドポイントやアプリのログレベルはコードから分離して、設定できると良い。そのために環境変数を使う。
- Flutterの
dart-define-from-file
という仕組みで環境変数をコードから参照できる。 - 環境変数はJSONファイルで記述できる
- 例えばプロジェクト直下に
/define/env.json
を作成し、以下を記述{ "apiEndpoint": "https://example.com/api", "logLevel": 1, "enableDebugMenu": true }
- VSCodeの設定
Flutter Run Additional Args
に以下を追加
--dart-define-from-file=define/env.json
- プログラムから参照可能(const変数に代入しないとデフォルト値になってしまうので注意)
const endpoint = String.fromEnvironment('apiEndpoint'); print('API endpoint: $endpoint'); // API endpoint: https://example.com/api
第5章 テーマとルーティング
テーマ
-
MaterialApp
とThemeData
を使うとマテリアルデザインに則ってテーマを自動計算してくれる- ダークモード対応も簡単
- アプリ独自のテーマを管理するには
Theme Extension
を使う
ルーティング
- Flutterの画面遷移はスタックで管理されている
- Webアプリをサポートする際にブラウザと連携するAPI群が追加された(
Navigator 2.0
) - モバイルアプリの場合は
Navigator 1.0
で対応可能な場合が多い -
Navigator 2.0
は複雑なので使う場合はライブラリを使った方がよい- go_routerパッケージ
-
Navigator
ウィジェットで画面遷移のスタックを管理する - スタックで管理される画面の単位は
Route
クラス
Push
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SecondScreen(),
),
);
Pop
// 前の画面に戻る
Navigator.of(context).pop();
画面間のデータ受け渡し
-
遷移元
final newNumber = await Navigator.of(context).push<int>( // intは遷移先からの戻り値の型 MaterialPageRoute( builder: (context) { return SecondScreen(number: _number); }, ), ); setState(() { if (newNumber != null) { _number = newNumber; } });
-
遷移先
ElevatedButton( child: const Text('Increment'), onPressed: () => { Navigator.of(context).pop(number + 1), }, ), ・・・
名前付きルートによる画面遷移
- 遷移先の画面に名前をつけて、名前で画面遷移する方法
- 以下の制限事項により現在は非推奨
- ディープリンクとして利用した場合、常に同じ動作となり、ログイン状態によって遷移先を変える などのカスタマイズができない
- ディープリンクで中間の画面を生成すると、Webアプリとして実行した際にブラウザの進む/戻るボタンの挙動が不自然になる
Routerウィジェットを使った画面遷移(Navigator 2.0)
-
GoRouter.of(context).go(<パス>);
で次の画面に遷移できる- 画面スタックに新しい画面をプッシュしているのではなく、画面スタックを新しいものに置き換えている
- ルートの構成を変更し、
GoRoute
を入れ子にすることで戻るボタンをタップした時にエラーにならないようにする -
Navigator
ウィジェットを入れ子構造にするにはShellRoute
クラスを使う。- このクラスを使う場合、前画面に戻る(pop)には
GoRouter
クラスのpopメソッドを使う
- このクラスを使う場合、前画面に戻る(pop)には
- 画面遷移が複雑な場合、
GoRoute
クラスの入れ子構造で表現するのは難しい-
go_router
パッケージには画面スタックにプッシュするメソッドもあるので、これを使う
-
-
GoRouter
クラスのgoメソッドとpushメソッドの違い-
go
はGoRoute
の入れ子構造をそのまま画面スタックに反映する -
push
は1つのRoute
クラスをスタックにプッシュする
-
第6章 実施ハンズオン1 画像編集アプリを開発
- スマホの画像ライブラリから画像を取得するには
image_picker
パッケージを使う - 画像データを扱うためには
image
パッケージを使う - iOSで画像ライブラリにアクセスするには
Info.plist
にNSPhotoLibraryUsageDescription
キーを追加して画像ライブラリにアクセスする理由を明記しなければいけない - Flutter組み込みのアイコンは
Icons
で使える
第7章 状態管理とRiverpod
Riverpodとは
- 状態管理パッケージ
- いくつかのパッケージ群から成る
- 基本機能を提供するパッケージ
- Providerのコードを自動生成するパッケージ
- 静的解析(Lint)のパッケージ
- Provierは大きく以下の2種類に分類できる
- 関数ベース(外部から状態の変更ができない)
- クラスベース(外部から状態の変更ができる)
- 関連パッケージで何を使えば良いか迷う場合は以下のパッケージをインストールするのがオススメ
flutter pub add flutter_riverpod riverpod_annotation flutter pub add --dev riverpod_generator build_runner custom_lint riverpod_lint
-
part
とpart of
で1つのライブラリを複数ファイルに分割することができる-
import
の場合はpublicなメンバーしか読み込めないが、part/part of
の場合はprivateなメンバーも読み込める
-
非同期処理を行うProvider
- Provierが返す型を
Future
型で実装すると、Providerが提供する型がAsyncValue
という型になる- Riverpodが提供している便利クラスで
loading
、error
、data
の3つの状態を表現できる
- Riverpodが提供している便利クラスで
- Providerの提供する型を
Raw
型でラップすると、AsyncValue
型を無くすことができる
Providerから値を取得する
-
WidgetRef
の以下メソッドを使う-
watch
- ウィジェットのbuildメソッドで
watch
をした場合、Providerの値が変化するとウィジェットのbuildメソッドが呼ばれる
- ウィジェットのbuildメソッドで
-
read
- その時点でのProviderの値を取得する
-
- 値の監視が必要な場合は
watch
を、必要ない場合はread
を使う - Providerの
select
メソッドを使うと、通知してほしい値を絞ることができ、無駄にbuildメソッドが呼ばれることを防止できる。
Providerのライフサイクル
- Providerは購読者がいなくなると自動的に破棄される
- 以下のような場合にはProviderを自動で破棄させないようにすることもできる
- アプリ起動中は状態を保持したい
- 複数画面にまたがって状態を共有したい
-
@Riverpod(keepAlive: true)
アノテーションを使うことで自動で破棄されないようにできる - Providerを任意のタイミングで再構築(状態をリセット)したい場合は
refresh
メソッドを使う
Providerにパラメータを渡す
- 関数ベースのProviderの場合は第二引数以降に渡す
- クラスベースのProviderの場合は
build
メソッドの引数に渡す
第8章 実施ハンズオン2 ひらがな変換アプリを開発
入力値のバリデーション
- 文字列が空でないことを確認する
-
Form
ウィジェットとFormField
ウィジェットを組み合わせる -
Form
ウィジェットはStatefulWidget -
GlobalKey
からForm
ウィジェットのStateを取得してvalidate
メソッドを呼ぶ-
validate
メソッドを呼ぶと、Form
ウィジェットの子孫にあるFormField
ウィジェットでバリデーションが行われる
-
-
TextFormField
のvalidator
コールバックで空文字チェックを行う
-
入力文字を取得する
-
TextEditingController
を使うと入力文字を取得できる -
TextEditingController
は不要になったらdispose
メソッド呼んでメモリリークを回避するようにする -
State
クラスのdispose
メソッドはState
のライフサイクルメソッドの1つ-
StatefulWidget
が破棄される時に呼ばれる
-
ひらがな化するAPIを呼び出す
- gooラボのひらがな化API
- 利用登録とアプリケーションIDの取得が必要
-
json_serializable
パッケージがJSONのシリアライズ・デシリアライズのコードを生成してくれる
第9章 フレームワークによるパフォーマンスの最適化
BuildContenxtとは何者なのか
- Elementというクラス
- BuildContextの
findAncestorWidgetOfExactType
メソッド- ウィジェットの親を辿っていき、現在のウィジェットから一番近い、指定した型のウィジェットを取得できる
- BuildContextの
findAncestorStateOfType
メソッド- 直近の祖先のStateを取得
- Elementツリーが構成されていく様子
- runApp関数の中でルートになるElementとウィジェット(MaterialApp)が生成される
- ルートのElementがMaterialAppのElement生成を命令する。MaterialAppのElementがツリーの一部になる。
- MaterialAppのElementがMaterialAppのbuildメソッドを呼び、HomeScreenウィジェットが返却される
- MaterialAppのElementがHomeScreenのElement生成を命令する。HomeScreenのElementがツリーの一部になる。
- 以降、3〜4の工程を末端のウィジェットまで繰り返してElementのツリーを構成する。
- StatefulWidgetの状態を保持する役割
- StatefulWidgetのStateはStatefulWidgetよりもライフサイクルが長い
- StateはElementとライフサイクルが一致している(ElementがStatefulWidgetのStateを管理している)
- StatefulWidgetのライフサイクル < Elementのライフサイクル
- Elementは再利用される仕組みがある
Elementの再利用とパフォーマンス
-
RenderObjectElement
というクラスがRenderObject
というクラスを管理している-
RenderObject
はElement
と同様に独自のツリー構造を持つ
-
- RenderObjectはウィジェットのレイアウト計算を行う
- RenderObjectの親から子にサイズ制約を渡す
- 子のサイズが決まったら自身とのオフセット量を計算する
- この操作を末端まで繰り返す。高コスト。
- レイアウトが決まったらRenderObjectは描画処理を行う
- 描画命令を発行し、Flutterフレームワークよりも下層のFlutter Engineに対して描画を依頼する
- この描画処理ツリーの末端まで繰り返す。高コスト。
- RenderObjectは描画に必要な状態を保持する
まとめ
- RenderObjectはElementによって管理されており、Elementの再利用はRenderObjectの再利用につながる
- RenderObjectはレイアウト計算や描画といったコストの高い処理を行う
- RenderObjectはレイアウト計算や描画に必要な情報を保持しており、更新が不要な場合はスキップする
Keyは何に使うのか
- ウィジェットのコンストラクタ引数にはいつもKeyがあるが、何者なのか?
Elementが再利用される条件
- ウィジェットのインスタンスが同じ
- ウィジェットの型が同じ かつ Keyが同じ
- GlobalKeyが同じ
いつKeyを使うのか
- Elementの再利用により意図しないウィジェットと紐付いてしまうケース
局所的にWidgetを更新する仕組み
InheritedWidget
- 階層を超えてデータを渡せるウィジェット
- 例えば、Themeウィジェットが内部で生成している_InheritedThemeウィジェットがInheritedWidget
- BuildContextのdependOnInheritedWidgetOfExactTypeメソッド
- 祖先のInheritedWidgetを検索するAPI
- 計算量がO(1)
- InheritedWidgetの更新を購読する効果がある
まとめ
- InheritedWidgetはウィジェットの階層を越えてデータを提供することができる
- 階層を越えてウィジェットの再構築をトリガすることができる
第10章 高速で保守性の高いアプリを開発するためのコツ
- buildメソッドで高コストな計算をしない
- buildメソッドで大きなウィジェットツリーを構築しない
- 階層が少なくなるようなWidgetを選択する(Row & Columnではなく、Alignを使うなど)
- const修飾子を付与する
- buildメソッドが実行されても常に同じインスタンスが使われるようになる
- 先祖の再構築の影響を受けなくする(そのウィジェットは以下の表示更新は可能)
- なるべくconst修飾子が使えるウィジェットを選択する
- 独自のウィジェットクラスにconstantコンストラクタを実装する
- 状態を末端のウィジェットに移す
- Riverpodの状態監視は末端のウィジェットで行う
第11章 Flutterアプリ開発に必要なネイティブの知識
ネイティブ側で指定するOSやSDKのバージョンによって挙動が変わることがある。
指定するOSとSDKのバージョンは以下がある。
- 最低サポートOSのバージョン
- アプリをインストールできる最低のOSバージョン
- ビルドSDKバージョン
- ビルド時に使用するSDKのバージョン
- iOSはXcodeのバージョンによって使用できるAPIが変わる
- ターゲットSDKバージョン(Androidのみ)
- アプリを動作させたいSDKバージョン(どのバージョンの互換モードで動かすか)
アプリアイコン
-
flutter_launcher_icons
というパッケージを使うと、アプリアイコンを手軽に生成できる。 -
flutter_native_splash
というパッケージを使うと、スプラッシュ画面を自動生成できる。
Basic Widgets
Container
あるウィジェットがあった時、そのウィジェット(以降、子ウィジェットと呼びます)をContainerでラップすると以下のようなことができます。
- 背景に色をつける
- 子ウィジェットとContainerの間にpaddingをつけられる
- Containerに対してmarginをつけられる
背景色、padding、marginをつけてみました。
body: Container(
color: Colors.blue,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
child: const Text("Sample Text"),
),
decoration
プロパティを使うと、Containerに形状を与えることができます。
BoxDecoration
でBox型の形状を与え、borderRadius
で角丸にしてみました。
body: Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.pink.shade100,
borderRadius: BorderRadius.circular(16),
),
child: const Text("Sample Text"),
),
ラベルっぽい見た目になりました。
alignmentプロパティで子ウィジェットの配置を指定することができます。
body: Container(
color: Colors.blue,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
alignment: Alignment
.topRight, // alignmentを設定すると、Containerの幅と高さがContainerの親Widgetの幅と高さになるように拡張し、alignmentで指定した値に従って子ウィジェットが配置される
child: const Text("Sample Text"),
),
alignmentにtopRightを指定している通り、Containerの右上に子ウィジェットが配置されましたが、
Containerが大きくなっています。
これはalignmentを設定すると、Containerの幅と高さがContainerの親Widgetの幅と高さになるようにexpandする仕様のためです。
alignmentを設定して、Containerの幅と高さがContainerの親Widgetの幅と高さになった場合でも
Containerに対してwidthやheightを指定してサイズを指定することができます。
heightだけ指定してみました。
body: Container(
color: Colors.blue,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
alignment: Alignment.topRight,
height: 100, // Containerのwidthやheightを指定することができる
child: const Text("Sample Text"),
),
widthやheightのサイズを直接指定することもできますが、BoxConstraintsを使って最小・最大幅、最小・最大高さの制約を指定することでもサイズを変更することができます。
結果はheightだけ指定した場合と同じです。
body: Container(
color: Colors.blue,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
alignment: Alignment.topRight,
// BoxConstraintsで最大高さを指定
constraints: const BoxConstraints(maxHeight: 100),
child: const Text("Sample Text"),
),