Open59

Flutterに入門してみた

ピン留めされたアイテム
Jun KatoJun Kato

サードパーティパッケージを使わずに状態を管理する

「状態管理」、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メソッドを定義し、BuildContextdependOnInheritedWidgetOfExactTypeメソッドを使い、その結果を返すようにしておきます。
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です。
Child2buildメソッド中のMyState.of(context).count;でStateを取得しています。
Stateが変わるたびにChild2buildメソッドが呼び出され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を使う

ValueNotifierChangeNotifierをシンプルにしたものです。
その名の通り、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が標準で提供している状態管理の仕組みの理解が深まりました。

Jun KatoJun Kato

第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 のところでエラーが出ているが一旦無視して進める。
必要が生じたら以下記事を参考にエラーを解消する。
https://stackoverflow.com/questions/77403383/how-can-i-fix-flutter-doctor-error-code-27

Jun KatoJun Kato

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'
Jun KatoJun Kato

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を一旦設定して進めばよかったのだと思う。

Jun KatoJun Kato

作成したプロジェクトをiOS, Androidで動かす

iOSはiOSシミュレーター、Androidはエミュレーターを選択して実行ボタンをクリックすることでアプリが起動する。
Androidの場合、エミュレーターが作成されていなければ事前に作成する。
FlutterがサポートしているAPIレベルは以下ページで確認可能。
https://docs.flutter.dev/reference/supported-platforms

Jun KatoJun Kato

第2章 Dartの言語仕様

finalとconst

定数を宣言する方法にfinalconstがある。
それぞれ以下のように使用できる。

// 型注釈
final int value1 = 1;
// 型推論
final value2 = 2;

// 型注釈
const int value3 = 3;
// 型推論
const value4 = 4;

finalとconstの違い

  • constはコンパイル時定数なので、クラス変数などはconstが使えない
  • finalで宣言されたクラスのフィールドは変更可能
  • constで宣言されたクラスのフィールドは変更不可
Jun KatoJun Kato

遅延初期化

グローバル変数の初期化など、コンパイラが初期化を正しく判断できない場合がある。
例えば、以下の場合、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は便利だが、コンパイラによる初期化チェックを回避するので
プログラマ側が変数を使用する段階で初期化済みであることを意識、保証しなければならない。
もし初期化せずにアクセスした場合、当然だが実行時エラーとなる。

Jun KatoJun Kato

組み込み型

整数型

  • intdoubleの2つ。どちらもスーパークラスnumを継承

String

  • 文字列型
  • ""''どちらにも対応
  • 文字列リテラルに変数の値を挿入する場合は $変数、式の結果を挿入する場合は${式}
  • 隣接する文字列リテラルは自動で連結される。+演算子で連結を明示することも可能。
    const str = "Hello" ' World!';
    print(str); // Hello World!
    
  • 複数行の文字列の場合は""" または '''が便利。
  • 文字列リテラルの前にrをおけば改行文字などの特殊文字を無効にできる
    void main() {
      const str = r"Hello\nWorld!";
      print(str); // Hello\nWorld!と表示される(改行文字が文字としてそのまま表示される)
    }
    

bool

  • 論理型
  • truefalse

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>型
Jun KatoJun Kato

ジェネリクス

ジェネリッククラス

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
Jun KatoJun Kato

演算子

ほとんど他の言語と同じなので、大事なところだけピックアップして書いていく。

比較演算子

  • == による比較のデフォルトの動作は参照の比較。オーバーライドして同値性を指定することも可能。両方がnullの場合はtrue、片方だけnullの場合はfalseになる。

カスケード記法

  • オブジェクトのメソッドやプロパティに..でアクセスするとそのオブジェクトそのものが戻り値となる機能
  • 同じオブジェクトに対して繰り返しアクセスする場合に便利
final sb = StringBuffer()
  ..write('Hello') // ..でアクセスすることでStringBufferのインスタンスが返る
  ..write(', ')
  ..write('World');
print(sb.toString()); // Hello, World

コレクションのオペレータ

  • ListSetMapのリテラルでのみ利用できる
  // 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]
Jun KatoJun Kato

制御構文

他の言語と同じ部分も多いので特筆すべきところだけ書いていく

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-inforEachが使える
  • whiledo-whileも普通に使える
  • breakでループを抜け、continueで次のループにスキップする
Jun KatoJun Kato

パターン構文

  • オブジェクトのマッチング(オブジェクトが特定の形式であるかを判断)
  • 分解宣言(オブジェクトを変数に分解する機能)

一致判定

  • コレクションの一致判定ではリテラルに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で分解宣言

  • ListMapRecord以外のクラスをマッチさせることが可能
  • オブジェクト全体と一致する必要はなく、変数へのバインドを省略してクラスの一致だけでマッチさせることも可能
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
}
Jun KatoJun Kato

パターンを補助する構文

  • 分解宣言する時に as 型名でキャストできる。キャストに失敗すると実行時エラー。
  • nullチェック
    • 非nullかどうかのチェックは変数名の後ろに?を付ける
    • nullの場合の処理が必要
  • nullアサーション
    • 変数名の後ろに!を付けることで非nullであることを前提として処理が可能
    • 非nullが前提なのでnullの場合の処理が不要
    • ただし、nullだった場合に実行時エラー
  • ワイルドカード
    • _を使うと変数にバインドさせることなくプレースホルダとして機能する
    • ワイルドカードパターンに型注釈を付与すると、クラスの一致だけでマッチさせることも可能
Jun KatoJun Kato

例外処理

  • throwで投げることができる例外の型は2つ、ErrorException
    • 実はこの2つ以外に任意のオブジェクトをスローすることも可能だが製品レベルコードでは非推奨
  • Error
    • プログラムのエラー(関数の使い方が間違っているなど)
    • 呼び出し元で捕捉する必要はない
  • Exception
    • 呼び出し元で捕捉されることを目的とした例外
  • on 例外の型 で捕捉する例外の型を指定する
  • catch(e, st)で例外オブジェクトとスタックトレースが受け取れる
  • rethrowで例外を再スローできる
  • 他の言語と同様finallyで例外発生有無に関わらずに処理を行える
  • assert(bool値)で開発中にバグがないかチェックできる。
    • bool値がfalseの場合はプログラムの実行を中断する
    • Debugビルドの時のみ有効
  • Flutterはフレームワークレベルで例外を捕捉するので、アプリ開発者が例外を捕捉しなくてもアプリがクラッシュすることはない
  • フレームワークは2つの例外ハンドラ(FlutterError.onErrorPlatformDispatcher.instance.onError)を提供する
  • FlutterError.onErrorではフレームワークがトリガーするコールバックで発生した例外を捕捉する
  • PlatformDispatcher.instance.onErrorはそれ以外の例外を捕捉する(例外を処理した場合はtrueを返す)
Jun KatoJun Kato

ライブラリと可視性

  • Dartでは基本的に1つのDartファイルをライブラリと呼ぶ
  • 外部ライブラリを使う場合はimportを使用する
  • publicやprivateなどの可視性をコントロールするキーワードは無い。基本publicで他のファイルからアクセス可能
  • _をクラス名や関数名の先頭につけるとprivateになり他のファイルからアクセスできなくなる
Jun KatoJun Kato

関数

  • 関数の引数宣言には省略可能引数と名前付き引数がある

省略可能引数

  • []で囲った引数は関数を呼ぶときに省略できる
  • 引数リストの末尾に置く必要がある
  • 省略されるとnullが渡る
  • デフォルト値を設定した場合は非null許容型にすることができる

名前付き引数

  • 関数呼び出し時に引数の名前を指定させる仕組み
  • 引数リストを{}で囲う
  • 名前付き引数はデフォルトで省略可能。必須にする場合requiredキーワードを使う。デフォルト値を設定することもできる
  • 引数リストの順番通りでなく好きな順に引数を指定して呼び出せる
  • 引数リストの末尾に置く必要があるが、呼び出し時は位置引数を末尾に置いてもよい

関数の省略記法

  • 関数が単一式の場合は=>を使って定義可能。returnキーワードも不要。

第一級関数と匿名関数

  • 関数を変数に代入したり、関数の引数として受け取ることが可能
  • 関数オブジェクトの型は戻り値の型 Function(引数リストの型)
  • 匿名関数は(引数リスト){関数の本体}で定義する
  • クロージャの性質を持ち、関数の戻り値として引数をキャプチャした匿名関数を返すことができる
Jun KatoJun Kato

クラス

初期化

  • コンストラクタの後ろの:に続けて書く部分を初期化リストと呼ぶ
  • 初期化リストではパラメータのアサーションが可能
    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コンストラクタ
Jun KatoJun Kato

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
Jun KatoJun Kato

名前付きコンストラクタ

  • 複数のコンストラクタがある場合に、特別な意味を持つインスタンスを生成する場合に便利
  • クラス名.識別子で使う
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); // 名前付きコンストラクタから別のコンストラクタを呼ぶ
Jun KatoJun Kato

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
}
Jun KatoJun Kato

クラス継承

  • 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";
  • メソッドのオーバーライドの条件
    • 戻り値の型が同じかそのサブクラス
    • 引数の型が同じかそのスーパークラス
    • 位置パラメータの数が同じ
    • 非ジェネリックをジェネリックに、ジェネリックを非ジェネリックにはできない

スーパークラスのコンストラクタ

  • サブクラスのコンストラクタではスーパークラスのデフォルトコンストラクタが自動的に呼ばれる
  • スーパークラスにデフォルトコンストラクタが無い場合は、明示的にスーパークラスのコンストラクタを呼び出す必要がある
Jun KatoJun Kato

暗黙のインタフェース

  • Dartでは全てのクラスに暗黙的にインタフェースが定義されている
  • 暗黙的なインタフェースとは、「クラスの全ての関数とインスタンスメンバを持ったインタフェース」
  • implementsキーワードに続けてインタフェースとして実装する型名を指定する
  • 継承と違い、implementsの場合は全てのメソッドとインスタンスメンバをオーバーライドしなければならない
Jun KatoJun Kato

拡張メソッド

  • 既存のクラスにメソッドやゲッタ、セッタを追加できる
  • extension 拡張名 on 拡張元の型
    • 拡張名が無い場合は同一ファイル内でのみ参照可能
  • static関数は拡張メソッドとしては定義できないが、拡張メソッドから呼び出すことはできる
Jun KatoJun Kato

mixin

  • Dartは多重継承できないが、それに似ているもの
  • mixin 名前 で定義する
  • mixinを使う側はwithの後に使用するmixin名を指定する
  • クラスのようにメソッドやフィールドを宣言できる
  • クラスとの違い
    • インスタンス化できない(コンストラクタを持てない)
    • extends キーワードで他のクラスから継承できない
  • mixin定義時にon 使用可能なクラス とすることで特定のクラスのみ使えるように制限をかけることもできる
    • この制限により、特定のクラスで使われることが保証できるため、制限したクラスの機能(メソッドなど)がmixin内で使える
  • mixin class で定義するとmixinとしても使えるし、classとしても使える
Jun KatoJun Kato

Enum

  • enumキーワードで宣言
  • コンストラクタやフィールド、メソッドを持ったEnum(enhanced enums)も定義可能
  • クラスに似ているが以下の条件がある
    • 少なくとも1つ以上のenumインスタンスが先頭で定義されていなくてはいけない
    • インスタンス変数はfinalでなければならない(mixinで追加されるものも同様)
    • コンストラクタはconstantコンストラクタ あるいは factoryコンストラクタが宣言可能
    • 他のクラスを継承できない
    • indexhashCode== 演算子のオーバーライドができない
    • valuesという名前のメンバは宣言できない
Jun KatoJun Kato

クラス修飾子

  • クラスや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文で全てのサブタイプが網羅されない場合はコンパイラが警告を出す。
Jun KatoJun Kato

非同期処理

Future

  • 非同期処理の結果を扱う型
  • asyncawaitと組み合わせて同期的なコードのように記述可能
  • awaitasyncを付与したメソッド内でしか使えない
  • asyncを付与したメソッドの戻り値は暗黙的にFutureクラスでラップされる
  • エラーハンドリングはthen-catchErrorを使うか、try-catchを使うかのどちらか
    • try-catchでエラーを捕捉するにはasync-awaitを使う必要がある
  • 例外発生時に代替の値を返す場合はthenメソッドの引数onErrorで処理する方法もある

Stream

  • 非同期に連続した値を扱う型
  • asyncawait 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文の後に処理を書いても実行されないので注意
  • エラーハンドリングはlistenメソッドのonErrorにコールバックを渡すか、try-catchを使うかのどちらか
    • try-catchでエラーを捕捉するにはasync-await forを使う必要がある
  • listenメソッドの引数cancelOnErrorで例外が発生した場合は購読をキャンセルするかどうかを指定できる
    • デフォルトはfalse(=購読は継続される)
Jun KatoJun Kato

StreamControllerクラス(以下SC)

  • async* 関数よりも簡単にStreamを生成する方法
  • addメソッドで外部からイベント(値)を送信できる
  • addErrorで例外を送信する
  • hasListenerプロパティで購読されているかどうかがわかる
  • async*との違い
    • async*は購読されるまで関数の本体が実行されないが、SCは購読されていなくてもaddメソッドで値を送信でき、バッファリングされる(購読の一時停止時も同様にバッファリングされる)
    • バッファリングされた値は購読された時に一斉に通知される
    • 用途によりメモリを消費する可能性があるので注意が必要
Jun KatoJun Kato

ブロードキャスト

  • 1つのStreamに対して複数回購読すると例外が発生する
  • 複数の購読者にイベントを通知するにはasBroadcastStreamメソッドを使う
  • ブロードキャストタイプのStreamは最初に購読されたタイミングで元のStreamの購読を開始する
  • 2つ目以降の購読を開始するとその時以降のイベントは通知されるが、それまでの値は通知されない。

Streamを変更する

  • Streamを変更するメソッドの代表格は以下
    • map(Streamの値を変換)
    • where(Streamの値をフィルタ)
    • take(Streamの値を何個取るかを指定)
Jun KatoJun Kato

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関数の動作を変更する機能や非同期コールバックの登録を捕捉する機能などがある
Jun KatoJun Kato

アイソレート

  • スレッドやプロセスのような仕組み
    • 専用のヒープメモリを持つ
    • 専用の単一スレッドを持ち、イベントループを実行する
    • アイソレート間でのメモリ共有はできない
  • すべてのDartプログラムはアイソレートの中で実行される
  • Flutterアプリを作るうえでアイソレートを意識することはほとんどない
    • メインアイソレートが自動的に起動し、その中でDartプログラムが実行される
Jun KatoJun Kato

第3章 フレームワークの中心となるWidgetの実装体験

  • FlutterアプリはUIをWidgetの階層構造で作っていく
  • ほとんどのWidgetはStatelessWidgetStatefulWidget分類できる
  • 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,
          ),
        ),
      ),
    );
  }
}
Jun KatoJun Kato

第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:
Jun KatoJun Kato

パッケージバージョンの更新方法

  • flutter pub outdatedでパッケージの最新バージョンや更新の可否が確認できる
  • コマンド出力結果の意味
    • Current
      • pubspec.lockに記載されている現在のバージョン
    • Upgradable
      • pubspec.yamlに記載されたバージョンの範囲内の最新バージョン
    • Resolvable
      • pubspec.yamlの制約を考慮しない場合の最新バージョン
    • Latest
      • 最新の安定リリースバージョン
  • Upgradableのバージョンに更新するにはflutter pub upgrade <パッケージ名>
  • Resolvableのバージョンに更新するにはflutter pub upgrade --major-versions <パッケージ名>
    • pubspec.yamlも自動的に更新される
    • ResolvableのバージョンがLatestと異なる場合はflutter pub depsで依存関係を調査する。競合する依存関係によって最新バージョンを利用できない場合がある。
Jun KatoJun Kato

アプリを日本語に対応させる

  • 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();
    // 以下省略

日付も日本語になった

Jun KatoJun Kato

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>
    
Jun KatoJun Kato

メッセージをローカライズする

  • 日本語しか扱わない場合でも以下の方法でメッセージを扱うと管理がしやすく、後から複数言語に対応する場合のコストも下がる
  • メッセージのローカライズもintlパッケージが提供している
  • arbファイルというJSON形式のファイルにメッセージを記述、コードジェネレータを使ってDartのコードに変換する
  1. pubspec.yamlflutterセクションのgenerate: trueでコードジェネレータを有効にする

  2. 生成されたコードをプロジェクトから参照できるようflutter pub getを実行する

  3. ローカライズの構成ファイルを作成する

    • プロジェクトルートにl10n.yamlというファイルを作成して以下を記載する
    template-arb-file: app_ja.arb // arbファイル名
    output-class: L10n // ローカライズクラスのクラス名
    nullable-getter: false // ローカライズクラスのゲッタがnull許容型かどうか。可能であればfalseにする。
    
  4. arbファイルを作成する

    • lib/l10nディレクトリを作成し、その中にapp_ja.arbファイルを作成する
    {
        "helloWorld": "こんにちは世界!", // キーに対して日本語メッセージを設定する
        "@helloWorld": { // キーの先頭に@をつけると属性を記述するキーとして扱われる
            "description": "お決まりの挨拶"
        }
    }
    
  5. flutter gen-l10n でDartのコードを生成する

    • .dart_tool/flutter_gen/gen_l10nディレクトリにDartファイルが生成される
  6. 自動生成したコードを使う

const MaterialApp(
  localizationsDelegates: L10n.localizationsDelegates, // localizationsDelegatesをL10nに置き換える
  supportedLocales: L10n.supportedLocales, // supportedLocalesをL10nに置き換える
  home: HomeScreen(),
),

Widget build(BuildContext context) {
  ・・・ 省略
   
  // ローカライズクラスを取得。キー名のプロパティで文字列が取得できる
  Text(L10n.of(context).helloWorld)  
    
  ・・・ 省略
}

arbファイルで設定したテキストが表示された

Jun KatoJun Kato

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": "お決まりの挨拶"
          }
      }
      
Jun KatoJun Kato

プロジェクトにアセットを追加する

アプリに画像を追加する

  • アセット用のディレクトリを作成する
    • 一般的にプロジェクトルートに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
Jun KatoJun Kato

flutter_gen

  • 型安全にアセットを扱うパッケージ
  • アセットにアクセスするコードを自動生成してくれる
  • ローカライズで生成したコードが格納されるディレクトリとこのパッケージは関係ない
  • 以下のコマンドを実行してパッケージを導入する
    flutter pub add --dev build_runner flutter_gen_runner
    
    • build_runnerはソースコード生成ツール
    • flutter_gen_runnerflutter_genのコードジェネレータ
  • 以下のコマンドでコードを生成する(/lib/gen/assets.gen.dartが出力される)
    flutter packages pub run build_runner build
    
Jun KatoJun Kato

flutter_svg

  • SVG画像を描画するウィジェットを提供しているパッケージ
  • 以下のコマンドでパッケージを導入
    flutter pub add flutter_svg
    
  • flutter_genflutter_svg用のオプションがある。オプションを有効にするにはpubspec.yamlに以下を追加する。
    flutter_gen:
        integrations:
            flutter_svg: true
    
  • SVG画像を配置したら以下のコマンドでコードを生成する
    flutter packages pub run build_runner build
    
  • flutter_genでコードの自動生成でエラーになる場合などは 公式をチェック
Jun KatoJun Kato

環境変数(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
    
Jun KatoJun Kato

第5章 テーマとルーティング

テーマ

  • MaterialAppThemeDataを使うとマテリアルデザインに則ってテーマを自動計算してくれる
    • ダークモード対応も簡単
  • アプリ独自のテーマを管理するには Theme Extensionを使う

ルーティング

  • Flutterの画面遷移はスタックで管理されている
  • Webアプリをサポートする際にブラウザと連携するAPI群が追加された(Navigator 2.0
  • モバイルアプリの場合は Navigator 1.0で対応可能な場合が多い
  • Navigator 2.0は複雑なので使う場合はライブラリを使った方がよい
  • 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アプリとして実行した際にブラウザの進む/戻るボタンの挙動が不自然になる
Jun KatoJun Kato

Routerウィジェットを使った画面遷移(Navigator 2.0)

  • GoRouter.of(context).go(<パス>);で次の画面に遷移できる
    • 画面スタックに新しい画面をプッシュしているのではなく、画面スタックを新しいものに置き換えている
  • ルートの構成を変更し、GoRouteを入れ子にすることで戻るボタンをタップした時にエラーにならないようにする
  • Navigatorウィジェットを入れ子構造にするには ShellRouteクラスを使う。
    • このクラスを使う場合、前画面に戻る(pop)にはGoRouterクラスのpopメソッドを使う
  • 画面遷移が複雑な場合、GoRouteクラスの入れ子構造で表現するのは難しい
    • go_routerパッケージには画面スタックにプッシュするメソッドもあるので、これを使う
  • GoRouterクラスのgoメソッドとpushメソッドの違い
    • goGoRouteの入れ子構造をそのまま画面スタックに反映する
    • pushは1つのRouteクラスをスタックにプッシュする
Jun KatoJun Kato

第6章 実施ハンズオン1 画像編集アプリを開発

  • スマホの画像ライブラリから画像を取得するには image_pickerパッケージを使う
  • 画像データを扱うためにはimageパッケージを使う
  • iOSで画像ライブラリにアクセスするにはInfo.plistNSPhotoLibraryUsageDescriptionキーを追加して画像ライブラリにアクセスする理由を明記しなければいけない
  • Flutter組み込みのアイコンは Iconsで使える
Jun KatoJun Kato

第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
    
  • partpart ofで1つのライブラリを複数ファイルに分割することができる
    • importの場合はpublicなメンバーしか読み込めないが、part/part ofの場合はprivateなメンバーも読み込める

非同期処理を行うProvider

  • Provierが返す型をFuture型で実装すると、Providerが提供する型が AsyncValueという型になる
    • Riverpodが提供している便利クラスでloadingerrordataの3つの状態を表現できる
  • Providerの提供する型をRaw型でラップすると、AsyncValue型を無くすことができる

Providerから値を取得する

  • WidgetRefの以下メソッドを使う
    • watch
      • ウィジェットのbuildメソッドでwatchをした場合、Providerの値が変化するとウィジェットのbuildメソッドが呼ばれる
    • read
      • その時点でのProviderの値を取得する
  • 値の監視が必要な場合はwatchを、必要ない場合はreadを使う
  • Providerのselectメソッドを使うと、通知してほしい値を絞ることができ、無駄にbuildメソッドが呼ばれることを防止できる。

Providerのライフサイクル

  • Providerは購読者がいなくなると自動的に破棄される
  • 以下のような場合にはProviderを自動で破棄させないようにすることもできる
    • アプリ起動中は状態を保持したい
    • 複数画面にまたがって状態を共有したい
  • @Riverpod(keepAlive: true)アノテーションを使うことで自動で破棄されないようにできる
  • Providerを任意のタイミングで再構築(状態をリセット)したい場合は refreshメソッドを使う

Providerにパラメータを渡す

  • 関数ベースのProviderの場合は第二引数以降に渡す
  • クラスベースのProviderの場合は buildメソッドの引数に渡す
Jun KatoJun Kato

第8章 実施ハンズオン2 ひらがな変換アプリを開発

入力値のバリデーション

  • 文字列が空でないことを確認する
    • FormウィジェットとFormFieldウィジェットを組み合わせる
    • FormウィジェットはStatefulWidget
    • GlobalKeyからFormウィジェットのStateを取得してvalidateメソッドを呼ぶ
      • validateメソッドを呼ぶと、Formウィジェットの子孫にあるFormFieldウィジェットでバリデーションが行われる
    • TextFormFieldvalidatorコールバックで空文字チェックを行う

入力文字を取得する

  • TextEditingControllerを使うと入力文字を取得できる
  • TextEditingControllerは不要になったらdisposeメソッド呼んでメモリリークを回避するようにする
  • StateクラスのdisposeメソッドはStateのライフサイクルメソッドの1つ
    • StatefulWidgetが破棄される時に呼ばれる
Jun KatoJun Kato

第9章 フレームワークによるパフォーマンスの最適化

BuildContenxtとは何者なのか

  • Elementというクラス
  • BuildContextのfindAncestorWidgetOfExactTypeメソッド
    • ウィジェットの親を辿っていき、現在のウィジェットから一番近い、指定した型のウィジェットを取得できる
  • BuildContextのfindAncestorStateOfTypeメソッド
    • 直近の祖先のStateを取得
  • Elementツリーが構成されていく様子
    1. runApp関数の中でルートになるElementとウィジェット(MaterialApp)が生成される
    2. ルートのElementがMaterialAppのElement生成を命令する。MaterialAppのElementがツリーの一部になる。
    3. MaterialAppのElementがMaterialAppのbuildメソッドを呼び、HomeScreenウィジェットが返却される
    4. MaterialAppのElementがHomeScreenのElement生成を命令する。HomeScreenのElementがツリーの一部になる。
    5. 以降、3〜4の工程を末端のウィジェットまで繰り返してElementのツリーを構成する。
  • StatefulWidgetの状態を保持する役割
    • StatefulWidgetのStateはStatefulWidgetよりもライフサイクルが長い
    • StateはElementとライフサイクルが一致している(ElementがStatefulWidgetのStateを管理している)
    • StatefulWidgetのライフサイクル < Elementのライフサイクル
    • Elementは再利用される仕組みがある
Jun KatoJun Kato

Elementの再利用とパフォーマンス

  • RenderObjectElementというクラスがRenderObjectというクラスを管理している
    • RenderObjectElementと同様に独自のツリー構造を持つ
  • RenderObjectはウィジェットのレイアウト計算を行う
    • RenderObjectの親から子にサイズ制約を渡す
    • 子のサイズが決まったら自身とのオフセット量を計算する
    • この操作を末端まで繰り返す。高コスト。
    • レイアウトが決まったらRenderObjectは描画処理を行う
      • 描画命令を発行し、Flutterフレームワークよりも下層のFlutter Engineに対して描画を依頼する
      • この描画処理ツリーの末端まで繰り返す。高コスト。
  • RenderObjectは描画に必要な状態を保持する

まとめ

  • RenderObjectはElementによって管理されており、Elementの再利用はRenderObjectの再利用につながる
  • RenderObjectはレイアウト計算や描画といったコストの高い処理を行う
  • RenderObjectはレイアウト計算や描画に必要な情報を保持しており、更新が不要な場合はスキップする
Jun KatoJun Kato

Keyは何に使うのか

  • ウィジェットのコンストラクタ引数にはいつもKeyがあるが、何者なのか?

Elementが再利用される条件

  • ウィジェットのインスタンスが同じ
  • ウィジェットの型が同じ かつ Keyが同じ
  • GlobalKeyが同じ

いつKeyを使うのか

  • Elementの再利用により意図しないウィジェットと紐付いてしまうケース
Jun KatoJun Kato

局所的にWidgetを更新する仕組み

InheritedWidget

  • 階層を超えてデータを渡せるウィジェット
    • 例えば、Themeウィジェットが内部で生成している_InheritedThemeウィジェットがInheritedWidget
  • BuildContextのdependOnInheritedWidgetOfExactTypeメソッド
    • 祖先のInheritedWidgetを検索するAPI
    • 計算量がO(1)
    • InheritedWidgetの更新を購読する効果がある

まとめ

  • InheritedWidgetはウィジェットの階層を越えてデータを提供することができる
  • 階層を越えてウィジェットの再構築をトリガすることができる
Jun KatoJun Kato

第10章 高速で保守性の高いアプリを開発するためのコツ

  • buildメソッドで高コストな計算をしない
  • buildメソッドで大きなウィジェットツリーを構築しない
    • 階層が少なくなるようなWidgetを選択する(Row & Columnではなく、Alignを使うなど)
  • const修飾子を付与する
    • buildメソッドが実行されても常に同じインスタンスが使われるようになる
    • 先祖の再構築の影響を受けなくする(そのウィジェットは以下の表示更新は可能)
    • なるべくconst修飾子が使えるウィジェットを選択する
    • 独自のウィジェットクラスにconstantコンストラクタを実装する
  • 状態を末端のウィジェットに移す
  • Riverpodの状態監視は末端のウィジェットで行う
Jun KatoJun Kato

第11章 Flutterアプリ開発に必要なネイティブの知識

ネイティブ側で指定するOSやSDKのバージョンによって挙動が変わることがある。
指定するOSとSDKのバージョンは以下がある。

  • 最低サポートOSのバージョン
    • アプリをインストールできる最低のOSバージョン
  • ビルドSDKバージョン
    • ビルド時に使用するSDKのバージョン
    • iOSはXcodeのバージョンによって使用できるAPIが変わる
  • ターゲットSDKバージョン(Androidのみ)
    • アプリを動作させたいSDKバージョン(どのバージョンの互換モードで動かすか)
Jun KatoJun Kato

アプリアイコン

  • flutter_launcher_iconsというパッケージを使うと、アプリアイコンを手軽に生成できる。
  • flutter_native_splashというパッケージを使うと、スプラッシュ画面を自動生成できる。
Jun KatoJun Kato

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"),
),