Zenn
📘

FlutterとFirebaseを使ってアプリを作る上でのテクニックあれこれ

2025/02/20に公開
3
47

はじめに

この記事は【Zenn 初開催】AI エージェント開発に挑戦!初心者歓迎ハッカソンに提出した成果物である「BLOOMS」という Flutter を用いた iOS/Web アプリケーションで使用した、様々なテクニックについてまとめたものです。

BLOOMS が何なのか、についてはこちらの記事を参照してください。iOS 版と Web 版があるので、興味のある方はぜひお試しください。
https://zenn.dev/kingu/articles/a36196c5c66171

筆者について

2025 年現在、私はフリーランスのソフトウェアエンジニアとして活動しています。
主に Flutter を用いたモバイルアプリケーションを得意とし、必要に応じて Firebase を用いてバックエンドも構築しています。

細かい内容はポートフォリオをご覧ください(PR) https://kingu.dev

この記事について

「BLOOMS」は私がこれまで個人開発や仕事で得た知識を活用して実装しており、言わば現在の私の集大成です。ソースコードも公開していますし、せっかくなのでどのようなテクニックを使っているのかをまとめてみようと思います。

BLOOMS のソースコードを交えつつ、できるだけなぜそのような判断になったのか理由も合わせて述べていきます。

前提

2025 年 2 月時点で有効な情報であり、BLOOMS のバージョン 1.0.1 時点の話です。
https://github.com/KoheiKanagu/blooms/tree/v1.0.1

Flutter のバージョンは 3.27.2、Dart のバージョンは 3.6.1です。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/pubspec.yaml#L7-L9

これらのバージョンが上がったことによって使えなくなったり、挙動が変わる可能性もありますが、この記事では修正しませんのでご了承ください。

主なトピック

この記事で取り上げる内容は、ある程度知っている方を対象としているので初学者の方にとっては説明不足に感じるかもしれません。

順番にはあまり意味はありません。ざっくばらんに書いていきます。
長いので目次もご活用ください →

Flutter の基本関連

バージョンは固定する

バージョンは固定します。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/pubspec.yaml#L7-L9

flutter createで作成されたpubspec.yamlには以下のように緩めに指定されていますが、緩くするメリットが無いように思っています。

environment:
  sdk: ^3.6.1

というのも、以前renovateを使っていた時にバージョンの範囲指定で問題があったり、FVM で Flutter をインストールするときにfvm use stableしてしまうと、いつの間にかバージョンが上がっていたり、GitHub Actions でflutter-actionを使う際に手元とは違うバージョンになってしまった事故が発生したので、固定するようにしています。

現在ではpubspec.yamlはこのように固定し、

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/pubspec.yaml#L7-L9

.fvmrcfvm use 3.27.2を実行して固定し、
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/.fvmrc#L1-L3

CI ではflutter-fvm-config-actionを用いて.fvmrcからバージョンを引っ張り、
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/pull_request.yml#L64-L68

flutter-actionで参照しています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/pull_request.yml#L70-L78

これにより、pubspec.yaml.fvmrcを更新しない限りバージョンは勝手に変わらないですし、手元の環境、開発メンバー、CI で同じバージョンが利用できます。
また、もしふとした拍子にfvm use 3.29.0などとしてしまった場合でも、dart pub getでエラーになるので気づきやすいです。

FVMを使う

前述していますが、Flutter のバージョン管理には FVM を利用しています。asdfも選択肢としてよく上がりますが、Flutter の管理のためだけなので FVM で良いかなと思っています。

既に nvm や rbenv をやめて asdf に寄せている方であれば、そのまま Flutter も管理すると楽かと思います。

FVM の問題として、Homebrew をサポートしているのでbrew install fvmでインストールできるのは良いのですが、FVM 自体が Dart を必要としているので、Flutter(Dart)をインストールするツールをインストールするために Dart をインストールする、という卵が先か鶏が先かのような状態になります。

brew install fvmするとdartがインストールされる
brew install fvm すると dart がインストールされる

FVM はマシン全体の Flutter バージョンも管理できますが、場合によっては Homebrew でインストールされた Dart と衝突して PATH が変になる場合があるのでbrew unlinkするか、そもそも Homebrew で FVM をインストールするのはやめた方がいいかもしれません。

ビルド番号は自動で

pubspec.yamlversion+20のように書くと、ビルド番号を設定できます

version: 1.0.1+20

20という値は ios/Flutter/Generated.xcconfigFLUTTER_BUILD_NUMBERに埋め込まれます。

FLUTTER_BUILD_NAME=1.0.1
FLUTTER_BUILD_NUMBER=20

この指定は私は不要だと思っています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/pubspec.yaml#L5

この番号は機械的にバージョンの前後を表すために利用されているので、App Store や Play Store にアップロードする際に必ずこの番号をインクリメントしないと弾かれてしまいます。
メジャー・マイナー・パッチが変わるタイミングであれば一緒に手動で+1 すればいいのですが、問題はビルド番号だけのインクリメントが必要な場合です。

例えば次期バージョンをテストしたいので TestFlightGoogle Play の内部テストで配布したい場合はあると思います。
何かしらバグや QA で問題があった場合、修正して再配布する必要がありますがビルド番号もインクリメントする必要があります。
別のメジャー・マイナー・パッチにする場合は一緒に+1 すれば良いですが、同じメジャー・マイナー・パッチを許容する場合は、ビルド番号をインクリメントするプルリクエストを作成してマージして...というステップが必要になってきます。

Ciderなどを使って CI で自動でインクリメントする方法もありますが、いずれにせよアプリの機能には関係のない部分で、TestFlight や Google Play に受け付けてもらうためだけに手間をかけるのは時間の無駄です。

とりあえずインクリメントできればいいので、Xcode Cloud でビルドする場合は Xcode Cloud に任せています。自動でインクリメントして App Store Connect にアップロードしてくれます。
https://developer.apple.com/documentation/xcode/setting-the-next-build-number-for-xcode-cloud-builds

BLOOMS は Android 未対応なので別のアプリの話になりますが、Android の場合はタイムスタンプを使って適当な値を入れると良いと思います。iOS でも Xcode Cloud を使わないならタイムスタンプを使う方法が考えられます。
https://github.com/KoheiKanagu/garage/blob/1791470276f3858f87daa19ebde0a314dade7e45/packages/obento/android/app/build.gradle#L49-L55

Codemagicでは自動でインクリメントしてくれる機能があるようなので、Codemagic を使う場合はこちらを使う方が良いかもしれません。


ちなみにversion+20を指定しない場合は、FLUTTER_BUILD_NUMBERには1.0.1が埋め込まれます。

FLUTTER_BUILD_NAME=1.0.1
FLUTTER_BUILD_NUMBER=1.0.1

環境の選択は --flavor

環境は最低でも本番環境とステージング環境ぐらいは用意すると思います。この環境を Android と Flutter では Flavor、iOS では Scheme と呼びます。

flutter buildflutter run などでは--flavorオプションを使って指定したり、--dart-defineまたは--dart-define-from-fileオプションを使う方法がよく見られますが、私は --flavorが良いと思っています。

https://docs.flutter.dev/deployment/flavors-ios
https://docs.flutter.dev/deployment/flavors

本番環境とステージング環境それぞれで使い分けたい値というのは、例えば API のエンドポイントやアプリ名、Firebase の設定などがあると思いますが、これらの値は 1 回設定したらそうそう変わらないはずです。
ですので、わざわざ--dart-define--dart-define-from-fileオプションを使って毎回指定せずとも--flavorで指定する方がシンプルですし、何の値を使い分けているのかを意識しなくてもよくなるので、分かりやすいように思います。

とはいえ、あまりターミナルで素の flutter runflutter buildは実行せず、.vscode/launch.jsonやスクリプトを用意すると思うので好みの世界かもしれません。

ただし、ビルドの度になんらかの値を変えたかったり、コミットしたくない情報がある場合は --dart-define--dart-define-from-fileオプションを使う方が良いと思います。

Xcode Cloud との相性は --flavorの方がいい

Xcode Cloud の設定をする時に Scheme が認識されるので、選ぶだけでアプリ名やアイコンなどが本番環境向けになります。
Xcode Cloudの設定
Xcode Cloud の設定

なお、ios/ci_scripts/ci_post_clone.shでは予め --flavor prodを指定して本番向けのGenerated.xcconfigを生成しましょう。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/ios/ci_scripts/ci_post_clone.sh#L37

Flutter で Flavor を使い分ける

Flutter 側で Flavor によって値を切り替える場合はFLUTTER_APP_FLAVORという環境変数から flavor が参照できるので、enum を定義して使うと良いと思います。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/constants/app_env.dart#L1-L16

どうやって Flavor を構築するか

flutter_flavorizrを使う方法も考えられますが、そもそも頻繁に更新するものではないですし、やっていることも難しいことではないので iOS と Android ネイティブに触れる機会としても自分で設定するのが良いと思っています。(めんどくさいだけです)

Flutter の公式ドキュメントで丁寧に説明されています。
https://docs.flutter.dev/deployment/flavors-ios
https://docs.flutter.dev/deployment/flavors

1 回理解してしまえばサッサと対応できるようになると思いますが、iOS や Android のネイティブに慣れていないとややこしい部分もあるので、初学者の方は素直にflutter_flavorizrを使う方が簡単かもしれません。

各種コマンドは*.shを用意しておく

flutter runflutter buildなどのコマンドはターミナルに打ち込むのが面倒ですし、毎回オプションを指定するのも面倒でミスの原因にもなります。

dart run build_runner buildflutter testdart analyzeなど、よく使うものはそれぞれのシェルスクリプトを用意すると良いと思います。これらは手元のマシンで実行したり、CI でも同じものを利用できます。
https://github.com/KoheiKanagu/blooms/tree/v1.0.1/app/scripts

例えばプルリクエストの際に実行されるジョブでは、静的解析やコード生成、テストなどを並列に実行しています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/pull_request.yml#L40-L48

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/pull_request.yml#L91-L98

USE_FVM とは?

USE_FVMという謎の環境変数が出てきていますが、これは FVM を使ってflutterdartコマンドを実行するかを指定するものです。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/scripts/build_runner-build.sh#L4-L13

前述したように FVM で Flutter のバージョン管理をしているため、dart run build_runner buildなどではグローバル環境の Flutter ではなく、FVM で管理している Flutter を使って欲しいです。

そこでUSE_FVMtrueあるいは未指定にしておくと、flutterコマンドを実行する場合は暗黙的にfvm flutterとなるように関数を定義しています。

スクリプト内ではfvm flutter testのように頭にfvmをつければいいじゃないかと思いますが、CI ではわざわざ FVM をインストールしなくてもsubosito/flutter-actionでグローバルにインストールされた Flutter を使えばいいので、FVM は不要です。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/pull_request.yml#L70-L78

よって、CI では USE_FVMfalseにしてグローバルにインストールされた Flutter を使っています。

シェルスクリプトかGrinder

Grinder は Dart で実装できるタスクランナーですが、私は昔こちらを使っていました。
ですが、複雑なシェル芸をしないのであればシェルスクリプトで十分です。

各種コマンドをまとめているだけであれば Grinder を使うとむしろ分かりにくくなりますし、場合によっては PATH 周りでハマることもありました。
https://github.com/KoheiKanagu/garage/blob/1791470276f3858f87daa19ebde0a314dade7e45/tool/firebase/flutterfire_configure.dart#L17-L42

flutterfire_cliを使おう

flutterfire_cliを利用すると、Firebase SDK の構成ファイルのダウンロード、Firebase へのアプリの登録、Crashlytics のセットアップなどを簡単に行うことができます。
https://github.com/invertase/flutterfire_cli
https://firebase.google.com/docs/flutter/setup?hl=ja&platform=ios

とても便利なので使いましょう。
私はよくflutterfire_configure.shというスクリプトを用意しています。BUNDLE_IDPROJECT_NAMEを指定するだけで、全部やってくれます。(Flavor 対応で所定の Scheme を作成した後に実行できます)

頻繁に実行するようなものではありませんが、利用する Firebase のプロダクトを追加した時などは構成ファイルの更新が必要になるので、その時はこのスクリプトで一発です。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/scripts/flutterfire_configure.sh

上記のflutterfire_configure.shは Android は未対応ですが、以下のように追加すれば良いです。詳しくはflutterfire_cliのドキュメントを参照してください。

--android-package-name $PACKAGE_NAME \
--android-out "android/app/src/prod/google-services.json`

i18n はslangパッケージを使う

Internationalization にはいくつか方法がありますが、私は slang をよく使います。(大抵の場合は日本語と英語で出し分けるぐらいしかしていないので、使いこなせてる感じではありません。)
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/i18n/ja.i18n.json

slang は Flutter に依存していないのでBuildContextが不要な点が良いと思っています。

BuildContextが不要なので enum の中でも参照できます。例えば、こちらはとある enum に生やしている getter ですが、i18n.highlight.*の値は当日過去7日間などになります。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/features/highlight/domain/highlight_period.dart#L26-L34

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/gen/strings_ja.g.dart#L79-L83

また、翻訳に過不足があるとコンパイルエラーになるので、取りこぼしがないかも確認しやすいです。ARB にも対応しているので、翻訳を外部に依頼する場合でも対応しやすいと思います。

Linter は厳しめに

最近はVery Good Analysisを使っています。

2021 年 9 月にリリースされた Flutter 2.5からはflutter createすると Flutter 公式のflutter_lintsが設定されているのでそのまま使ったり、pedantic_monoを使う選択肢もあります。
正直ここはチームの方針や好みによるので議論が絶えませんが、個人的には厳しいルールの方が良いと思っています。

ひとまず厳しいルールにしておいて、どうしても対応するのが大変なルールは除外すると良いでしょう。なお、analyzer.excludeで余計なファイルは除外しておくとパフォーマンス的にも良いです。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/analysis_options.yaml

コード生成の高速化

有名な話ですが、riverpod_generatorfreezedなどコード生成が伴うパッケージを使っている場合はbuild.yamlでコード生成対象を絞ると高速化できます。includeexcludeで指定しましょう。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/build.yaml#L14-L21

build_runner 自体の高速化について議論が進んでいるようなので、近いうちに改善されるかもしれません。

プロジェクトの構造は Feature-first

議論が絶えないトピックではありますが、個人的には Feature-first が良いと思っています。
https://codewithandrea.com/articles/flutter-project-structure/

良いと思う点は機能毎にソースコードがまとまるので、実装する際に確認すべきソースコードがどこにあるのか見つけやすいです。

例えばこちらは BLOOMS のハイライト機能に関する内容ですが、ドメイン、ウィジェット、ビジネスロジックのソースコードが近くにあるので分かりやすいと思っています。
Feature-firstで構成されたBLOOMSのハイライト機能
Feature-first で構成された BLOOMS のハイライト機能

stable チャンネル?beta チャンネル?

私は個人開発なら beta、仕事なら stable を使っていることが多いです。

stable チャンネルは当然ですが、安定版なので最も安心です。(とはいえ結構頻繁に Hotfix がリリースされていますが...)
beta チャンネルも Google 製品のテストで検証されているので、比較的安定しているんじゃないかと思っています。何より新しい機能を先取りできるのがいいですね。

どの程度の信頼性が欲しいかによって選択すると良いと思います。

https://docs.flutter.dev/release/upgrade#switching-flutter-channels

BLOOMS では、当初beta を使っていましたが、analyzer が遅くてストレスフルだったので1 日で stable に戻しました。

適当なアイコンをさっと作る

BLOOMS のアイコンは Gemini に頼んで作ってもらったものをベースに、Graphiteで手動でベクタライズしています。
ベクター化は Adobe Illustrator とか GIMP でもいいと思いますが、ブラウザで動く Graphite が楽でした。まだまだ開発途中という感じですが、複雑なことをするわけではないので十分です。

ダークモードに適しているのかよく分からないですが、とりあえずダークモードにも対応しました。

ライト ダーク
BLOOMSのアイコン BLOOMSのダークモード向けアイコン

できたアイコンをIconKitchenで所定のサイズにリサイズしたり、STAGING というバッジを付けてステージング環境のアプリでは切り替えています。IconKitchen は文字のバッジが追加できるのが便利ですね。
自作のアイコンではなく、適当な図形で良ければ IconKitchen だけで作るのもありです。

BLOOMSのステージングのアイコン
BLOOMS のステージングのアイコン

最近知ったIcon Maker by Raycastもカッコよくて良さそうです。(でもバッジって付けられないですよね多分)

成果物はどこでビルドする?

成果物とは App Store Connect や Google Play Console にアップロードするためのバイナリのことを指しています。

iOS は Xcode Cloud

iOS の場合は証明書とプロビジョニングプロファイルが必要なので、Xcode Cloud をよく利用します。
25 コンピューティング時間/月が無料なので、成果物をビルドするだけであれば個人開発や小規模なチームであれば十分だと思います。機密情報を GitHub Actions に渡さなくてもいいのも安心です。
https://developer.apple.com/jp/xcode-cloud/

手動や特定のタグの変更をトリガーとして実行できるので、Releaseを作ったらビルドされ、TestFlight で配信されるようにすると良いと思います。

タグをトリガーとした設定
タグをトリガーとした設定

https://zenn.dev/miyasic/articles/flutter-xcode-cloud

macOS や Xcode のバージョン指定も簡単にできるので便利ですね。
macOSのバージョン指定
macOS のバージョン指定
Xcodeのバージョン指定
Xcode のバージョン指定


余談ですが 2022 年時点では Intel Mac が利用されていたようです。
現在は Apple シリコンになっているでしょうが、次第にマシンスペックも上がっていくのでしょうか?
https://wojciechkulik.pl/xcode/xcode-cloud-review-is-it-ready-for-commercial-projects#xcode-cloud-m1

Android は悩ましい

Android の場合は iOS よりも署名関連はまだ簡単ですが Google Play Console へのアップロードを考えると、アップロード鍵の管理が悩ましいです。

GitHub Actions で iOS の証明書などを管理する方法と同じように BASE64 でエンコードしてシークレットに登録して都度デコードし、成果物はGitHub Action の Artifactsに保存する方法も考えられます。Fastlane を使って直接アップロードしてもいいと思います。

https://docs.github.com/ja/actions/use-cases-and-examples/deploying/installing-an-apple-certificate-on-macos-runners-for-xcode-development

しかし、パブリックリポジトリの場合は事故りそうで怖いです。
GitHub Actions ではない CI/CD を使ったり、自己ホストランナーでビルドするのが良いかもしれません。


以前は手元のマシンでビルドとアップロードをやっていました。個人開発なら多分これが一番早いと思います。が、属人化するのでやめましょう。
https://github.com/KoheiKanagu/garage/blob/1791470276f3858f87daa19ebde0a314dade7e45/melos.yaml#L139-L147

https://github.com/KoheiKanagu/garage/blob/1791470276f3858f87daa19ebde0a314dade7e45/tool/store/upload_to_play_store.sh

GitHub Actions ではキャッシュを活用しよう

CI でのビルド時間の短縮は重要です。プルリクエスト毎にクリーンな環境はそれはそれで綺麗なのですが、キャッシュできる部分はキャッシュしてしまった方が開発サイクルが速くなるのでメリットが大きいと思っています。

私がよくやるのは、main ブランチへの Push をトリガーとして、依存ライブラリや Flutter のバージョンが変わった場合にビルドしてpubios/Podsをキャッシュする方法です。
https://github.com/KoheiKanagu/blooms/blob/32f20eb3125d0316f845ec1132bc4d2c62408f83/.github/workflows/flutter_cache.yml

これにより各種プルリクエストではキャッシュを使ってビルドすることができるため、ビルド時間が短縮されます。
まだ遭遇したことはありませんが、もしキャッシュが悪さをしている事態になった場合は、キャッシュを削除すればクリーンな状態でビルドできます。

もし不安ならキャッシュを使わずビルド・テストするワークフローも用意して、デイリーで実行するなどして確認すると良いと思います。

不要なキャッシュは削除しよう

GitHub Actions のキャッシュは最大 10GB までですが、作成される複数のキャッシュは100MB〜1GB 程度あるので結構すぐ上限に達します。(使っているパッケージの量に依存します)

特にDependabotを使って依存ライブラリのアップデートを行なっているとpubspec.yamlが変わるので新しくキャッシュが作成されることになり、キャッシュまみれになります。

プルリクエストがマージされたら、そのブランチのキャッシュは不要なので削除しましょう。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/cleanup_caches_by_a_branch.yml

ポイントはpermissionsで、これがないと Dependabot が作成したプルリクエストがマージされた際にキャッシュを削除できません。

permissions:
  actions: write

とはいえ、7 日間アクセスされていなければ古いものから勝手に削除されていくので、キャッシュ作成の頻度によっては気にしなくても問題ないと思います。

https://docs.github.com/ja/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy

アプリの見た目関連

Cupertino ウィジェットを使う

Flutter は基本的には Material デザインである Material ウィジェットですが、Apple ライクな Cupertino ウィジェットも用意されています。
https://docs.flutter.dev/ui/widgets/cupertino

2024 年 12 月に安定版となったFlutter 3.27や、2025 年 2 月に安定版となったFlutter 3.29では、Cupertino ウィジェットの忠実度が向上したり、showCupertinoSheetが追加されたりと、Cupertino ウィジェットにも力が入っているような気がします。

せっかくなので、BLOOMS では Cupertino ウィジェットを使って実装しています。
しかし、iOS でお馴染みのコンポーネント全てが Cupertino ウィジェットで実装されているわけではないので、一部はサードパーティのパッケージを利用しました。

https://pub.dev/packages/pull_down_button
https://pub.dev/packages/cupertino_calendar_picker

Flutter 3.27 でImpellerが利用できるようになったこともあり、パフォーマンスも良く、結構それっぽくなりました。iOS 版の BLOOMS で実際に見てみてください。

デザインとしては当然ですが Apple のアプリのようになるので、デザイナーの方のデザインに沿って実装する場合は Material ウィジェットの方が使い勝手がいいと思います。
ただ、ダイアログや画面遷移の仕方などはプラットフォームに沿った方がユーザにとって違和感がないと思うので、デザイナーの方と相談して使い分けると良いと思います。

Cupertino Icons

Apple ライクなアイコンも使いたいですよね?
Apple が提供している SF Symbols はバリエーションも多く、高品質なのですが規約的に使えません。
https://developer.apple.com/jp/sf-symbols/

そこで似た感じのアイコンを提供しているのがcupertino_iconsです。もちろん規約的にも OK です。
しかしめちゃくちゃ探し難いので、Cupertino Icons Gallery を使いましょう。
https://cupertino-icons.web.app/


carアイコンが重なっているバグがあったり、car_detailedがなぜか TESLA っぽかったりします。

TESLAっぽいcar_detailedアイコン
TESLA っぽい car_detailed アイコン

日本語のテキストをいい感じに折り返す

日本語のテキストは単語の途中で改行されると読みづらくなります。

最近では、BudouXという機械学習を使って分かち書きができるツールが登場し、Web 系や Android で採用されています。
https://developers-jp.googleblog.com/2023/09/budoux-adobe.html

BLOOMS では、オンボーディングのサブタイトルだけ BudouX を使っています。
「見つめてみよう〜」のテキストはうまいこと改行されていますが、「変化しやすい体調も〜」の文章は単語の途中で改行されてしまっているのが分かります。
いい感じに折り返されるテキスト
いい感じに折り返されるテキスト

なお BudouX 公式では 2025 年 2 月現在 Dart はサポートされていませんが、私が移植したパッケージで利用できます。ご興味がある方はどうぞ。
https://pub.dev/packages/budoux_dart

BlurHash で綺麗なプレースホルダー

BlurHash は画像を変換して Base83 でエンコードすることで、元の画像の特徴を保ったまま軽量なプレースホルダーを文字列で表現することができます。
https://blurha.sh/

BlurHashで読み込まれる画像
BlurHash で読み込まれる画像

最初プリンの画像がぼんやりしているのは BlurHash が表示されており、後から画像が読み込まれて鮮明になっています。

このプリンの BlurHash は LDJ7s_$#~U.7RjWW$%xZ?Gxu8^M{であり、非常に軽量です。
ただの文字列なので、サーバからデータをもらう際に一緒にこの BlurHash の文字列を含めておけば、サムネイル画像を読み込むなどの処理をしなくとも瞬時にプレースホルダーとして表示できます。

Flutter においては flutter_blurhash パッケージを利用すると良いと思います。

どこで BlurHash を生成するか

サーバでやった方がいいです。
Dart でエンコードするパッケージもありますが、遅いという点とサポートしている画像の形式が限られます。

BLOOMS ではカメラやフォトライブラリから画像を受け取ったら、Cloud Functions に投げてリサイズ、WebP にエンコード、Google Storage に保存、BlurHash の生成を行なっています。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/functions/src/features/image/processConditionContentImage.ts#L12-L82

Cloud Functions 関連

Cloud Functions で Firestore を使う際のコールドスタート高速化

Cloud Functions で Firestore を使う場合、そのままだと gRPC の初期化が走ってしまい、コールドスタートが遅くなります。
gRPC が必要になる機会はあまりないと思うので、preferResttrueにして高速化しましょう。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/functions/src/index.ts#L8-L10

https://www.youtube.com/watch?v=9nkAGR5Qe2A&t=45s

Firestore 関連

データ構造を考えるときは OpenAPI ドキュメント で作る

Firestore のデータ構造を設計するときは、みなさんどうやってますか?
ER 図を書くのも手だと思いますが、私は最近 OpenAPI ドキュメントでいいんじゃないかと思っています。(公式ツールとかベストプラクティスって無いですよね?)

Firestore のデータの構造はドキュメントとコレクションの繰り返しで構成されているので URI として表現できるはずですし、ドキュメントはほぼ JSON なので OpenAPI ドキュメント で表現できるように思います。

BLOOMS の Firestore のデータ構造はこのようになっています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/firestore/schemas.yml

昔は JSON5 で書いていたこともありましたが、OpenAPI ドキュメントの方がリッチですし、Swagger UI もあるので確認しやすい気がします。

Swagger UI
Swagger UI

ここからデータモデルを生成できれば、Flutter と Cloud Functions で共通にできるので管理しやすそうですが、どうなんでしょうか?freezedと共存できるなら良いのですが...

データの作成者は createdBy で判定

このドキュメントは誰が作ったのか、どのユーザに紐づいているのかを把握する状況はよくあります。

いくつか考えられる仕様はありますが、私はcreatedByというフィールドに Firebase Authentication の UID を入れる方法をよく取ります。
セキュリティルールとの相性もよく、クエリもしやすいと思います。

https://medium.com/firebase-developers/patterns-for-security-with-firebase-per-user-permissions-for-cloud-firestore-be67ee8edc4a

とはいえ、サービスの要件によりけりなので、最適かどうかは十分検討が必要です。

データの削除は TTL

データを論理削除する場合はdeletedAtのようなフィールドにフラグを立てたり、時刻を入れるのがよくあると思います。

Firestore では Timestamp を入れるのが良いと思います。
というのも、Firestore には特定のフィールドの Timestamp の値が過去になった場合に自動で物理削除してくれる機能があり、こちらと相性が良いです。
https://cloud.google.com/firestore/docs/ttl?hl=ja

一定期間保持した後に削除したい、といった場合にはdeletedAtに入れる時刻を工夫すると実現できます。何より自分で削除する実装を行わなくていいので楽です。

BLOOMS ではユーザが削除する操作を行なった場合はdeletedAtに現在時刻を入れることで、24 時間以内に物理削除されるようにしています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/firestore/schemas.yml#L53-L56

ユーザデータの削除

ユーザがアカウントを削除する操作を行なった場合は、そのユーザに紐づくデータも削除する必要があります。

ユーザがアカウントを削除する操作を行うと、そのユーザ固有のドキュメント users_v1/{UID}deletedAtフィールドに現在時刻を書き込みます。
deletedAtに値があるユーザは削除されたものとみなし、Cloud Functions で定期的に Firebase Authentication から削除します。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/functions/src/features/auth/application/onUserDeleteSchedule.ts#L6-L28

この時のポイントは、Firebase Authentication からは一人ずつ削除することです。


関連するデータの削除には、Delete User Data 拡張機能を使います。この機能拡張は Firebase Authentication のユーザ削除をトリガーとして、関連するコレクションや Cloud Storage に保存されたファイルなど一括で削除してくれます。
https://firebase.google.com/docs/extensions/official/delete-user-data?hl=ja

具体的にはfunctions.auth.user().onDeleteトリガーを利用しているため、onDeleteが呼ばれる必要があります。

https://github.com/firebase/extensions/blob/d7bdc93c3a809114b3ae571f5b5d510c3ee57f7c/delete-user-data/functions/src/index.ts#L193-L198

しかし、getAuth().deleteUsers()を使って Firebase Authentication から一気にユーザを削除してしまうとonDeleteが呼ばれないため、注意が必要です。


Delete User Data 拡張機能では、Cloud Storage に保存されたファイルも削除してくれますが、/images_v1/{UID}のように UID でディレクトリを分けて保存している必要があります。設計に関わってくるので、もし使うなら最初から考慮しておくと良いでしょう。

BLOOMS では次のように指定しています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/extensions/delete-user-data.env

Firebase Extensions 関連

Cloud Functions がデプロイされるロケーションを指定する

こちらの謎の 3 行でロケーションを指定することができます。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/extensions/delete-user-data.env#L9-L11

デフォルトだと Firebase Extensions の関数は us-central1 にデプロイされてしまいます。Extensions を構成する際にロケーションを指定できる場合もありますが、Extensions の構成パラメータで定義されている必要があります。

例えばDelete User Data 機能拡張では Firestore のパスが指定できますが、これはextensions.yamlの params にFIRESTORE_PATHとして定義されています。
https://github.com/firebase/extensions/blob/delete-user-data-v0.1.24/delete-user-data/extension.yaml#L88-L104

実は params に定義されていなくても、これ以外のパラメータも指定することができます。
firebaseextensions.v1beta.v2function第 2 世代の Cloud Functions に関する設定を指しており、location をオーバーライドすることができます。

こちらの issue で発見された解決策です。
https://github.com/firebase/extensions/issues/1750#issuecomment-1761102399

Firebase CLI で機能拡張をデプロイできない場合がある

Cloud Storage のデフォルトのバケット名が 2024 年 9 月に変更されました。この影響で firebase deploy --only extensionsとするとバリデーションエラーとなり、デプロイできない事態になっていました。

報告したところすぐに対応してもらえました。v13.30.0で修正されたのでバージョンアップしましょう。
https://github.com/firebase/firebase-tools/issues/8152

Firebase Hosting 関連

今回、Firebase Hosting を使って 3 つのサイトをホストしています。

Firebase Hosting のデプロイターゲットを使うと、1 つのプロジェクトで複数のサイトをホストできます

GitHub Actions で Firebase Deploy

OIDC を使おう

GitHub Actions から Firebase にデプロイする際には、Firebase CLIを使ってデプロイします。この時、該当する Firebase プロジェクトにアクセスするため、なんらかの認証情報が必要です。

以前はFIREBASE_TOKENを使う方法が一般的でしたが、2021 年 12 月に OIDCが導入されたため、より安全にアクセスできるようになりました。

当初は(確か)一部リソースのデプロイが未対応だったり、Admin SDK が対応しておらずテストが動かないため、やむを得ずFIREBASE_TOKENを使っていました。最近は OIDC が原因の問題に遭遇していないので多分 OIDC だけで大丈夫だと思います。

https://docs.github.com/ja/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform


セットアップがやや面倒ですが、gcloud コマンドで一気に設定できるようにしたスクリプトを用意しています。手元のマシンで実行したり、Cloud Shell で実行すると良いでしょう。
https://gist.github.com/KoheiKanagu/a7ba42bde25f8fb10abb673c4fb8154c

何をしているのかについては参考文献をご覧ください。
https://zenn.dev/kou_pg_0131/articles/gh-actions-oidc-gcp

セットアップが完了したら、次のようにAuthenticate to Google Cloudアクションで認証情報を取得できます。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/firebase_deploy.yml#L55-L60

run-name を動的に変えてデプロイ先を表示

GitHub Actions の小技ですが、実はrun-nameは動的に変えることができます。

本番環境向けとステージング環境向け、それぞれデプロイするワークフローを実装するのは管理が大変になるのでできるだけ共通化したいです。ですが、同じrun-nameだとどの環境でデプロイされたのか分かりにくいです。

inputs.environmentから取得した環境名をrun-nameに渡せば、名前を変えることができます
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/firebase_deploy.yml#L3-L14

inputs.environmentに応じてrun-nameが変わった様子
inputs.environment に応じて run-name が変わった様子

何のワークフローをどこの環境で実行したのか一目で分かるので便利です。


OIDC のWORKLOAD_IDENTITY_PROVIDERSERVICE_ACCOUNTは次のように環境毎に切り替えています。(二値なのでまだましですが、条件分岐が増えると分かりにくくなるので考え直す必要はあります。)
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/firebase_deploy.yml#L20-L23

アプリが起動した時に行うこと関連

読み込み画面を出しつつ初期化する

こちらの記事を参考にしています。
https://codewithandrea.com/articles/robust-app-initialization-riverpod/

要はスプラッシュスクリーンの状態で様々な初期化を行うのではなく、スプラッシュスクリーンでは最低限の初期化だけを行い、読み込み画面を表示させたら他の初期化を実行し、全て完了したらメイン画面、またはエラーページに遷移させましょうというものです。

BLOOMS ではmainからrunAppまでの間に行なっている初期化はFirebase.initializeAppや Locale といった最低限のものだけです。もしこれらの初期化が失敗した場合はアプリはクラッシュしたりフリーズしても良いレベルのエラーです。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/main.dart#L19-L41

go_router使っているので間は省きますが、runApp されたらAppStartupWidgetが表示されます。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/features/startup/presentation/startup_page.dart#L9-L26

appStartUpProviderでその他の初期化処理を行なっており、Provider の状態に応じて読み込み画面、エラーページを表示します。初期化が完了した場合は go_router のリダイレクトでオンボーディングかメインの画面に遷移します。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/features/startup/application/app_startup.dart#L16-L98

もしappStartUpProviderでエラーが発生した場合は、ref.invalidate(appStartUpProvider)で破棄すれば再度初期化することもできます。

前述の記事で詳しく解説されているので、興味があれば読んでみてください。

Transition は控えめに

デフォルトの Transition は画面横からスライドしてきますが、これだとアニメーションが激しいのでアプリを起動する度にウワッとなります。
NoTransitionPageFadeTransitionでスムーズに切り替わるようにすると良いと思います。

読み込み画面はそもそも必要なのか?

個人的にはあった方がなんとなく起動してますよ感があるのでユーザも安心できると思っています。
スプラッシュスクリーンのままでしばらく固まっているよりは、ずっと良いと思います。


稀にアプリを起動したらオンボーディング画面に遷移し、認証状況をチェックし、サインイン済みであればホーム画面に遷移する、といった挙動のアプリに遭遇します。
サインイン済みなのに一瞬オンボーディング画面が出るのって違和感ありますよね?特にネットワークが貧弱でなかなかアクセストークンが取得できないのか分かりませんが、しばらくオンボーディングの画面が出るアプリにも遭遇したことがあります。

この違和感を解消するためにも読み込み画面を挟み、認証状況なりをチェックしてから遷移させると良いと思います。

go_router の refreshListenableredirectで初期化後の画面遷移

go_router では、refreshListenableValueNotifierを渡すことで、値が変わった時にリダイレクトすることができます。

リダイレクトではアプリの初期化がまだなら AppStartupWidgetに遷移させたり、サインイン状況を確認してオンボーディングかメイン画面に遷移させたりしています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/routing/application/my_go_router.dart#L50-L99

refreshListenableには refreshListenableProviderを渡しており、アプリの初期化状況や Firebase のサインイン状況、ユーザドキュメントの作成状況を監視しています。これらの値が変わったらリダイレクトが実行されます。
この方法のメリットとしては、アプリの初期化とユーザの認証状況に応じた画面遷移を一元管理することができる点です。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/routing/domain/my_go_router_listenable.dart#L24-L69

これにより、どの画面にいても認証状況が変化したら特定の画面に遷移できます。
前述したAppStartupWidgetではappStartupProviderが完了した後の画面について実装していませんでしたが、リダイレクトで所定の画面に遷移するので実装する必要はありません。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/features/startup/presentation/startup_page.dart#L13-L24

強制サインアウトもできる

refreshListenableProviderではユーザ個人のドキュメントの作成も監視しています。このドキュメントはいわゆるユーザ毎の情報で、名前などのプロフィールの記録を想定したものです。(BLOOMS では作成日、更新日、削除日しかありません。

このドキュメントが作成されているかを監視しているので、ドキュメントを消せば強制的に即刻オンボーディング画面に遷移させることもできます

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/routing/domain/my_go_router_listenable.dart#L57-L66

Firebase Authentication のリフレッシュトークンを revokeしても良いと思いますが、アクセストークンが有効な 1 時間は利用できてしまうので、緊急性がある場合は先にユーザドキュメントを削除してキックした上で、revoke すると良いと思います。

一応、デバイスの紛失や盗難時における不正利用や、不正なユーザからのアクセスをブロックするための対策も兼ねているのですが、実際に使ったことはないので実用的かはなんとも分かりません。
後述しますが、サインインしたらユーザドキュメントを作る処理が完了したかどうかを監視するための用途が主です。

サインインしたらユーザドキュメントを作る

ユーザドキュメントはユーザ毎の情報を保存していますが、サインインが完了したタイミングで Cloud Functions で作成すると良いと思います。

アプリでユーザドキュメントの作成を行う場合、もし作成に失敗したら進行不能になってしまいます。リトライしてなんとかしてもいいと思いますが、Cloud Functions でFirebase Authentication トリガーを使って作成した方がシンプルだと思います。
また、アプリのバージョンによって作られるユーザドキュメントの内容が変わってしまう可能性もあるので、一元管理できる Cloud Functions で作成するのが良いと思います。

BLOOMS ではユーザドキュメントの作成に加え、いわゆるシードデータも作成しています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/functions/src/features/auth/onCreateAuthUser.ts#L21-L53

前述したrefreshListenableProvider ではこのユーザドキュメントを監視することでユーザドキュメントが作成されたらリダイレクトできます。

(ちなみに Firebase Authentication トリガーは未だに第 1 世代なのですが、なんとかなりませんかね...

匿名認証じゃない場合はブロッキング関数でも

ブロッキング関数を使えば、アプリに認証情報が渡る前になんらかの関数を実行することもできるので、Firebase Authentication トリガーを使わなくても同様のことが実現できます。
しかし、残念なことに匿名認証では動作しません

これはブロッキング関数のユースケースが特定のドメインのメールアドレスだけを許可したり、特定の IP アドレスからのサインインをブロックすることなので、匿名認証で動作しないのは仕方ない気がします。
https://firebase.google.com/docs/auth/extend-with-blocking-functions?hl=ja&gen=2nd

ルーティング

シンプルなアプリであれば Navigator 1.0 だけでも十分ですが、認証に応じて画面遷移したかったり、ディープリンクをサポートしたかったり、何かしら柔軟なルーティングが必要な場合は Navigator 2.0 を利用すると良いと思います。
しかし Navigator 2.0 はそのまま使うととても難しいので、なんらかのパッケージを使うべきです。
https://medium.com/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade

go_routerは Navigator 2.0 を簡単に使えるパッケージで、利用者も多いですし Flutter 公式が開発しているので、デファクトスタンダードと言ってもいいでしょう。

とはいえ、Navigator 2.0 だけで全てのルーティングを行うのは至難の業だと思います。画面毎のパスの定義や画面間のデータの受け渡し、同じ画面を使い回すがパスが違う場合の対応など、意外にハマりどころが多いです。

私のおすすめは主要な画面だけ Navigator 2.0 で定義し、個別の画面は Navigator 1.0 を使う方法です。

BLOOMS では、アプリ起動直後に表示するローディングのstartup、オンボーディングのonboarding、ホーム画面のhomeの 3 つだけを Navigator 2.0 で定義しています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/app/lib/routing/application/my_go_router.dart#L30-L37

[GoRouter] Full paths for routes:
           ├─/startup (Widget)
           ├─/onboarding (Widget)
           └─/home (Widget)
[GoRouter] setting initial location /startup

次の GIF にあるような画面遷移は Navigator 1.0 で行っています。

Navigator 1.0での画面遷移
Navigator 1.0 での画面遷移

このように適材適所で使い分けた方が、ルーティングで考えることが減るので実装しやすいと思います。

どの画面で Navigator 2.0 を使うのか

直接その画面に遷移したいかどうかで決めると良いと思います。

ディープリンクで直接特定の画面に遷移したいのであれば、その画面は Navigator 2.0 を使うべきです。一方で設定画面や詳細画面など、直接遷移しない画面は Navigator 1.0 で十分でしょう。

しかし、Web 対応する場合は URL の表現に違いが出てきます。Navigator 1.0 だと URL が変わらないため、同じ URL のまま画面遷移することになってしまいます。
この挙動をどう考えるかは要件次第ではありますが、気にしないのであれば Navigator 1.0 と Navigator 2.0 のハイブリッドが良いと思います。

Flutter の状態管理

Riverpod で状態管理

2024 年 12 月頃、pub.dev でパッケージのダウンロード数が表示されるようになりましたが、RiverpodProviderが相当数ダウンロードされていることが判明しました。

私はhooks_riverpodを初め、riverpod_generator使っています。
Provider でできることは Riverpod でもできるはずなので、移行がまだの方は検討してみてはいかがでしょうか?

Riverpod の使い方やテクニック、いいところ悪いところ、他の状態管理との比較などは様々な記事があるので、みなさん各自で確認してください。ここで多くは語りません。

https://zenn.dev/topics/riverpod

https://codewithandrea.com/tags/riverpod/

https://medium.com/tag/riverpod

Dependabot で依存パッケージをアップデート

依存しているパッケージって結構バージョンアップがあります。まだまだ枯れた技術ではないので、活発に開発されていることが伺えます。

私はよく毎週月曜日の明け方にチェックするように設定しているのですが、毎週何個もアップデートがあります。
https://github.com/KoheiKanagu/blooms/pulls?q=sort%3Aupdated-desc+is%3Apr+label%3Adependencies+is%3Amerged+

パッチ番号レベルのアップデートであれば自動でマージしても良いのですが、CHANGELOG を眺めて「開発されてんなー」と感じたいので手動でマージするようにしています。

Dependabot の設定におけるポイントはgroupsをきちんと定義することで、まとめてアップデートされたプルリクエストを作ってくれるようになります。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/dependabot.yml#L19-L30

例えば Firebase 関連はモノレポで管理されていることもあってか、まとめて一気にアップデートされることが多いです。これら一つ一つが別々のプルリクエストになると CI の実行時間も延びますし、マージされる順番によってはリベースが必要になってなかなかマージされない事態になります。


Renovateも選択肢の一つです。Dependabot に比べてカスタマイズできる幅が広いので、細かくカスタマイズしたい場合はこちらを使うと良いかもしれません。

ただセットアップがやや面倒なので、とりあえずアップデートをチェックしたいということであれば Dependabot の方がお手軽だと思います。

関係するファイルが更新された時だけ GitHub Actions のジョブは実行しよう

BLOOMS のリポジトリには、アプリとサーバサイドであるFirebase 関連が共存しています。そのためアプリに関するソースコードだけを変更する場合と、Firebase 関連だけを変更する場合があります。

アプリに関するソースコードだけを修正した場合、Firebase 関連には変更がないので、Firebase 関連に対するジョブは必ずしも実行する必要はありません。逆もまた然りです。アプリに関するジョブは比較的時間がかかるので、できれば関係ない時は実行したくありません。

そこで、Changed Filesアクションを使って関連するファイルが更新された時だけ、所定のジョブを実行するようにしています。
アプリに関するapp、Firebase 関連に関するfirebase、アプリの pubspec.yaml の変更に関するpubspecを定義しています。
https://github.com/KoheiKanagu/blooms/blob/ce956da252c0f21000f759ac172498225701b6ca/.github/workflows/pull_request.yml#L24-L35

changed-files.outputsより、それぞれの変更の有無を取得できるので、必要なジョブだけを実行することができます。

https://github.com/KoheiKanagu/blooms/blob/ce956da252c0f21000f759ac172498225701b6ca/.github/workflows/pull_request.yml#L61-L62

実際のワークフロー実行時間の比較

次の画像はapp/**に変更を行ったプルリクエストのワークフローの様子です。

右上のcheck_cloud_functionsは Cloud Functions のテストを実行するジョブなのですが、4 秒で完了しています。これはランナーは起動したが、テストを実行する必要がなったのですぐに終了したことを示しています。

一方で アプリに関するジョブであるMatrix: check_appは、数十秒から 3 分程度のジョブがそれぞれ並列に実行されています。
よってchanged-filesジョブの時間も含めると、このワークフローが完了するまでには 3 分 7 秒必要でした。

に変更を行ったプルリクエストのワークフローの様子
app/**に変更を行ったプルリクエストのワークフローの様子

逆に、firebase/**に変更を行ったプルリクエストのワークフローの様子を見てみましょう。

次の画像にあるようにcheck_cloud_functionsは 51 秒かかっていますが、Matrix: check_appは 5 秒以内に完了しています。
よってchanged-filesジョブの時間も含めると、このワークフローが完了するまでには 55 秒必要でした。

に変更を行ったプルリクエストのワークフローの様子
firebase/**に変更を行ったプルリクエストのワークフローの様子

変更に対して関係のあるジョブだけを実行することで、ワークフローが完了するまでの時間を短縮できることが分かります。

ただし数秒のジョブはコスパ最悪

数秒で終わるジョブをたくさん作るのはコストの面では最悪です。

GitHub Actions では実行時間の秒単位は分に切り上げられてしまうので、たとえ 1 秒のジョブでも 1 分としてカウントされてしまいます。
よって、前述のようにMatrix: check_appで 6 つのジョブが数秒で終わったとしても、6 分としてカウントされています。

もう少しコスト面を考慮するならMatrix: check_appは 1 つのジョブにまとめ、さらにchanged-filesに相当する処理をcheck_cloud_functionscheck_appジョブ内で実行すると良いと思います。

changed-filesを前段のジョブにして、かつ数秒で完了する可能性のあるジョブを並列に実行するのは、GitHub Actions がパブリックリポジトリでは無料であるからこそ気にせず使えるテクニックであると言えます。

on.pull_request.pathsを使えばいいのでは?

on.pull_request.pathsを使う方法も考えられますが、これはワークフロー自体の実行をスキップしてしまいます。

実行のスキップは、特定のジョブが成功しないとプルリクエストをマージできないようにする、ブランチ保護ルールステータスチェックとの相性が悪いです。
ステータスチェックではワークフローは実行した上で OK か NG かを判定するので、on.pull_request.pathsを使うとワークフローが実行されないため、ステータスチェックが通らないという事態になります。

(スキップされた場合は OK とみなす、といったステータスチェックは定義できないはずです)

Firestore と Firebase Extensions の設定も CI でチェックしよう

プルリクエストで実行される GitHub Actions のワークフローでは、check_firebaseというジョブも実行しています。
ここでは Firestore のインデックスと Firebase Extensions の設定に差分が無いかをチェックしています。

https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/pull_request.yml#L153-L197

Firestore のインデックスの差分

複合クエリを実行した時、インデックスが不足しているとエラーが表示されますが、エラーメッセージには不足しているインデックスを作成するための URL が含まれています。この URL にアクセスすると Firebase コンソール上でワンクリックでインデックスを作成することができます。

このインデックスは Firebase CLI で更新できますが、ローカルにあるfirestore.indexes.jsonで上書きされてしまうことになります。
前述した"不足したインデックスを作成するための URL" からインデックスを作成した場合、当然ですがfirestore.indexes.jsonは更新されません。何らかの方法でfirestore.indexes.jsonを更新する必要があります。

更新自体はfirebase firestore:indexesでインデックスをエクスポートすれば良いのですが、往々にして忘れます。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/scripts/export_firestore_indexes.sh

そこで、check_firebaseジョブでエクスポートを実行し、firestore.indexes.jsonと Firebase にデプロイされているインデックスに差分が無いかをチェックしています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/.github/workflows/pull_request.yml#L185-L189

Firebase Extensions の設定の差分

Firebase Extensions は Firebase Extensions Hubからインストールする方法が簡単なのでよく使います。
Firebase Extensions の設定はfirebase ext:exportextensions/*.envにエクスポートできますが、これも往々にして忘れます。

この場合も Firestore のインデックスと同様にcheck_firebaseジョブでエクスポートを実行し、エクスポートした設定とextensions/*.envに差分が無いかをチェックしています。
ついでに、前述した Cloud Functions のデプロイ先のロケーションが期待通りオーバーライドできているかもチェックしています。
https://github.com/KoheiKanagu/blooms/blob/v1.0.1/firebase/scripts/export_extensions.sh

チェック必要?

かなりの確率で事故が防げると思うので、チェックしておいた方が良いと思います。

本番環境へのデプロイは firebase deployを実行することになると思いますが、ここできちんとステージング環境と同じ設定がデプロイされる必要があります。
インデックスや Firebase Extensions の設定は Firebase コンソール上で変更を加えることが多々あるので、その変更がきちんとコミットされていてfirebase deployでデプロイされるのかを保証するためにも、CI でチェックすることは重要だと思います。

とはいえ、これは Firebase に限らずだと思います。本番とステージングで設定が違って事故ること、あるあるですよね?

おわりに

BLOOMS を作りながらメモしていたことを一通り書きました。

想定より長文になってしまったので全て目を通していただくのは大変だと思いますが、何かしら参考になる部分があれば幸いです。他にも何か思い出したら追記するかもしれません。

ご質問等ありましたら、お気軽にコメントいただければと思います。

以上です。

GitHubで編集を提案
47

Discussion

ござパイセンござパイセン

素晴らしい記事をありがとうございました!レポジトリ、Forkさせてもらいました。
自分がCI/CDまわり弱いので、Github Actionsの使い方がとても勉強になりました。

GoRouterのAuthGuard、自分は認証情報を持ってるProviderをredirectで都度readしてチェックしてたんですが、refreshListenablewatchしてredirectを走らせるやり方ほうがスマートだなと。GoRouterをRiverpodに入れているところは、自分も同じです。

自分もNavigatorは使い分けしてます。DeepLinkで呼ばれるところはGoRouterで呼び出すが、複数画面で使い回してオブジェクトを渡したい局面などはNavigator.pushで実装しました。GoRouterは親のパラメータを必ず子に渡さないといけない縛りがあって、それがいらない局面もまぁまぁあるので。

kingukingu

ご一読いただきありがとうございます

認証情報を持ってるProviderをredirectで都度readしてチェック

私も以前はそのようにしていたのですが、画面遷移を伴わないと redirect が実行されないため、なかなか使いづらかった記憶があります。
例えばサインイン完了したら適当な画面に遷移させて redirect を呼ぶ必要があるはずです。

かといって以下のように watch するとGoRouterが再構築されてしまいますし。

GoRouter myGoRouter(Ref ref) {
  final isSignedIn = ref.watch(isSignedInProvider);

  return GoRouter(
    redirect: (context, state) async {
      // Do something
    },
  );
}

refreshListenable を使えばこれらの問題を解決できるので結構使いやすいと思っています。

ござパイセンござパイセン

例えばサインイン完了したら適当な画面に遷移させて redirect を呼ぶ必要

これはアプリの要件によるかもしれないです。

ゲストは閲覧できるが、お気に入り登録やカートINはゲスト不可みたいな感じでして、どの画面からログイン or 会員登録モーダルが呼ばれたかによって、ログインor会員登録後に叩くAPIが違ってたり、遷移する画面も違ってたので、遷移が必要であれば完了後にcontext.go("<where_to_go>")で実装しました。

ログアウトしたら何も使えんぞ!っていう要件なら、redirectでログインページにすっ飛ばすとか、もしくはログアウト成功後に、SignInRoute().go(context)で画面スタック入れ替えてもいいのかなっていう。

refreshListenableを使わなかったんで、起動したときの初期表示画面などの調整は読込画面(SplashPage)をはさんで、そこのuseEffectでゴニョゴニョしてました。

ご指摘の通り、GoRouterのリビルドが走ってしまうのでwatchは使えない。GoRouter内部では。常にinitialLocationに飛んでしまう〜

ログインするとコメントできます