torikatsu.dev

Flutterとかプログラミングとかガジェットとか書きます

【Flutter】flutterfire x riverpod x go_routerで認証ガードをつくるときの落とし穴

はじめに

お久しぶりです。とりかつ(torikatsu923)です。

Flutterで開発をしているとflutterfire x riverpod x go_router の構成をとることがよくあります。 この構成でGoRouterの認証ガードを作ってみたらハマリポイントがあることがわかりました。 今回はその落とし穴と回避方法について紹介します。

FlutterFire x riverpod x go_routerの落とし穴

FlutterFireでは認証状態はonAuthStateChanged()Stream<User?>として取り出すことができます。 ここにriveropdのStreamProviderを組み合わせると、認証状態の変更を監視するのが容易になります。

しかし、この方法には少しだけ問題があります。 例えばルーティングをgo_routerで行なっており、GoRouteredirectに認証ガードを設定したい時があります。

// router provider
// ...
GoRoute(
  path: '/home',
  redirect: (state) {
     // Stream<User?> to User?
    final user = ref.read(authProvider.select((v) => v.value);
    if (user == null) return '/signin';
    return null;
  }
)
// ...

この方法はうまくいきそうですが、認証状態の変更を伴う画面遷移で問題が起きます。 例えば以下のようなサインイン処理を考えます。

// sign in screen

// emit when user pressed sign in button.
Future<void> onSubmit() async {
   // ...
      await FirebaseAuth.instance
          .signInWithEmailAndPassword(email: email, password: password);
   context.go('/home');
}

これは一見うまくいきそうですがうまくいきません。 FirebaseAuthのauthStateChanged()は最新の状態が反映されるまでに若干タイムラグがあります。 それゆえ、サインインして/homeへ遷移した際のonAuthStateChangedの値は未認証のままとなります。 ここでGoRouterのredirectでuserが取れず認証されていないと判断され、/signinにリダイレクトされます。

このままではユーザは認証したのに画面遷移されない...となってしまいます。

回避方法

これを回避するためにはauthStateChagne()の更新を待ってから画面遷移する必要があります。

一般にstreamで次の値を待つには.firstを使用します。しかし、StreamProviderはBroadcastStreamのため、.firstで取得できる値は最新の値ではなく最新のキャッシュです。

そのため、次の値を待つためのextensionを準備します。

extension StreamExt<T> on Stream<T> {
  Future<T> next() {
    final _completer = Completer<T>();
    final sub = listen(null);
    sub.onData((e) {
      sub.cancel();
      _completer.complete(e);
    });
    return _completer.future;
  }
}

これで値の更新を待ってから画面遷移する準備ができました。

final user = _ref.read(authStreamProvider.stream).next();

authStateChagned同じ値が流れてくることがあります。 例えば現在の値がnullで次にまたnullが流れてきたり、現在がUserのときに次にUserが流れてくるということがあります。 これは値の更新を待って画面遷移したとしても、再度nullが渡ってくることがあると言うことです。

これを回避するため、以下のように値の変更があったときだけ流すようStreamを変換します。

final _prev = StateProvider<User?>((ref) => null);

final authStreamProvider = StreamProvider<User?>(
  (ref) => FirebaseAuth.instance.authStateChanges().transform(
    StreamTransformer.fromHandlers(
      handleData: ((data, sink) {
        if (data != ref.read(_prev)) {
          sink.add(data);
          ref.read(_prev.notifier).update((_) => data);
        }
      }),
    ),
  ),
);

やっていることは以下の通りです。 - Streamの直前の値をキャッシュする - Streamに値が流れてきた時に値が変更されているかチェックする - 値の変更があればsink.addする

以上で認証状態の更新を待って正しく画面遷移することができるようになりました。

// onSubmit in sign in screen
// ...
        final user = _ref.read(authStreamProvider.stream).next();
        await _ref.read(authenticatorProvider).signin(
              email: state.email.text,
              password: state.password.text,
            );
        if (await user == null) {
          throw AppError.unknown();
        } else {
          _ref.read(routerProvider.notifier).go('/home');
        }

おわりに

今回の記事ではFlutterで人気な構成での落とし穴とその回避方法を紹介しました。 この記事が誰かの助けになれば幸いです。

【Flutter】RivderpodのStateNotifierがoverrideWithProviderできるようになっていた

こんにちは、とりかつ(@torikatsu923)です。

RiverpodでScopedProviderを使わなくてもStateNotifierをoverrideできるようになっていました。 今回はStateNotifierをoverrideする方法について紹介します。

今までのStateNotifier.family

以前以下のような記事を書きました。 torikatsu923.hatenablog.com

この記事では StateNotifierFamily を用いてStateNotifierの初期値を設定していました。 そして、初期値を設定したStateNotifierをウィジェットツリーから利用するために以下のようなコードを書く必要がありました。

このコードでやっていることは以下のとおりです。

  1. StateNotifier.family( providerFamily )を定義する (最終的にウィジェットツリーから利用したいStateNotifier)
  2. 初期値を設定したproviderFamilyをprovideするめに利用するScopedProvider( provider )を定義する
  3. ProviderScopeで生成した providerFamily を、 provider をoverrideすることで子ウィジェットから利用可能にする

provider定義側

final provider =
    ScopedProvider<StateNotifierProvider<SomeStateNotifier, SomeState>>(
        (ref) => throw Error());

final providerFamily = 
    StateNotifierProvider.family<SomeStateNotifier, SomeState, String>(
        (ref, text) => SomeStateNotifier(text: text));

なんらかのウィジェット

      // ...
      return ProviderScope(
        overrides: [
          provider
              .overrideAs((watch) => providerFamily(text)),
        ],

ところがRiverpod 1.0.0で以下のような変更が入っていました。

All providers can now be scoped. Breaking: ScopedProvider is removed. To migrate, change ScopedProviders to Providers.

pub.dev

ScopedProviderが撤廃されて、全てのProviderが overrideWithProvider することが可能になりました。

これによって上と同じことを以下のコードで実現できるようになりました。

provider定義側

final provider = StateNotifierProvider<SomeStateNotifier, SomeState>(
        (ref) => throw Error());

final providerFamily = 
    StateNotifierProvider.family<SomeStateNotifier, SomeState, String>(
        (ref, text) => SomeStateNotifier(text: text));

なんらかのウィジェット

      // ...
      return ProviderScope(
        overrides: [
          provider.overrideWithProvider(providerFamily("some text")),
        ],

StateNotifierをoverrideできると嬉しいこと

Providerをoverrideするためのコードは大きく変化しているようには見えません。

では、この変更がどういう時に嬉しいのでしょうか。 この変更はWidgetRef経由でStateNotifierを利用する際にとても便利になります。

従来のScopedProviderを利用する場合は、StateNotifierへアクセスするために以下のようにする必要がありました。

ref.read(ref.read(provider))

ScopedProviderによってprovideされるproviderをread する必要があったため ref.read を二重にしています。大したことないように見えますが、実装を進めていくと毎回二重に ref.read をするのは結構面倒でした。

これがアップデートによって ref.read が一回で済むようになりました。

ref.read(provider)

二重に ref.read する手間を省ける上に、StateNotifier.familyを利用してもStateNotifierと同じように利用できる点がとても便利になりました。

【Flutter】freezed・json_serializableのファイル生成先を変更するためのbuild.yaml

こんにちは、とりかつ(@torikatsu923)です。 今回の記事ではfreezed・json_serializableのファイル生成先を変更する方法を紹介します。

課題感

freezed・json_serializableは、手動で作成するのが面倒なコードをサクッと生成できるため非常に便利です。しかし、生成対象のクラスが増えるにつれて生成ファイルの数がとても多くなります。

APIとの通信まわりのオブジェクトを格納するディレクトリがこのようになってしまった経験がある方は少なくないと思います。

f:id:torikatsu923:20220207061208p:plain:h300
生成ファイルだらけになってしまったディレクト

このままだと見通しが悪いため、生成ファイルは一箇所でまとめて管理したいですよね。

ファイル生成先を変更するためのbuild.yaml

以下のようにbuild.yamlで生成先を指定することで、ファイルの生成先を変更することができます。

build.yaml

targets:
  $default:
    builders:
      source_gen|combining_builder:
        options:
          build_extensions:
            '^lib/{{}}.dart': 'lib/generated/{{}}.g.dart'
      freezed:
        options:
          build_extensions:
            '^lib/{{}}.dart': 'lib/generated/{{}}.freezed.dart'

ここでfreezedとjson_serializable別で生成先を指定する必要がある点に注意です。

また、source_genをpubspec.yamlのdev_dependenciesに追加する必要がある点についても注意が必要です。

pubspec.yaml

dev_dependencies:
  # ...
  source_gen: ^1.2.1

これで自動生成ファイルを一箇所にまとめることができました。

f:id:torikatsu923:20220207235136p:plain:h300
ファイルの生成先を変更できた

参考

  • source_genのbuild.yamlの設定方法の紹介 pub.dev

  • 生成先のパスの指定方法について github.com

  • freezedのbuild_extensionsの対応についてのIssue github.com

【Flutter】Dartの実行環境を作る

こんにちは、とりかつ(torikatsu923)です。

Flutter開発をしていると、Dartの実行環境が欲しい時があります。 例えばFreezedのようなflutter sdkに依存しないDart onlyなパッケージを試したい時などが考えられます。

今回はDartの実行環境の作り方を紹介しようと思います。

目次

とりあえずHello worldしてみる

とりあえずdart runHello worldできるようにしてみます。

  1. main.dartを用意します。

main.dart

void main() {
  print('Hello world');
}
  1. main.dartを実行する

ターミナルで以下を実行します

$ dart run main.dart

f:id:torikatsu923:20220115191234p:plain

無事コンソールにHello Worldが表示されました。

しかしこのままだとパッケージを試したい時に不便です。 いつもならpub getとするところを、手でパッケージをダウンロードしてきてimportも頑張ってパスで指定する必要があります。

pub getできるようにする

pub getできるようにするためには手元のプロジェクトをパッケージにする必要があります。 Dartのプロジェクトの単位はパッケージで、最小構成は以下のようになります。

.
├── ./lib
│   └── ./lib/${パッケージ名}.dart
└── ./pubspec.yaml

ここでpubspec.yamlではnameでパッケージ名指定する必要があるため注意です。

今回はパッケージ名をplaygroundとするため、ファイルツリーとpubspec.yamlは以下のようになります。

.
├── ./lib
│   └── ./lib/playground.dart
└── ./pubspec.yaml

pubspec.yaml

name: playground

それでは実行してみます。

dart run ./lib/playground.dart

f:id:torikatsu923:20220115192829p:plain

無事に実行することができ、pub getもできるようになりました。

今のままでもいいですが、dart run ./lib/playground.dartのファイル指定が少し面倒です。

ファイル指定なしでdart runできるようにする

一旦dart runしてみます。

You should edit ~/Desktop/flutter_dev/playground/pubspec.yaml to contain an SDK constraint:

environment:
  sdk: '>=2.10.0 <3.0.0'

See https://dart.dev/go/sdk-constraint

エラーっぽいものが出てきました。僕の環境ではdart2.15.1なのですが、dart2以降はpubspec.yamldartのバージョンを指定する必要があるみたいです。

~/desktop/flutter_dev/playground
❯ dart --version    
Dart SDK version: 2.15.1 (stable) (Tue Dec 14 13:32:21 2021 +0100) on "macos_x64"

pubspec.yamlを次のようにして再度dart runしてみます。

pubspec.yaml

name: playground
environment:
  sdk: '>=2.10.0 <3.0.0'
~/desktop/flutter_dev/playground
❯ dart run   
Could not find `bin/playground.dart` in package `playground`.

ファイルがないと怒られました。ファイル無指定時のdart runのエントリーポイントはlib/playground.dartではなく、bin/playground.dartのようです。 なのでbin/playground.dartを作って再度dart runします。

bin/playground.dart

import 'package:playground/playground.dart' as playground;

void main() {
  playground.main();
}

ファイルツリー

.
├── ./bin
│   └── ./bin/playground.dart
├── ./lib
│   └── ./lib/playground.dart
└── ./pubspec.yaml

無事実行されました!

~/desktop/flutter_dev/playground
❯ dart run
Building package executable...
Built playground:playground.
Hello World

【必殺技】dart createに頼る

ここまで手でpubspec.yaml等を作成してきました。 実はここまでの手順は以下のコマンドで一発でいけます。

dart create ${パッケージ名}

f:id:torikatsu923:20220115195507p:plain

めっちゃ楽ですね...

おわりに

今回はDartの実行環境の作り方を紹介しました。 普段Flutter触ってるけどDartのプロジェクト作り方わからんって人の助けになればと思います。

【Flutter】dart-definesでアプリの環境を切り替える

はじめに

こんにちは、とりかつ(@torikatsu923)です。

アプリ開発をしていると本番、ステージング、開発と環境を分けたい場合があります。 Flutterでアプリの環境を切り替える方法として、今まではFlavorを使う方法がありました。

最近、DartDefinesという仕組みでも環境を切り替えることができるようになりました。 Flavorと比べ、DartDefinesを使った方が設定がシンプルになります。

今回の記事では目的、OS別(android, iOS)にDartDefinesを利用したアプリの環境切り替え方法を紹介します。

この記事の読み方

この記事では基本設定が終わっている前提で各環境の切り替えの解説をします。そのため、初めに基本設定を読むことを強くお勧めします。 基本設定の後はどの順番で読んでいただいても大丈夫です。

この記事の読み方
この記事の読み方

環境切り替えの対象

  • アプリアイコン
  • アプリのID(bundleId, applicationId)
  • アプリの表示名
  • Firebaseの設定ファイル(GoogleService-Info.plist, google-services.json)
  • Dartのコード中の設定

サンプルコード

サンプルコードは以下のリポジトリです。切り替え対象を全て切り替えられるよう設定した状態になります。

github.com

目次

基本設定

この章ではネイティブ側でDartDefinesの値を利用できるようにする方法を解説します。この基本設定が終わると以下のようにflutter runで環境を指定できるようになります。

$ flutter run --dart-defines=FLAVOR=${環境}

環境

切り替える環境は以下の3つです。

環境 FLAVOR
開発 dev
ステージング stg
本番 prd

iOS

iOSでは環境ごとに.xcconfigを用意しておき、ビルド時にDartDefinesの値から読み込む.xcconfigを変更する方法をとります。

1. 環境に対応するxcconfigを作成する

以下の.xcconfigRunner > Flutterに作成します。

  • dev.xcconfig
  • stg.xcconfig
  • prd.xcconfig

Runner > Flutterを右クリックし、New File...を選択します。 NewFileを選択するとモーダルが表示されるため、Configuration Settings Fileを選択します。

選択後Save Asを作成したいファイル名に変更し、createを押してファイルを作成します。

作成したxcconfigの中身は以下のようにします。

FLAVOR=devまたはstgまたはprd

dev.xcconfigの場合はこのようになります。

次に環境ごとのxcconfigをincludeできるようDebug.xcconfigRelease.xcconfigに以下を追記します。

#include "DartDefine.xcconfig"

この後、ビルド時にFlavorに対応するxcconfigをincludeするDartDefine.xcconfigというファイルを動的に生成するようにします。そのためここではDartDefine.xcconfigをincludeするようにしています。

2. DartDefinesに対応するxconfigをincludeするシェルスクリプトを作成する

以下の内容のファイルをios/scripts/dart_define.shに置きます。

#/bin/bash
echo $DART_DEFINE | tr ',' '\n' | while read line; 
do
  echo $line | base64 -d | tr ',' '\n' | xargs -I@ bash -c "echo @ | grep 'FLAVOR' | sed 's/.*=//'"
done | (
  read flavor
  echo "#include \"$flavor.xcconfig\"" > $SRCROOT/Flutter/DartDefine.xcconfig
)

DartDefinesの値は$DART_DEFINESでアクセスできます。
また$SRCROOTiosディレクトリのパスを指しています。

以下のように指定した場合、$DART_DEFINESで得られる値は次のようになります。

--dart-defines=A=valueA,FLAVOR=dev,B=valueB
QT1hLEZMQVZPUj1kZXYsQj1i,RkxVVFRFUl9XRUJfQVVUT19ERVRFQ1Q9dHJ1ZQ==

どうやら実行時に指定した値以外にFLUTTER_WEB_AUTO_DETECT=truebase64エンコードした値がカンマ区切りで追加されるようです。

この文字列からFLAVORの値を取り出し、include対象を指定したxcconfigを吐き出すため、ワンライナーで以下の処理をしています。

  1. 文字列をカンマ区切りる
  2. カンマで区切った各文字列をbase64でデコードし、カンマで区切る
  3. FLAVOR=xxxが見つかったら=より右側の値を取り出す
  4. flavorに対応するxcconfigincludeするDartDefine.xcconfigというファイルを生成する

3. ビルド前に2.のスクリプトが走るようPre-actionを設定する

Runner > edit schema...を選択しモーダルを表示します。

Build > Pre-actionを開き以下を設定します。

Provide build setting from

Runner

スクリプト

sh "$SRCROOT/scripts/dart_define.sh"

4. .gitignoreの編集

ビルドのたびに差分が発生しないよう.gitignoreに以下を追記します。

.gitignore

DartDefine.xcconfig

iOSでDartDefinesを受け取る基本設定は以上になります。

android

androidではbuild.gradle(app)でDartDefinesから値を取り出して変数に代入する方法をとります。

android/app/build.gradleに以下を追記します。追記する場所はどこでもいいですが他の環境切り替えの設定でandroid.defaultConfigでDartDefinesの値を利用することを考えると、android { ...よりも前にする必要があります。

def dartEnvironmentVariables = project
            .property('dart-defines')
            .split(',')
            .collectEntries {
                new String(it.decodeBase64(), 'UTF-8')
                    .split(',')
                    .collectEntries {
                        def pair = it.split('=')
                        [(pair.first()): pair.last()]
                    }
            }

DartDefinesの値はproject経由でアクセスすることができます。DartDefinesはbase64エンコードされた文字列がカンマ区切りになっているため、split(',')してさらに各値をsplit('=')することでDartDefinesに渡された値全てを含むmapを作成しています。 [(pair.first()):pir.last()]よりmapのキーはDartDefinesに指定した変数名になるため、FLAVORの値は以下のようにアクセスできるようになります。

dartEnvironmentVariables.FLAVOR

androidの基本設定は以上になります。

アプリアイコン

本節ではflutter_launcher_iconsで自動生成したアイコンを使用して環境ごとにアイコンを切り替える方法を紹介します。

アイコンの自動生成

初めにpubspec.yamlにflutter_laucher_icons`の依存関係を追加します。

...

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^1.0.0
  flutter_launcher_icons: ^0.9.2 # 依存関係を追記

...

依存関係を追加したら以下を実行します。

$ flutter pub get

次に環境ごとのアイコン画像を用意します。今回は以下の3つの画像を用意してassets/launcher_icons/に置いておきます。

画像が用意できたら環境ごとの設定ファイルを用意します。今回は以下の3つを用意しました。

  • flutter_launcher_icons-dev.yaml
  • flutter_launcher_icons-stg.yaml
  • flutter_launcher_icons-prd.yaml

ファイル名はflutter_launcher_icons-${環境名}.yamlとする必要があり、置く場所はpubspec.yamlと同じ階層にする必要があります。

それぞれのファイルの中身は以下のようにします。以下はdevの例で、アイコン画像のパス(image_path)は環境ごとに変えておく必要があります。

flutter_icons:
  android: true
  ios: true
  image_path: "assets/launcher_icons/dev.png"

さらにエラーが出てしまうことを防ぐため、pubspec.yamlの一番最後に以下を追記します。

flutter:
  uses-material-design: true

# ここから先を追記
flutter_icons:
  android: true
  ios: true

最後に以下を実行してアイコン画像を自動生成します。

$ flutter pub run flutter_launcher_icons:main

このコマンドによってandroidandroid/app/src/${環境}/resに、iOSios/Runner/Assets.xcassets/AppIcon-${環境}.appiconsetにアイコンが生成されました。

あとはネイティブ側ビルド時に使用するアイコンを切り替えればOKです。

iOS

iOSでは使用するアイコンを変数によって切り替えることでアイコン切り替えを実現します。

xcodeでTargetのRunner > Build Settingsを開き、Primary App Icon Set Nameを以下に書き換えます。

AppIcon-$(FLAVOR)

これでiOSのアイコンを切り替えることができるようになりました。

android

androidではビルド時に環境に対応するアイコンをandroid/app/src/main/res/にコピーすることでアイコンを切り替えます。

android/app/build.gradleを開き以下を追記します。追記する場所はdef dartEnvironmentVariablesandroid { ...の間である必要があります。

task copyIcons(type: Copy) {
    from "src/${dartEnvironmentVariables.FLAVOR}/res"
    into 'src/main/res'
}

tasks.whenTaskAdded {
    it.dependsOn copyIcons
}

copyIconsでは標準で用意されているcopyタスクを使用しています。fromにコピー元、intoにコピー先を指定するだけでファイルをコピーするタスクを簡単に定義することができます。 タスクを定義したらwhenTaskAddedでタスクの依存関係にcopyIconsを追加します。これによって何かしらのタスクが実行された際にアイコンをコピーするタスクを実行することができるようになります。

whenTaskAddedが既に定義されている場合、既存のwhenTaskAddedクロージャ内にit.dependsOn copyIconsを追記してください。

ビルドのたびに差分が発生しないよう.gitignoreに以下を追記します。

.gitignore

**/android/app/src/main/res/mipmap-*/ic_launcher.png

これでandroidでもアイコンを切り替えることができるようになりました。

アプリのID(bundleId, applicationId)

本節では変数を利用してアプリのIDを切り替える方法を紹介します。

iOS

まず各環境に対応するxcconfigAPP_ID_PREFIXという変数を定義します。今回はdev, stg, prdと3つの環境に対応するために以下のようにしました。

dev.xcconfig

# ...
APP_ID_PREFIX=dev.

stg.xcconfig

# ...
APP_ID_PREFIX=stg.

prd.xcconfig

# ...
APP_ID_PREFIX=

次にxcodeTARGETS > Runner > Build Settingsを開き、Product Bundle IdentifierAPP_ID_PREFIXを参照するようにします。サンプルのアプリIDはcom.example.flutterenvのため、以下のようになります。

$(APP_ID_PREFIX)com.example.flutterenv

これでiOSのアプリIDを切り替えられるようになりました。

android

androidにはapplicationIdSuffixが用意されています。ここに値を設定することでapplicationIdにサフィックスを追加することができます。

android/app/build.gradleを開いて、defaultConfigの最後に以下を追記します。

android {

    // ....

    defaultConfig {
     
        // ...

        if(dartEnvironmentVariables.FLAVOR == 'dev') {
            applicationIdSuffix ".dev"
        } else if(dartEnvironmentVariables.FLAVOR == 'stg') {
            applicationIdSuffix ".stg"
        } else if(dartEnvironmentVariables.FLAVOR == 'prd') {
            applicationIdSuffix ""
        } else {
            throw new GradleException("unknown flavor" + dartEnvironmentVariables.FLAVOR + "has passed")
        }
    }

これでandroidでアプリIDを切り替えることができるようになりました。

アプリの表示名

本節では変数を参照することでアプリの表示名を切り替える方法を紹介します。それぞれのOSで以下のようにアプリの表示名が切り替わるようにします。

  • dev.${アプリ名}
  • stg.${アプリ名}
  • ${アプリ名}

iOS

まず各環境に対応するxcconfigAPP_ID_PREFIXという変数を定義します。今回はdev, stg, prdと3つの環境に対応するために以下のようにしました。(アプリIDの手順を先に行っている場合はこの手順は飛ばしてください。)

dev.xcconfig

# ...
APP_ID_PREFIX=dev.

stg.xcconfig

# ...
APP_ID_PREFIX=stg.

prd.xcconfig

# ...
APP_ID_PREFIX=

次にxcodeTARGETS > Runner > Infoを開き、Bundle display nameAPP_ID_PREFIXを参照するようにします。サンプルのアプリの名前はflutterenvのため、以下のようになります。

$(APP_ID_PREFIX)flutterenv

これでiOSで環境ごとに表示名を切り替えることができるようになりました。

android

初めにandroid/app/build.gradleを開いて、defaultConfigの最後に以下を追記します。

android {

    // ....

    defaultConfig {
     
        // ...

        if(dartEnvironmentVariables.FLAVOR == 'dev') {
            manifestPlaceholders += [appNamePrefix:"dev."]
        } else if(dartEnvironmentVariables.FLAVOR == 'stg') {
            manifestPlaceholders += [appNamePrefix:"stg."]
        } else if(dartEnvironmentVariables.FLAVOR == 'prd') {
            manifestPlaceholders += [appNamePrefix:""]
        } else {
            throw new GradleException("unknown flavor" + dartEnvironmentVariables.FLAVOR + "has passed")
        }
    }

manifestPlaceholdersに環境ごとのappNamePrefixを追加します。こうすることでAndroidManifest.xmlからbuild.gradleで追加した変数を利用することができます。

以下のように代入してしまうとflutterのプラグイン中で追加された変数を上書きしてしまうためビルドに失敗します。

manifestPlaceholders = [appNamePrefix:""]

次にandroid/app/src/main/AndroidManifest.xmlを開き、android:labelでgradleで追加した変数を使用するようにします。

<manifest
    ...
   <application
         android:label="${appNamePrefix}flutterenv" // この行を編集
         android:name="${applicationName}"
         ...

これでandroidのアプリ表示名を切り替えることができるようになりました。

Firebaseの設定ファイル(GoogleService-Info.plist, google-services.json)

本節ではFirebaseの設定ファイルの切り替え方を紹介します。iOS, androidどちらもファイルをコピーする方法をとります。

iOS

以下のファイル名で各環境のGoogleService-Info.plistを用意します。

  • devGoogleService-Info.plist
  • stgGoogleService-Info.plist
  • prdGoogleService-Info.plist

ファイルが用意できたらios/Firebase/に置きます。

xcodeTARGETS/Runner/Build Phasesを開き、右上の+ボタンからNew Run Script Phaseを選択します。

追加できたら以下のスクリプトを設定します。

cp -f ${SRCROOT}/Firebase/${FLAVOR}GoogleService-Info.plist ${SRCROOT}/GoogleService-Info.plist

Phaseの名前はわかりやすいようにCopy GoogleServiceinfo.plistに変更しています。

最後にGoogleService-Info.plistの参照をプロジェクトに追加します。

まずios直下にGoogleService-Info.plistを置きます。このファイルは後で削除するためファイル名さえあっていれば中身はなんでもOKです。

次にfinderからxcodeのRunner直下にGoogleService-Info.plistドラッグ&ドロップします。

ダイアログが表示されたらFinishを押します。これでファイルの参照を作ることができました。

finderからGoogleService-Info.plistを削除します。削除後にxcodeGoogleService-Info.plistが赤くなっていれば成功です。

ビルドのたびに差分が発生するのを避けるために.gitignoreに以下を追記します。

.gitignore

**/ios/GoogleService-Info.plist

以上でビルド時にiOSのFirebaseの設定ファイル切り替えられるようになりました。

android

以下のファイル名で各環境のgoogle-services.jsonを用意します。

ファイルが用意できたらandroid/app/src/firebaseに置きます。

次にandroid/app/build.gradleを開き以下のタスクを定義します。

build.gradle

task copyFirebaseSource(type: Copy) {
    from "src/firebase/${dartEnvironmentVariables.FLAVOR}-google-services.json"
    into './'
    rename { String fileName ->
        fileName = "google-services.json"
    }
}


tasks.whenTaskAdded {
    // ...
    it.dependsOn copyFirebaseSource
}

ここでもcopyタスクを利用します。コピーする際にファイル名を変更する必要があるため、renameにファイル名変更の処理を指定しています。

すでにwhenTaskAddedが定義されている場合、既存のwhenTaskAddedの最後にit.dependsOn copyFirebaseSourceを追記します。

ビルドのたびに差分が発生するのを避けるために.gitignoreに以下を追記します。

.gitignore

**/android/app/google-services.json

以上でビルド時にandroidのFirebaseの設定ファイル切り替えられるようになりました。

Dartのコード中の設定

DartコードからFlavorの値を利用したい場合があります。その際は以下のようにしてアクセスすることができます。

String.fromEnvironment('FLAVOR')

dotenv等を利用している場合、この値を使うことで読み込むファイルを切り替えることができます。

【zsh】空ファイルを作る3つの方法

この記事はUnipos Advent Calender 2021、11日目の記事です。

お久しぶりです。とりかつ(@torikatsu923)です。

開発作業をしていると空ファイルを作成したい時がしばしばあります。

新しいファイルにプログラムを書きたい、とりあえずメモを取りたい、、、

エンジニアの皆さんはターミナルから空ファイルを作成することが多いと思います。 ターミナルを使用した空ファイルの作成方法には複数あります。 今回の記事は空ファイルの作成方法とそれぞれの特徴について紹介していこうと思います。

空ファイル作成コマンド一覧

空ファイルを作成するコマンドをざっと5つぐらい上げてみました。

  • touch foo
  • cp /dev/null foo
  • cat /dev/null > foo
  • echo -n > foo
  • :> foo

空ファイルの作成方法はいろいろな記事で紹介されています。それらを大きく以下の3つに分類することができます。

  1. 無をリダイレクト(>)する
  2. touchコマンドを使用する
  3. 0byteのファイルをコピーする

無をリダイレクト(>)する

この方法はなんらかの方法で0byteの出力を得て、それをリダイレクトすることで空ファイルを作成する方法です。この方法の強みはタイプ数が最も少なくなることです。

0byteの出力を得る方法はいろいろあります。

echoで改行を表示しない方法や、、

$ echo -n

無をcatする方法、、、

$ cat /dev/null

その中でも最もタイプ数が短いのは:を使う方法です。

$ :

これだけで無を得ることができます。:という演算子がありこれは何もしません。 実際にバイト数を測ってみると0でした。

$ : | wc -c
       0

空ファイルを作成する際はこれをリダイレクトするだけなのでファイル名を除けば2キーストロークで完結します。

:>ファイル名

短くて最高ですね!!

touchコマンドを使用する

touchコマンドによってファイルを作成することができます。この方法は馴染みが深い方法だと思います。 touchの強みは以下のように複数のファイルを一気に作成できることです。

$ touch foo.txt bar.txt

$ ls
bar.txt foo.txt

また、touchはファイルが存在しなければファイルを作成し、ファイルが存在すれば更新日時を最新にしてくれます。リダイレクトはファイルが存在する場合は失敗するだけなので使い分けができそうですね。

~/Desktop/touch_test
❯ ls1 
bar.txt foo.txt

~/Desktop/touch_test
❯ :>bar.txt
zsh: file exists: bar.txt

touchを激推ししましたが、実はteeを使うことで複数のファイルを一気に作ることもできます...

$ : | tee a b c 

touchよりこっちのがタイプ数すくなくてええやん

0byteのファイルをコピーする

以下のようにあらかじめ0byteのファイルを用意しておいてコピーする方法です。

$ cp 0バイトのファイル foo

0byteのファイルを常に準備する手間がかかってしまうため、/dev/nullを使う方法がいいかなと思います

$ cp /dev/null foo

cpには強制的に上書きする-fオプションがあります。これを組み合わせると初期化処理で活躍しそうです。

~/Desktop/touch_test
❯ seq 5 >> bar.txt

~/Desktop/touch_test
❯ cat bar.txt
1
2
3
4
5

~/Desktop/touch_test
❯ yes | cp -f /dev/null bar.txt
overwrite bar.txt? (y/n [n]) 

~/Desktop/touch_test
❯ cat bar.txt

おわりに

今回は様々な空ファイル作成方法について紹介しました。 特に:>はめちゃめちゃ便利で、記号のみで構成されているところに非常に強い趣を感じることができます()

今回紹介した方法以外にも空ファイルを作成する方法は様々あると思うので、もしアイデアがあればコメント等いただけるととても嬉しいです!

【zsh】ターミナルでサクッとメモを取るための設定

いきなりですが、あなたは今ターミナルで作業をしています。 巧みなキーボードさばきで様々なコマンドをバシバシと打ち込みタスクを進めています。 そんな作業中ふとgitのコミットIDを控えたくなりました。

そんな時あなたはどうしますか?

  • OSデフォルトのメモアプリを開く
  • evernoteを開く
  • 手元のメモ帳にペンでメモ書きする

...etc

色々な方法があると思います。
せっかくターミナルで作業をしているなら、ターミナルでメモを取れるようにしたいですよね

f:id:torikatsu923:20210911232556p:plain

今回はzshでコマンドのインストールなしでメモを取るための簡単な設定を紹介したいと思います。

メモの設定のステップ

ターミナルでサクッとメモをとるためには、以下の3ステップが必要になります。

  1. メモのファイルを置くディレクトリを作成する
  2. メモのファイルを作成する
  3. ターミナル上で動作するエディタでファイルを開く

例えば以下のコマンドで上のステップを実行することができます。

$ mkdir ~/memo
$ touch ~/memo/foo.md
$ vim ~/memo/foo.md

でも毎回コマンドを叩くのは面倒だと思います。 なので、この一連のステップを関数にして、.zshrcに関数を呼び出すためのエイリアスを登録します。

.zshrcの設定

以下を.zsrhcに追記します。

# memo alias
function writeMemo() {
  MEMO_HOME=$HOME/memo
  MEMO_FILE=$MEMO_HOME/$(date +%Y_%m_%d).md
  if [[ -d $MEMO_HOME ]] then
    mkdir $MEMO_HOME -P
  fi
  if [[ -f $MEMO_FILE ]] then
    touch $MEMO_FILE
  fi
  vim $MEMO_HOME/`date +%Y_%m_%d`.md
}

alias memo=writeMemo

.zshrcを更新したら.zshrcの再読み込みをします。

$ source ~/.zshrc

これで以下のコマンドでメモを取れるようになりました!

$ memo

この設定の嬉しいこと

今回紹介した設定で嬉しいことはたくさんあります!
例えば...

  • キーボードのみで操作可能

    vim, vim, neovim, emacs, nano, vimといったターミナルで動くエディタを使用するのでキーボードのみで操作が可能です

  • シームレスにメモを取ることができる

    メモを取るためにアプリを移動する必要がないため、非常に高速にメモを取り始めることができます。  エディタにvimを選択している場合<C-z>でメモをバックグランドにし、ターミナルの様子を確認したあと$ fgでメモを再開することができるのも大きなメリットです。
    特にtmuxとのコンビネーションは最強なのでおすすめです。

  • メモ全体をgrepできる

    以下のようにメモ全体に対してgrepすることができます。
    cat ~/memo/* | grep '検索したい文字列'

  • バックアップが簡単

    以下のコマンドで簡単にバックアップを取ることができます。
    tar ~/memo | gzip > backup.tar.gz
    githubのプライベートリポジトリと連携し定期的にメモ同期することで、あらゆるPCでメモを共有することができるかもしれません

  • 任意の形式でメモを取ることができる

    生成するメモのファイル名を自由に指定することができるため、任意の拡張子のファイルを生成することができます。これにより、任意の形式でメモを取ることができます。
    今回紹介した設定では.mdを指定しているためマークダウン形式です。ここに.yaml.json.htmlを指定することもできます()
    またエディタ側の設定次第でシンタックスハイライトや入力補完を有効にすることができます。

おわりに

今回はターミナルでメモを取るための簡単な設定を紹介しました。
もし今回紹介した設定が誰かの生産効率UPにつながれば幸いです!

【Flutter】Canvasで線香花火を作る

はじめに

こんにちは、とりかつ(@torikatsu)です。

夏も終わりに近づいてきました。みなさん夏休みはどこかいかれましたか? 私はうっかり夏休みを取り損ねたためずっと仕事でした()

今年はコロナの影響もあって花火大会や夏祭りもなかったため、夏を感じる機会が少なかったように思われます。 なんだかこのまま夏を終えるのは少し寂しいですよね...

ということでFlutterのCanvasで線香花火を作りました!
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

今回の記事はFlutterのCanvasを使って線香花火を作る方法を紹介します。

リポジトリ

リポジトリはこちらになります。 github.com

作り方

作り方は大きく以下の3つのステップに分かれます。

  • 火花の動きを再現する
  • 被写界深度を考慮する
  • パラメータの調整

火花の動きを再現する

線香花火という現象は無数の火花の動きによって起きています。 これより火花一個一個の動きをきちっと再現できればリアルな線香花火を作ることができると考えられます。 火花の動きはざっくり火花の移動と火花の分裂に分けて考えることができます。

火花の動き

 t秒後の火花の動きは運動方程式 F=ma tで2階積分することで求められます。

積分した位置の式は以下のようになります。

  •  k: 空気抵抗係数
  •  v: 火花の速度
  •  m: 火花の質量
  •  a: 加速度 (鉛直方向以外にも式を使い回したいのであえて加速度で考えます)
  •  x_0: 初期位置

 x(t) = -\dfrac{m}{k} \lbrace (v_0 - \dfrac{ma}{k})e^{-\frac{k}{m}t} + at + v_0 - \dfrac{ma}{k} \rbrace + x_0

実際に作るときは吹いている風を考えて初速度を v_0 = v_0 + v_{wind}として扱います。


導出

運動方程式

 F = ma-kv

また F = m \dfrac{dv}{dt}より

 m\dfrac{dv}{dt} = ma-kv

となります。

これをvについて解いて、

 \dfrac{dv}{dt} = a - \dfrac{kv}{m}

 \dfrac{dv}{dt} = -\dfrac{k}{m}(v - \dfrac{ma}{k})

 \dfrac{dv}{v - \dfrac{ma}{k}} = -\dfrac{k}{m}dt

両辺を積分して、

 \int \dfrac{1}{v - \dfrac{ma}{k}}dv = \int -\dfrac{k}{m}dt

 \int \dfrac{1}{x + a}dx = \log | x + a | + C より

 \log | v - \dfrac{ma}{k} | = -\dfrac{k}{m}t + C (Cは積分定数)

両辺の指数をとって

 v - \dfrac{ma}{k} = e^{c}e^{-\frac{k}{m}t}

ここで e^{C} = Dとすると

 v - \dfrac{ma}{k} = De^{-\frac{k}{m}t}

 v = De^{-\frac{k}{m}t}+\dfrac{ma}{k}

となります。 t=0のとき、火花の速度は初速度 v_0なので、

 v0 = D + \dfrac{ma}{k}

 D = v_0 - \dfrac{ma}{k}

となりDの値がわかります。

よって t秒後の火花の速度は

 v(t) = (v0 - \dfrac{ma}{k}) e^{-\frac{k}{m}t} + \dfrac{ma}{k}

となります。

さらに t積分して

 x(t) = \int \lbrace (v0 - \dfrac{ma}{k}) e^{-\frac{k}{m}t} + \dfrac{ma}{k} \rbrace dt

 x(t) = -\dfrac{m}{k}De^{-\dfrac{k}{m}t}+\dfrac{ma}{k}t+C_2 (C_2は積分定数)

ここで t = 0のとき、初期位置を x_0とすると

 x_0 = -\dfrac{m}{k}D + C_2

 C_2 = x_0 + \dfrac{m}{k}D

となり C_2がわかります。

よって位置の式は以下のようになります。

 x(t) = -\dfrac{m}{k}De^{-\frac{m}{k}t}+\dfrac{ma}{k}t+\dfrac{mD}{k} + x_0

 x(t) = -\dfrac{m}{k} \lbrace (v_0 - \dfrac{ma}{k})e^{-\frac{k}{m}t} + at + v_0 - \dfrac{ma}{k} \rbrace + x_0

火花の分裂

線香花火の火花の内側からは気泡が発生しています。その気泡が表面に達した際に破裂することあの火花の分裂が起きます。

分裂周りの計算は複雑になるので割愛します。詳細はこちらの論文を見てください。 非常にわかりやすくまとめてあります。

https://www.jstage.jst.go.jp/article/jcombsj/60/193/60_156/_pdf/-char/ja

この論文より、線香花火の主成分はカリウム化合物で、火花の分裂回数は最大8回ということがわかります。 またn回分裂したときの火花の半径 r_n、火花の寿命 t_n、分裂時の初速度 u_nは以下で求めることができます。

 r_n = {β}^{n}R_0

 t_n = \dfrac{ {R_0}^{2} }{k} {β}^{2n}

 u_n = \sqrt{ \dfrac{σ}{ρR_0} } {β}^{ -\frac{n-1}{2} }

  •  β = 0.5: 分裂時の定数
  •  R_0 = 10^{-4} (m) 火の玉から飛び出す火花の半径(分裂0回目の火花の半径)
  •  k = {10}^{-1} (m2/s) カリウム化合物の熱拡散率
  •  ρ = {10}^{3} (kg/m3) カリウム化合物の密度
  •  σ = {10}^{-1} (N/m) カリウム化合物の表面張力
  •  n (1 \leq n \leq 8) 分裂回数

火花の半径、寿命、初速度は全てnによって決まり、nは1から8になることがわかっています。 そのためプログラム起動時に各分裂時の計算結果を定数として持つことができます。アニメーション中は膨大な数の計算をすることになるので、計算結果を使いまわせるのは非常に嬉しいですね!

分裂時の火花の初速度がわかったので、次は火花の初速の向きを考えます。 自然な分裂を表現するためには運動量保存を守る必要があります。

  •  m 分裂前の火花の重さ
  •  v_0 火花の初速度
  •  v_1 火花1の初速度
  •  v_2 火花2の初速度

とすると、運動量保存の式は以下になります。

 mv_0 = \dfrac{1}{2}mv_1 + \dfrac{1}{2}mv_2

これを解いて

 2v_0 = v_1 + v_2

 v_2 = 2v_0 - v_1

これより v_1が決まれば v_2がわかります。

初速度は v_nであることがわかっているため

 {v_{n}}^2 = {v_1}^2

となります。ベクトルの x方向の成分を a y方向の成分を b z方向の成分を cとすると、

 {v_{n}}^2 = a^{2} + b^{2} + c^{2}

となります。 これより、火花の初速度の2乗した値を適当に3分割し、平方根ととると v_1を求めることができます。

奥行きを表現する

2次元のCanvasで立体的な線香花火を表現するためには奥行きを表現する必要があります。 今回はピンボケを使って奥行きを表現します。

ピンボケは計算式があるようです。 keisan.casio.jp この式に則って計算をすると正確な値が出せますが、その分計算コストがかかります。 今回は少しでも計算コストを減らすために、原点と火花の距離を適当な範囲で区切ってボケに使う値を出すことにしました。

  late final double sigma = () {
    final z = position.z.abs();
    if (z < 0.035) return 0.00005;
    if (z < 0.040) return 0.0003;
    if (z < 0.045) return 0.0005;
    if (z < 0.050) return 0.0008;
    if (z < 0.055) return 0.001;
    return 0.002;
  }();

Canvasでボケを表現するためにMaskFilter.blur()Paint()に渡します。

...
        Paint()
          ..color = e.color
          ..maskFilter = MaskFilter.blur(BlurStyle.normal, e.sigma)
          ..strokeWidth = max(e.deameter, 0.001));
...

Canvasでぼかしをする方法を調べるとImageFilterばかり出てきて、MaskFilterの情報はほとんど引っ掛からなかったので地味にハマりポイントでした。

パラメータの調整

最後に色々なパラメータを調整して全体を整えます。 具体的には表示倍率を変更したり、初速度を計算結果より若干早くしました。

ここの記事にある時間の流れを若干遅くする方法は、計算量を減らしつつ見た目もそれっぽくなるのでかなり有効でした。 zenn.dev

あと計算量が多くなりコマ落ちしやかったので、isolateで火花の計算を別スレッドで行うようにしたりしました。

おわりに

実際に線香花火を再現することで、家でコードを書きながら夏を満喫するという最高の体験をすることができました! ぜひみなさんも夏っぽいものをコードで再現して、夏を満喫しましょう!

【Flutter】ListViewで子要素がListViewの内側に表示されたか検知する

こんにちは、とりかつ(https://twitter.com/torikatsu923)です。 いきなりですが、FlutterにはListViewという大変便利なWidgetがあります。

api.flutter.dev

ListView+ListTileを使うことで数分で以下のようなUIを作ることができます。

f:id:torikatsu923:20210730005502p:plain:h600

このような画面をさくっと実装できるのはFlutterのとてもいいところだと思います。

最近、ListViewの要素が画面内に表示されたかどうか知りたいケースがありました。 そこで今回の記事は、ListViewの要素がListViewの内側に表示されたことを検知する方法について紹介します。

今回紹介する方法を使うと、ListViewの内側に表示されている間だけ色を変えるみたいな実装も実現可能です。

f:id:torikatsu923:20210730233808g:plain:h600

コード

今回の記事で紹介するコードは以下のリポジトリです。 github.com

方針

ListViewの要素がListView内に表示されたかどうかは、ListViewの表示領域とListViewの要素の座標を比較することで判断することができます。 図にすると以下のようになります。 f:id:torikatsu923:20210730012837p:plain:h600 要素がListViewの内側にあるとき、要素の下端のy座標はListViewの下端のy座標よりも小さくなります。 逆に要素がListViewの外側にあるとき、要素の下端のy座標はListViewの下端のy座標よりも大きくなります。

これをまとめると、以下のようになります。

要素がListViewの内側にあるときの条件: `要素の下端のy座標 <= ListViewの下端のy座標`  
要素がListViewの外側にある時の条件  : `要素の下端のy座標 > ListViewの下端のy座標`

この判定をListViewのスクロールイベントが発生するごとに行います。
そうすることで、ListViewの要素がListViewの内側に完全に入ったことを検知することができます。

実際にこれを実装していきます。実装手順は以下の通りです。

  1. ListViewの要素の下端の座標を取得する
  2. ListViewの要素で、ListViewの表示領域の下端の座標を取得する
  3. 1、2で取得した座標を使って、要素がListViewの領域内に表示されているかどうかの判定を行う
  4. 3の判定をスクロールイベントが発生するたびに行う

1. ListViewの要素の下端の座標を取得する

まずはListViewの要素の下端の座標を取得します。下端の座標はRenderObjectから取得します。 FlutterはWidget、Element、RenderObjectの3つのツリーでUIを構築しているのですが、RenderObjectは実際に描画された時のサイズや座標を持っています。

https://flutter.dev/images/arch-overview/trees.png

任意のWidgetに対応するcontext経由で以下のように取得することができます。 とってくるRenderObjectの実体はRenderBoxなので、取得するついでにキャストしています。

visibility_detector_list_item.dart

    final renderBox = context.findRenderObject() as RenderBox?;

次にRenderBoxからグローバル座標を取得します。 グローバル座標の取得にはRenderBoxのlocalToGlobal()を使用します。

api.flutter.dev

今回は単にグローバル座標を取得したいだけなので、Offset.zeroを渡してあげます。 ここまでのコードは以下のようになります。

visibility_detector_list_item.dart

    final renderBox = context.findRenderObject() as RenderBox?;
    if (renderBox == null) return;
    final globalOffset = renderBox.localToGlobal(Offset.zero);

2. ListViewの要素で、ListViewの座標を取得する

1の手順と同様にcontext経由でRenderObject経由でListViewの座標を取得します。 問題はどうやってListViewの要素からListViewのcontextにアクセスするかです。 やり方はいろいろあると思いますが、今回はGlobalKeyを使います。 ListViewを含むWidget側でGlobalKeyを生成し、ListViewのコンストラクタに渡します。 さらに、ListViewの要素側でこのGlobalKeyを受け取れるようにしてあげます。

visibility_detectable_list_view.dart

  final listViewKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemBuilder: (_, index) =>
          ListItem(index: index, listViewKey: listViewKey),
      separatorBuilder: (_, __) => const Divider(height: 0),
      itemCount: 100,
      key: listViewKey, // pass global key
    );
  }

ListView側では、GlobalKeyのcurrentContext経由でcontextにアクセスし、1の手順と同様にListViewのグローバル座標を取得します。 ここまでのコードは以下のようになります。

visibility_detectable_list_item.dart

    final renderBox = context.findRenderObject() as RenderBox?;
    final listViewRenderBox =
        widget.listViewKey.currentContext?.findRenderObject() as RenderBox?;

    if (renderBox == null || listViewRenderBox == null) return;
    
    final globalOffset = renderBox.localToGlobal(Offset.zero);
    final listViewGlobalOffset = listViewRenderBox.localToGlobal(Offset.zero);

3. 1、2で取得した座標を使って、要素がListViewの領域内に表示されているかどうかの判定を行う

ListViewの要素がListView内にあるかどうかの条件は冒頭で紹介しました。 条件分岐にはウィジェットの最下端のグローバル座標が必要になるので、まずはその座標を計算します。

1、2でlocalToGlobal(Offset.zero)を使って取得したグローバル座標は、ウィジェットの左上の座標になります。 以下の図のようなイメージです。

f:id:torikatsu923:20210730224141p:plain:h600

ウィジェットの再下端の座標は、1、2で取得したグローバル座標にウィジェットの高さを足すことで算出できます。 ウィジェットの高さはRenderBox.size.heightで取得することができます。

それぞれのウィジェットの再下端を取得できたら、あとは冒頭で紹介した条件に突っ込むだけです。 ここまでのコードは以下のようになります。

    final renderBox = context.findRenderObject() as RenderBox?;
    final listViewRenderBox =
        widget.listViewKey.currentContext?.findRenderObject() as RenderBox?;

    if (renderBox == null || listViewRenderBox == null) return;

    final listItemBottomPosition =
        renderBox.localToGlobal(Offset.zero).dy + renderBox.size.height;

    final listViewBottomPosition =
        listViewRenderBox.localToGlobal(Offset.zero).dy +
            listViewRenderBox.size.height;

    if (listItemBottomPosition <= listViewBottomPosition) {
      // inside
    } else {
      // outside
    }

4. 3の判定をスクロールイベントが発生するたびに行う

3でつくったの判定のロジックをスクロールイベントが発生するたびに行うことで、ListViewの要素ががListView内に表示された瞬間に、そのことを検知することができます。 そのためにはScrollControllerを使用します。 まず、ListViewにScrollControllerを設定します。さらにListViewの要素でScrollControllerのリスナに3で作った処理を設定します。

visibility_detectable_list_item.dart

class _VisibilityDetectableListItemState
    extends State<VisibilityDetectableListItem> {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    WidgetsBinding.instance?.addPostFrameCallback(
      (_) => widget.controller.addListener(_onScroll),
    );
  }

  @override
  void dispose() {
    super.dispose();
    widget.controller.removeListener(_onScroll);
  }

_onScroll()は3で作った処理をまとめた関数です。

ここで大事なのは、リスナを設定するタイミングです。 ListViewの要素がListView内に描画されているかどうかの条件判定はcontextにアクセスする必要があります。 画面がレンダリングされる前にcontextにアクセスできないので、didChangeDependenciesWidgetBinding.instance?.addPostFrameCallback()を使ってリスナを設定しています。

また、リスナを開放ことも非常に重要です。 ListViewは画面内に入りそうな要素を必要に応じて作成・破棄します。なので、画面がスクロールされると要素が破棄されることがあります。 要素が破棄された場合、onScrollは存在しなくなります。 リスナを解放しないとスクロールイベントが発生した時、存在しないonScroll()を呼び出そうとしてメモリリークが起きます。 そのため、disposeで確実にScrollController.removeListener()してあげます。

実装は以上になります!これで以下のように画面内に表示されたことを検知できるようになりました!

f:id:torikatsu923:20210730233808g:plain:h600

おわりに

今回はグローバル座標を基準に、ListViewの要素がListView内に表示されたかどうかを検知する方法を紹介しました。 localToGlobal()にListViewのRenderObjectを渡すことで、ListViewとListViewの要素の相対的な座標で判定を行うこともできそうです。

ListViewで子要素がListViewの内側に表示されたか検知する方法に番兵を用いる方法もありました。 qiita.com

番兵を用いる方法は非常にシンプルでとっつきやすいです。 ですが、管理するWidgetの数が増えてしまいます。また、番兵を用いるとbuildが呼ばれたタイミングで初めてListViewで子要素がListViewの内側に表示されたかを検知することができるのですが、Widgetが破棄されていない等の理由で微妙に扱いづらい部分があります。 その点今回の記事で紹介した方法は汎用性がかなり高いため、安定した動作を求めるならこの記事で紹介した実装方法がおすすめです!

【Flutter】いい感じのローディング画面を表示したい!

こんにちは、とりかつ(@torikatsu923)です。 よくアプリでこんな感じのローディング画面を見かけると思います。

f:id:torikatsu923:20210714014700g:plain:h400

アプリ起動時にただ真ん中でぐるぐるするのを見ているより、いい感じのローディング画面が表示されていたら、きっとユーザは退屈しなくて済むと思います。

このいい感じのエフェクトのことをShimmerと呼ぶそうです。 そこで、今回の記事はShimmerローディング画面を実装する方法を紹介していきます。

リポジトリは以下になります。 github.com

実装の流れ

  1. UIのスケルトンをつくる
  2. ケルトン全体をグラデーションで塗りつぶす
  3. アニメーションをつける

UIのスケルトンをつくる

まず、アニメーションとかが無いシンプルなローディング画面を作ってみます。 今回は以下のような画面を作っていきます。

f:id:torikatsu923:20210713044630p:plain:h400

Row,Column,Containerを組み合わせて、以下のようになりました。 わかりやすいよう、あえて黄色を設定しています。

f:id:torikatsu923:20210713044943p:plain:h400

アウトラインはこのようになっています。

f:id:torikatsu923:20210713045035p:plain:h400

UIのスケルトンを実装した段階のサンプルはこちらです。 github.com

ケルトン全体をグラデーションで塗りつぶす

次に黄色の部分をメタリックな感じのグラデーションで塗りつぶします。 今から作成する黄色の部分を塗りつぶすウィジェットShimmerとします。

塗りつぶしにはShaderMaskというウィジェットを使用します。 ShaderMaskは、子要素のうちcolorが設定されている部分をShaderMaskで指定された条件で塗りつぶします。 api.flutter.dev

ShaderMaskのshaderCallbackにはShaderを返す関数を指定してあげる必要があります。 今回はLinerGradient.createShaderでShaderを作成してあげます。 まずはいい感じのグラデーションになるようLinerGradientを作成します。設定する値は好みで問題ありません。

        final gradient = LinearGradient(
          colors: [
            Color(0xFFEBEBF4),
            Color(0xFFF4F4F4),
            Color(0xFFEBEBF4),
          ],
          stops: [
            0.1,
            0.3,
            0.4,
          ],
        );

次にgradient.createShaderShaderを作成してあげるのですが、この作り方が少し特殊になります。 今回Shimmerの効果をかけたいのはShimmerで囲われたエリアです。 Shimmerの子要素は動的に変わるため、画面が描画されるまでShimmer効果をかける位置や高さが分かりません。 なので、Shimmer.buildが実行されるタイミングでShimmerの効果をかける位置や高さを取得してあげる必要があります。

以下はbuild内で位置や高さを取得している部分です。

    // shimmerのRenderObjectを取得する
    // shimmerが一度もbuildされていない場合はRenderObjectが取得できない
    final shimmerBox = context.findRenderObject() as RenderBox?;
    if (shimmerBox == null) {
      // shimmerのRenderObjectが存在しなければ何もないウィジェットを返す
      return SizedBox();
    }

    // Offset.zeroをRenderObjectの座標系に変換する
    // つまり、RenderObjectのleftとtopを取得する
    final offsetWithinShimmer =
        shimmerBox.localToGlobal(Offset.zero, ancestor: shimmerBox);

    // グラデーションの範囲をShimmerと同じ範囲にする
    final shimmerRect = Rect.fromLTWH(
      offsetWithinShimmer.dx,
      offsetWithinShimmer.dy,
      shimmerBox.size.width,
      shimmerBox.size.height,
    );

これを使ってgradient.createShader(shimmerRect)をすると以下のようになります。

f:id:torikatsu923:20210714013258p:plain:h400

黄色で塗りつぶされていたところに灰色のグラデーションがかかっていることがわかります。 ここまでの実装は以下になります。 github.com

アニメーションをつける

最後にアニメーションをつけていきます。 LinerGradientのコンストラクタにはtransformを指定することができます。 ここには、自作のGradientTransformを渡してあげます。 以下は自作のGradientTransformになります。 ここで大事なのは、0-1のdoubleを受け取って、その値に応じて_SlidingGradientTransform.transformが返す値が変化するようにすることです。

class _SlidingGradientTransform extends GradientTransform {
  const _SlidingGradientTransform(this.slidePercent);

  final double slidePercent;

  @override
  Matrix4? transform(Rect bounds, {TextDirection? textDirection}) =>
      Matrix4.translationValues(bounds.width * slidePercent, 0, 0);
}

これをLinerGradientのコンストラクタに渡してあげます。 ここで、_SlidingGradientTransformのコンストラクタに適当な値を渡して挙動を見てみます。

0.1のとき 0.4のとき
f:id:torikatsu923:20210714013258p:plain:h400 f:id:torikatsu923:20210714014040p:plain:h400

渡した値が大きくなるにつれて、グラデーションの色が明るいところが右へ移動していることがわかります。 ここにAnimationControllerを渡すといい感じのローディング画面ができ上がります。

今回のAnimationControllerの実装は以下のようになっています。

    _controller = AnimationController.unbounded(vsync: this)
      ..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1500))
      ..addListener(() {
        setState(() {});
      });

これを設定すると以下のようなアニメーションができました!

f:id:torikatsu923:20210714014731g:plain:h400

あとはグラデーションの傾きをいい感じに調整するとこの記事の冒頭のようなShimmer効果が出来上がります。

おわりに

今回はShimmer効果の実装方法を紹介しました。 Shimmerは効果をつけたいときは以下のような公開されているパッケージを使う方法もあります。

pub.dev

Shimmerの実装量は100行程度なので、パッケージに頼らずに自前で実装してしまうのも多いにアリですね!

それではよいFlutterライフを!

【Flutter】riverpod 1.0.0-dev.0の変更点まとめ

こんにちは、とりかつ((@torikatsu923)https://twitter.com/torikatsu923)です。

今朝、riverpodの1.0.0-dev.0が公開されました!

以前記事で触れましたが、4月の半ばごろにriverpodの作者の方からriverpodに破壊的変更が入るかもしれないというアナウンスがありました。 torikatsu923.hatenablog.com

Changelogを確認したところ、今回のアップデートはそのアナウンスを受けてのものと考えて良さそうです。 個人的に欲しかった機能がつまっているアップデートなので、使うのがとても楽しみです!

今回のアップデートは変更点が20個程含まれており、破壊的変更も多く含まれています。 pub.dev

そこで今回の記事ではriverpodの変更点についてまとめていきます。

アップデートの方針の考察

今回のアップデートのキーは「Providerへアクセスする方法を統一する」だと思われます。 初めてriverpodを触る時、Providerをreadする方法の複雑さにウッとなる方も多いと思います。 https://riverpod.dev/assets/images/reading-eddc30e98d777456a442ddd5b181eb2a.svg

以前の作者さんの(アナウンス)https://github.com/rrousselGit/river_pod/issues/335や、change logでproviderへのアクセス周りで破壊的変更が多く入っていることから、 アップデートにはProviderへアクセスする方法を統一し、よりシンプルにするにする目的があると考えられます。

実際に変更後はref.read, ref.watch, ref.listenによってProviderへのアクセス、UIでの状態の監視、状態変更の監視が可能になっています。

以下ではこの点を踏まえつつ、変更点を追っていきます。

変更点一覧

useProviderの削除

HookWidgetのuseProviderがなくなり、Widget refを使用してProviderへアクセスするように変更が入っています。 HookConsumerWidgetを継承することで、buildの引数にWidget refを受け取ることができるようになります。

Before

class Example extends HookWidget {
  @override
  Widget build(BuildContext context) {
    useState(...);
    int count = useProvider(counterProvider);
    ...
  }
}

After

class Example extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    useState(...);
    int count = ref.watch(counterProvider);
    ...
  }
}

pub devより引用

ConsumerWidgetのbuildメソッドでWidget refを受け取るようにする変更

従来は以下のようにbuildメソッドでScopedReaderを受け取っていましたが、変更後はWidgetRefを受け取るようになりました。 変更後はProviderの変更をwatchしたい際はref.watchとする必要があります。

Before

class A extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final state = watch(someProvider);
  }
}

After

class A extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(someProvider);
  }
}

ProviderListenerを非推奨にする変更

WidgetReflistenという状態の変更を監視する関数ができました。その関係で、今まで状態の変更を監視する際に使用されていたProviderListenerが非推奨になりました。

この変更により、今までWidgetをネストしなければならなかったところをとてもシンプルに記述することができるようになりました!

Before

class A extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderListener(
      provider: someProvider,
      onChange: (context, state, child) { ... },
      child: Container( ... ),
    );
  }
}

After

class A extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(someProvider, (state) {
       // something
    }
  }
}

ConsumerStatefulWidget, ConsumerStateの追加

StatefulWidgetとConsumerWidgetが合体したConsumerStatefulWidgetが追加されました。 StatefulWidgetのライフサイクルにフックしてProviderの処理を走らせたいが、状態の管理はProviderに行わせたいみたいな場合、いままではStatefulWidgetでWidgetを定義して、build内でConsumerで括ってあげる必要がありました。 今回のConsumerStatefulWidgetの追加で、Consumerで括る必要がなくなったため、よりシンプルに記述できるようになりました。

watchでselectでstateの特定の値の変更のみ検知できるようにする変更

userのフィールドにusernamestatusがあるとします。 以下のコードではstatusに変更があった場合はリビルドされず、usernameに変更があった場合にリビルドされるようになりました。

final userProvider = StateNotifierProvider<UserController, User>(...);

Consumer(
  builder: (context, ref, _) {
    bool userName = ref.watch(userProvider.select((user) => user.name));
  },
)

グローバルな状態とリビルドについて悩んでいたため、この変更は個人的にとても嬉しいです!

overrideWithValueの削除

今まで.familyで作成したProviderはoverrideする必要がありました。その際値のみをオーバーライドするoverrideWithValueとProviderごとオーバーライドするoverrideWithProviderの2種類の方法がありました。 しかし、今回の変更でoverrideWithProviderに一本化されたようです。

ProviderObserverが変更前と変更後の2種類の状態を受け取れるようにする変更

ProviderObserverが変更の前後の状態を受け取れるように変更が入りました。 これによって状態の差分をみたりする処理が書きやすくなりました。

ProviderObserver.mayHaveChangedの削除

ProviderObserver.mayHaveChangedが削除されました。 これは破壊的変更ですが、個人的にはあまり使っていない機能だったため影響はありませんでした。

ref.listenの追加

先で触れたようにref.listenが追加されました。 バージョンアップ後はダイアログの表示等はこれを使用することになります。

ProviderReferenceを非推奨にする変更

ProviderReferenceが非推奨になり、代わりにProviderBaseが採用されました

全てのProviderがProviderRefBaseをパラメータとして受け取るようにする変更

今までProviderは以下のようにrefを受け取っていましたが、これがProviderRefBaseに変更されました。

Provider((ref) => ...);

この変更でrefがstateというプロパティを持つようになり、ref.state++のようにすることで状態を変更することができるようになりました。 また、StateProviderのrefにはcontrollerというプロパティが追加され、ref.controller....でStateControllerにアクセスできるようになりました。

ProviderReference.mountedの削除

ProviderReference.mountedが削除されました。代わりにonDisposeを使用することで似たような挙動を実現できるようです。

Provider<T>((ref) {
  var mounted = true;
  ref.onDispose(() => mounted = false);
});

ProviderContainerのdebugproviderValuesとdebugProviderElementsの削除

ProviderContainerのdebugProviderValuesとdebugProviderElementsが削除され、 ProviderContainer.getAllProviderElementsを使用できるようになりました

StreamProviderのlast, stream, futureがプロバイダーの再構築の回数に依存しないようにする変更

関連するProviderの再構築の回数に関係ないfuture, streamを外部に公開するようになりました。

Providerの値が同じ時、Providerをオーバーライドできるようにする変更

StreamProvider<User>があった時、Provider<AsyncValue<User>>でオーバーライドすることができるようになりました。

ref.refreshの呼び方変更

ref.refreshの呼び方がref.container.refreshに変更されました。

ref.onDisposeのタイミング変更

Providerの依存関係の変更がわかったらすぐにonDisposeが呼ばれるようになりました。

Providerの状態の再計算のタイミング変更

Providerの依存関係が変更されてリスナがいるとき、次の読み取りまで状態の再計算を待たなくなりました。

ProviderContainer.pumpの追加

ProviderContainer.pumを使うことで、awaitのように簡単にproviderが状態の変更を通知するまで待つことができるようになりました。

familyとdisposeを併用した時に状態が残る問題の修正

familyとdisposeを一緒に使用した際状態が残ってしまう問題がありましたが、今回のアップデートで解決されたようです。

おわりに

今回の記事ではriverpodの変更点を追っていきました。 Providerへのアクセスする際の構文がrefを経由して行われるように統一されたので、より使いやすくなったと感じています! またいくつかの変更によってシンプルに処理をかけるようになったのもとても嬉しいです!

それではよい開発ライフを!

null-safetyとお友達になるTips

こんにちは、とりかつ((@torikatsu923)https://twitter.com/torikatsu923)です。

Flutterで開発をする際はDartを書くことになります。DartにはNNBD(null-safety) があり、Flutter側でもnull-safetyの対応がどんどん進んでいます。 null-safetyのおかげで、nullが入りこむことによって起こるバグを見つけやすくなりました。 私もFlutterの開発をする際はnull-safetyをonにしているのですが、このnull-safetyの恩恵にあやかるためにはちょっとした工夫が必要になります。 今回の記事では、そんなnull-safetyと最高のお友達になるためのtipsを2つ紹介します。

note 今回の記事は以下のdocsの更新を受けてのものです。 一時ソースをサクッと確認したい方は以下からお願いします。 dart.dev

nullチェックしてるのにnullの可能性があるとコンパイラに怒られるケース

Dartのコードを書いていると、以下のようにnullチェックをすることがあると思います。

一見正しそうに見えますが、実際にやってみるとコンパイルエラーになります。 f:id:torikatsu923:20210619164656p:plain

エラーの内容は以下の通りです。

The method 'toDouble' can't be unconditionally invoked because the receiver can be 'null'.
Try making the call conditional (using '?.') or adding a null check to the target ('!').dart(unchecked_use_of_nullable_value)

ざっくり訳すと「valueはnullの可能性があるからtoDoubleは呼び出せないよー」となります。 手前でnullチェックをしているのにvalueがnullの可能性があるとはどういうことでしょうか、、、

Tips1 ローカル変数に切り出す

実はこれ、一度ローカル変数に切り出すことでうまく切り抜けることができます。 試しに以下のようにfinal v = value;のようにしてみます。

すると、、、

f:id:torikatsu923:20210619165402p:plain エラーが消えました!!🎉🎉

Tips2 !を使って握り潰す

どこからどうみてもnullでない値なのにnullの可能性があるなんて言われた時には!(bang operator)を使用することもできます。 以下のようにコードを変更してみると、、、

f:id:torikatsu923:20210619171016p:plain

エラーが消えました!!🎉🎉

実はこの方法、一つ注意することがあります。 MyIntegerのコードの例ではvaluefinalのため変更されることがないので問題が起きません。 しかし、valuefinal出ない時は7行目でnullチェックをした後に値が書き換えられる可能性があります。 もし値が書き換えられてnullが入り込んでしまうとクラッシュしてしまいます。 bang operatorは簡潔に記述することができますが、使い方によっては思わぬバグを作り込む原因になってしまうため、使用する際には十分に注意を払う必要があります。

個人的には少し冗長ですが1の方法が安心感があって好みです。

終わりに

今回はnull-safetyとお友達になるTipsを2つ紹介しました。 Kotlinとか触ってからdartを触るとなんでnullableと判定されちゃうん、、、というケースがちょくちょくあって初めはびっくりしました。 しかし、ちゃんと付き合い方を知れば心強い味方になってくれます!

それでは良い開発ライフを!

【Flutter】MVVMなアーキテクチャで初期化処理をしたい!

こんにちは、とりかつ(@torikatsu923)です。 私は趣味でFlutterアプリの開発をしていて、MVVMライクなアーキテクチャをriverpod+StateNotifier+freezedで状態管理をしています。

この状態管理手法は非常に便利ですが、初期化処理をする際にすこし詰まりました。 そこで、今回はMVVM的なアーキテクチャを目指すときの画面の初期化処理のやり方について書いていきます。

はじめに

Flutterでアプリを開発していると、「画面表示時にサーバからデータの取得をしつつ初期ローディング画面を表示したい」みたいなケースが結構あります。 このケースの例としてYoutubeアプリが挙げられます。 Youtubeアプリはアプリを起動するとホームが表示されます。 最初にホームが表示されるときは初期ローディングエフェクト(Shimmer)が表示されて、データの取得が終わると動画一覧が表示されます。

f:id:torikatsu923:20210530004824p:plain
初期ローディング(shimmer)

f:id:torikatsu923:20210530004850p:plain
初期データ取得後画面

大したことなさそうに見えます。 とりあえず動くものを作ろうとすると、FutureBuilder使って初期ローディングを行うだけです。

一見問題なさそうに見えますが、MVVM的なアーキテクチャを目指そうとした時、以下のような課題感があると思いました。

  • StateNotifierを経由せずにUIの更新を行っている
  • UIにエラーハンドリングの詳細ロジックが漏れる

StateNotifierを経由せずにUIの更新を行っている

UIは常にStateNotifierが持っている状態をもとにUIのレンダリングを行うべきです。 もう少し抽象的な言い方をすると、UIは状態を引数に取る関数によって構築されるべきです。 この考えはFlutterのdocsでも述べられています。

https://flutter.dev/assets/development/data-and-backend/state-mgmt/ui-equals-function-of-state-54b01b000694caf9da439bd3f774ef22b00e92a62d3b2ade4f2e95c8555b8ca7.png

上記の例ではStateNotifierの状態の変更とFutureBuilderのfutureの結果の2つの要素によってUIが構築されます。 上記の例は状態によってUIを管理できておらず、将来的にアプリケーションの状態管理が複雑になることが考えられます。

UIにエラーハンドリングの詳細ロジックが漏れる

上記の例ではinitialize()がどういった値を返すのか、どういったエラーをハンドリングする必要があるのかをUI側のコードに記述する必要があります。 これは「画面の初期化処理」というイベントのロジックそのものと考えられます。 StateNotifierを採用してMVVM的なアーキテクチャを目指す一つの理由に、UIとロジックの分離が挙げられます。 これより、「画面の初期化処理」はViewModel側(StateNotifier)に記述し、「初期化処理」によって生み出された「状態」によって以下の画面を出し分ける必要があります。

  • 初期ローディング画面
  • 初期化処理完了後画面
  • エラー画面

課題解決の方法

前述の課題を解決するためには以下を達成する必要があります。

  • 画面の出しわけを状態に基づくようにする
  • 初期化処理をUI構築時に呼び出せるようにする

これらを実装に反映させていきます。

画面の出しわけを状態に基づくようにする

初期ローディングはbool initializingという状態として考えることができます。 なので、StateNotifierが管理する状態にinitializingというフィールドを追加し、UI側を以下のように変更します。 また、エラーは「エラーが起きている」という状態として考えることができます。 なので、Error errorというフィールドを追加します。 そしてUI側のコードを以下のように変更します。

UI側のコードが状態飲みに依存するようになり、どのような状態の時にどのようなUIが構築されるのか見通しがよくなりました。

ここでは2つのフラグによってUIを管理していますが、enumでUIの状態の種類を定義し、switchを使用する方法もあると思います。 このとき、それぞれのフラグの概念をenumにまとめていいかどうか注意する必要があります。

初期化処理をUI構築時に呼び出せるようにする

初期化処理をUI構築時に呼び出す方法にはいくつかあります。 とりあえず以下のような方法を思いつきました。 (これらは全てアンチパターンです。課題感の前提を共有するためにあえて流れを追うように書いていきます。)

  • buildの最初に呼び出す
  • StatelessWidgetのコンストラクタで呼び出す
  • StatefulWidgetのinitStateで呼び出す

しかし、これらは全て失敗します。 まず、buildの最初に呼び出す方法ですが、これだとWidgetがrebuildされるたびに初期化処理が走ります。 そのため、状態に変更が入るたびに初期化処理が発火してしまいます。 初期化処理はウィジェットのライフサイクルに合わせたいため、この方法では目的が達成できません。

次にStatelessWidgetのコンストラクタで呼び出す方法を試しました。 コンストラクタ内でinitializeを呼び出す時ProviderContainerを使用してStateNotifierを取得します。 しかし、コンストラクタでProviderContainerが参照するStateNotifierとbuild内でcontextを使用して参照するStateNotifierは別のインスタンスです。 コンストラクタ内で初期化処理を行ってもbuild内で参照するStateNotifierは初期化処理が行われていないため、失敗します。

次にStatefulWidgetのinitStateで呼び出す方法を試しました。 initState内ではcontextを参照することができません。それゆえ、この方法は失敗します。 contextを参照したいときはinitState直後に呼ばれるdidChangeDependenciesを利用するそうです。

以上の失敗を踏まえ、以下2つの方法が候補として考えられます。

  • StateNotifierのコンストラクタで呼び出す
  • didChangeDependenciesで呼び出す

StateNotifierのコンストラクタで呼び出す

以下のようにして呼び出します。

class ViewModel extends StateNotifier<SomeState> {
  ViewModel(): super(SomeState()) {
    _initialize();
  }

  Future<void> _initialize() async {
    ...
  }
}

これはうまく機能します。 初期化処理はViewModelのライフサイクルの開始時に行いたいため、StateNotifierのコンストラクタで初期化処理を行うのは理にかなっています。 また、初期化処理をViewModel内に閉じ込めることもできます。 ですが、コンストラクタ内で非同期に状態を変更する関数を呼び出すため、ユニットテストがしづらくなります。

didChangeDependenciesで呼び出す

didChangeDependenciesではcontextを参照できるため以下のように初期化処理を行うことができます。 これにより、ページのライフサイクルの開始時に合わせて初期化処理を発火させることができます。 さらに、ViewModelのユニットテストが容易になります。 ですが、ViewModelの初期化処理を外部に公開する必要があります。

この2つの方法はどちらもメリットでメリットがあるので、どちらを選択するかは好みかと思います。 私はユニットテストをしやすくしたいため、didChangeDependenciesで初期化処理を呼び出す方が好みです。

これで以下の課題を解決することができました🎉

  • StateNotifierを経由せずにUIの更新を行っている
  • UIにエラーハンドリングの詳細ロジックが漏れる

おわりに

今回の記事では、MVVM的なアーキテクチャを目指すときにどのように初期化処理を行うかということについて述べました。 目指すアーキテクチャの姿は人それぞれであり、正解はありません。 もしこっちの方法がスマートになる!みたいなものがあればコメントにて教えていただけると幸いです。

それではよい開発ライフを!

【Flutter】今週のインプット 2021/5/17-23

こんにちは、とりかつ((@torikatsu923)https://twitter.com/torikatsu923)です。

今回の記事は、Flutterについてここ一週間(2021/5/17-23)でのキャッチアップをつらつら書いていこうと思います。

Flutter2.2

先日のGoogleIOにてFlutter2.2が発表されました。 medium.com

Flutter2.2の内容についてはここの記事がよくまとまっていました。 medium.com

Dart2.13

Flutter2.2ではDart2.13が利用可能です。 Dart2.13ではクラスのタイプエイリアスを定義することができます。

// いままでのtypedef
typedef VoidCallBack = void Function();

// 2.13で可能になったtypedef
typedef Json = Map<String, dynamic>

いままでJsonをパースする処理でMap<Map<String, Map<String, dynamic>>>とか書かなければならなかったところがすっきり書けるようになったので とても嬉しい機能です。

Flutter web

main.dart.jsの2重ダウンロードの修正が入ったようです。 この修正を手元のプロジェクトで有効にするためには以下の手順を踏めば良さそうです。

  1. index.htmlを削除する
  2. flutter create .を実行する

また、アクセシビリティ周りの機能も充実しました。

dev tool のアップデート

devtoolでProviderを確認できるようになりました🎉

riverpodのProviderは絶賛対応中だそうです。

riverpod

docsの更新

riverpod.dev 0.13.0 -> 0.14.0のStateNotifierまわりの破壊的変更に伴う、マイグレーションガイドが追加されていました。

どうやらcliマイグレーションを行うことができるようです。

$ dart pub global activate riverpod_cli
$ riverpod migrate

大幅なアップデート

こちらのIssueを眺めていると、riverpodの書き方の大幅なアップデートが入ることがわかりました。 github.com

個人的に大きいと感じていることは、ProviderListener周りのアップデートです。

いままで、値の変更に応じてダイアログを表示したいようなケースではProviderListernerを使って、 以下のようにウィジェットを囲む必要がありました。

class Example extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderListener<Something>(
      provider: anotherProvider,
      onChange: (context, value) {}
    );
  }
}

これがバージョンアップによって以下のように書くことができるようです。

class _StatefulExampleState extends State<StatefulExample> {
  @override
  Widget build(BuildContext context) {
    A value = watch(a);
    listen<A>(a, (value) {
    });

  }
}

ウィジェットをネストする必要がなくなったため、よりフラットにかけるようになりました。

Flutterにおけるエラーハンドリング

Flutterで以下のようなMVVMライクな構造にしようとしたとき、DataレイヤのエラーをViewModelでハンドリングしたいことが結構あります。 https://miro.medium.com/max/700/1*Uj5dnm3RTQ89uidDpcObHw.jpeg

dartにはJavaでいうthrowsのような構文がないため、どんなエラーを吐くのかを知りたいときはメソッドの内部使用を見る必要があります。 しかし、これではメソッドを呼び出す側がメソッドがどんな型のエラーを吐くかを知ることになります。 これでは、せっかくレイヤーごとに分離しているのに、レイヤ間が密結合になってしまいます。 また、エラーハンドリング漏れも十分に考えられます。

そこで、Resultを使用する方法が考えられます。 ntaoo.hatenablog.com

Resultは値に対して成功、失敗の文脈を持たせることができるため、エラーハンドリングの漏れを防ぐことができます。

さらに、独自のResultクラスとエラークラスを定義して、各エラーをアプリケーションに依存したエラーへ変換することで、よりすっきりした構成にすることが可能です。 github.com

【riverpod】画面遷移時にStateNotifierへ値を渡す方法

こんにちは、とりかつ(@torikatsu923)です。

普段私はFlutterで開発しており、riveropd+StateNotifier+freezedでMVVMライクに状態管理をしています。 先日、画面遷移を実装する際、画面遷移時にStateNotifierで作ったViewModelのコンストラクタへ渡そうとしてうまく渡せずとても苦戦しました。 そこで、今回の記事では画面遷移時にViewModelへ値を渡す方法について紹介していこうと思います。

リポジトリ

実装はこちらのリポジトリになります。 github.com

作ろうとしているもの

今回は以下のようなアプリを作ろうとしています。

f:id:torikatsu923:20210424165822p:plain
作ろうとしているアプリ

HomePageで入力したテキストが、PopUpPageで表示されるというシンプルなアプリです。

最初の実装の概要

初め、PopUpPageのViewModelと状態クラスの実装は以下のようにしていました。

class PopUpViewModel extends StateNotifier<PopUpState> {
  PopUpViewModel(): super(PopUpState());

  void setText(String text) {
    state = state.copyWith(text: text);
  }

  String get text => state.text;
}

@freezed
abstract class PopUpState with _$PopUpState {
  factory PopUpState({
    @Default('') String text,
  }) = _PopUpState;

  PopUpState._();
}

実装はとてもシンプルでsetTextで入力された文字列を受け取って、stateへセットします。

一方viewからは以下のように文字列をセットします。

class PopUpPage extends ConsumerWidget {
  const PopUpPage(this.text, {Key? key}) : super(key: key);

  final String text;

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    context.read(popUpViewModelProvider.notifier).setText(text);

    return Scaffold(......

最初の実装の問題点

これで一応動作はします。 しかし、この実装には以下のような問題があります。

  • ビルド後にsetTextをするので、余分なビルドが走ってしまう
  • setTextが公開されているので、いつでもtextを変更できてしまう
  • viewModel側で、textを使った初期化処理をコンストラクタで行うことができない

一応動くものを作るときはこれでもいいかもしれません。 しかし、それなりの規模感のものを作ろうとしたとき、このような実装はいつか負の遺産になりえます。

問題点の解決方法

前述の問題点を回避するためには、textPopUpViewModelのコンストラクタで受け取って初期化する必要があります。

class PopUpViewModel extends StateNotifier<PopUpState> {
  PopUpViewModel({ required String text }): super(PopUpState(text: text));

  String get text => state.text;
}

@freezed
abstract class PopUpState with _$PopUpState {
  factory PopUpState({
    required String text,
  }) = _PopUpState;

  PopUpState._();
}

こうすることで、ViewModelの初期化時にtextをセットできるので、先ほどの問題点は解消されます。

新たな問題点

しかし、ここで新たな問題が発生しました。 それはいかにしてProviderを初期化するかということです。 riverpodのProviderはグローバル変数として定義されます。それゆえViewやViewModelよりもはるかに長いライフサイクルを持ちます。 そのため、以下のような実装ができません。

final popUpViewModelProvider =
StateNotifierProvider<PopUpViewModel, PopUpState>(
        (ref) => PopUpViewModel()); // コンストラクタへtextを渡す必要があるのでコンパイルエラーが出る

また、グローバルなProviderにtextをセットしたとき、他のViewからもtextへアクセスすることが可能になります。 値を他のViewに見せたくないときにこれだと困ってしまいます。

解決策

困って色々ググっていたら、以下のriverpodの作者のツイートを見つけました。

どうやらScopeedProviderを使うことで動的にProviderを初期化できるようです。 さらに、providerへアクセスできる範囲を制限できるので、見せたくないViewからtextを参照することができなくなります。

ScopedProviderを使うように修正

修正の流れは以下のようになります。

  1. StateNotiferをfamily修飾子を使用して作成する
  2. 未実装のScopedProviderを作成する
  3. PopUpPageNavigator.pushするとき、ProviderScopePopUpPageを包む
  4. ProviderScope内でScopedProviderの実装をオーバーライドする

1. StateNotiferをfamily修飾子を使用して作成する

familyを使って以下のようにstateNotifierProviderを作成します。 こうすることでStateNotifierProvider作成時にtextをコンストラクタに渡すことができるようになります。

final popUpViewModelFamily =
    StateNotifierProvider.family<PopUpViewModel, PopUpState, String>(
        (ref, text) => PopUpViewModel(text: text));

2. 未実装のScopedProviderを作成する

以下のように未実装のScopedProviderを作成します。

final popUpViewModelProvider =
    ScopedProvider<StateNotifierProvider<PopUpViewModel, PopUpState>>(
        (ref) => throw Error());

ここでエラーを投げるようにするのは、オーバーライドされていないpopUpViewModelProviderが思わぬ使い方をされないようにするためです。

APIリファレンスでもエラーを投げるよう実装することが推奨されていました。

Note: We made our ScopedProvider throw by default, as our list items by design requires an index to be specified. Another possibility would be to return null and have the item handle the null scenario.

pub.dev

3. PopUpPageNavigator.pushするとき、ProviderScopePopUpPageを包む

次にPopUpPageにstaticなMaterialPageRouteを作成するメソッドを生やしていきます。

  static MaterialPageRoute<PopUpPage> createPageRoute(String text) {
    return MaterialPageRoute(builder: (BuildContext context) {
      return ProviderScope(
        child: PopUpPage(),
      );
    });
  }

4. ProviderScope内でScopedProviderの実装をオーバーライドする

3で生やしたメソッド内で、overrideAsを使ってpopUpViewModelProviderをオーバーライドします。

  static MaterialPageRoute<PopUpPage> createPageRoute(String text) {
    return MaterialPageRoute(builder: (BuildContext context) {
      return ProviderScope(
        overrides: [
          popUpViewModelProvider
              .overrideAs((watch) => popUpViewModelFamily(text)),
        ],
        child: PopUpPage(),
      );
    });
  }

以上で実装は完了です。 あとは、HomePage側で以下のようにPopUpPageNavigator.pushすれば期待通りに動作します。

final text = context.read(homeViewModelProvider).text;
Navigator.push(context, PopUpPage.createPageRoute(text));

おわりに

今回の記事では、riverpodを使ってMVVMな実装をしているときに、画面遷移時にViewModelへ値をいい感じに渡す方法について紹介しました。 最初は少しトリッキーな実装のように感じていましたが、いろいろな課題点をクリアして、スッキリ実装できるriverpodの柔軟さに改めて驚きました。

それではよい開発ライフを!