Flutter アプリはどこから始まるのか
Flutter アプリは Dart の main() から始まり、最初のフレームが描かれるまでに決まった順序で処理が進む。その流れを最小の実コードで追い、初期化・エラー捕捉・実行時設定を「どこに置くか」を掴む。
Flutter アプリは、突き詰めれば 1 本の Dart プログラムです。実行は Dart の main() から
始まり、そこから画面に最初のフレームが描かれるまでの間に、決まった順序で処理が進みます。
この順序を理解しておくと、「初期化コードはどこに書くのか」「クラッシュはどこで拾うのか」 「本番とステージングの設定はどこで切り替えるのか」といった問いに、その場の勘ではなく 構造として答えられるようになります。今回はその起動フローを、最小のコードで一本道に追います。
§ 01ENTRYPOINTすべては main() から
Dart プログラムのエントリポイントは main() 関数です。もっとも短い Flutter アプリは、
これだけで動きます。
void main() => runApp(const App());
main() に特別な仕組みはありません。ただの関数で、実行されると先頭から順に処理が走り、
runApp() を呼んだところで Flutter の世界につながります。
ところが実際のアプリでは、UI を出す前に「待つ」処理が入ります。設定ファイルの読み込み、
ローカル DB のオープン、外部 SDK の初期化——こうした処理は結果を await してから
先に進みたいので、main() は非同期関数として書かれるのが定番です。
Future<void> main() async {WidgetsFlutterBinding.ensureInitialized();await bootstrap();runApp(const App());}
void main() と Future<void> main() async の違いは、単に「中で await を使えるかどうか」
です。async を付けた瞬間に何かが変わるわけではなく、await で初期化の完了を待ってから
runApp() に進みたいから async にしている、と理解しておくと迷いません。
§ 02BINDINGrunApp() の前に足場を用意する
上のコードに出てきた WidgetsFlutterBinding.ensureInitialized() は、起動フローで最初に
つまずきやすい一行です。
バインディング(binding)とは、Flutter フレームワークとネイティブエンジンをつなぐ足場のこと
です。プラットフォームチャンネル(ネイティブとの通信路)、描画、ジェスチャーなどは、この足場が
用意されて初めて使えます。通常 runApp() が内部でこの初期化を行うため、普段は意識しません。
問題になるのは、runApp() より前にプラグインやプラットフォームチャンネルを使いたいとき
です。たとえば「保存済みのログイン状態を読んでから最初の画面を決めたい」ような場合、runApp()
の前にストレージへアクセスします。このとき足場ができていないと、次のエラーで落ちます。
Binding has not yet been initialized.
だから、プラグインに触れる初期化を runApp() の前で行うなら、その前に一度だけ
ensureInitialized() を呼んで足場を立てておきます。
初期化処理そのものは、main() に直書きせず専用の関数へ切り出しておくと見通しがよくなります。
互いに依存しない初期化は Future.wait でまとめて走らせると、起動時間を縮められます1Future.wait は渡した処理を並行に走らせる。既定(eagerError: false)では全処理の完了を待ってから最初のエラーで完了し、eagerError: true を渡すと最初の失敗で即座にエラー完了する。いずれの場合も、開始済みの処理はキャンセルされない。失敗を個別に扱いたいなら各 Future を try/catch で包むか、順に await する。。
Future<void> bootstrap() async {// 互いに独立した初期化は並行に走らせるawait Future.wait([configureLogging(),openLocalDatabase(),initRemoteSdk(),]);}
ひとつ注意点があります。この初期化が終わるまで最初のフレームは描かれず、その間ユーザーには ネイティブのスプラッシュ画面が出続けます。ここに重い処理を積むほど「起動が遅いアプリ」に なります。起動時に本当に必要なものだけを待ち、残りは画面表示後に回すのが基本方針です。
§ 03RUNAPPルート Widget をツリーに載せる
runApp() は、渡された Widget をツリーの頂点(ルート)として取り付け、最初のフレームの
描画を予約します。ここから先、画面上のすべては Widget です。
ルート Widget には、MaterialApp(Material Design)や CupertinoApp(iOS 風)を置くのが
一般的です。これらがテーマ、ルーティング、ローカライズといったアプリ全体の土台を提供します。
さらに実務では、ルート Widget を「アプリ全体へ状態や依存を配るスコープ」で包むことがよく
あります。たとえば状態管理に Riverpod を使う場合は、ProviderScope で包みます。
runApp(const ProviderScope( // 状態と依存をツリー全体へ配るスコープ(Riverpod の例)child: App(),),);
この「ルートを包む」パターンは Riverpod に限りません。仕組みとしては Flutter の
InheritedWidget(ツリーの上から下へ値を配る仕組み)で、子孫の Widget が
BuildContext 経由で祖先の値を取り出せる、という一点に集約されます。詳しくは次回の
BuildContext の回で扱います。
ルート Widget 側では、ルーティングの構成方法を選びます。従来の Navigator を使う
MaterialApp と、Router API(go_router などを組み合わせる)を使う MaterialApp.router
の 2 系統があります。
class App extends StatelessWidget {const App({super.key});@overrideWidget build(BuildContext context) {return MaterialApp.router(title: 'My App',routerConfig: appRouter, // go_router などが返すルーティング設定);}}
MaterialApp(home: や routes: を渡す従来型)と MaterialApp.router(routerConfig: を
渡す宣言的ルーティング)は、どちらもナビゲーションの入口です。ディープリンクやタブごとの
ナビゲーション履歴を扱いたくなったら後者、という選び分けになりますが、これはナビゲーションの
回で改めて掘り下げます。今の段階では「runApp() に載せるルートが、アプリ全体の設定を握る
Widget である」ことを押さえておけば十分です。
§ 04GUARDエラーをどこで捕まえるか
本番アプリは、想定外の例外で静かに死ぬのではなく、ログに残しクラッシュレポートに送りたい ものです。Flutter のエラーは大きく 2 種類あり、捕まえる場所が違います。
ひとつは、build / レイアウト / 描画といった Flutter フレームワーク内で起きる例外。
これは FlutterError.onError で受け取ります。もうひとつは、その外側で起きる
非同期の未捕捉例外。これは runZonedGuarded でゾーン全体を包むか、
PlatformDispatcher.instance.onError で受け取ります2PlatformDispatcher.instance.onError は Flutter 3.3 以降で使える、エンジンに届いた未捕捉例外の最終捕捉点。多くの場合 runZonedGuarded の代わりになり、より軽量。処理済みとして扱うなら true を返す。。
起動フローでは、runApp() までをまるごと runZonedGuarded で包み、その中で
FlutterError.onError も設定しておくのが定番です。
Future<void> main() async {runZonedGuarded(() async {WidgetsFlutterBinding.ensureInitialized();// Flutter フレームワーク内で起きる例外FlutterError.onError = (details) {FlutterError.presentError(details); // コンソールにも従来どおり出すreportCrash(details.exception, details.stack);};await bootstrap();runApp(const App());},// このゾーン内で捕まえきれなかった非同期例外(error, stack) => reportCrash(error, stack),);}
ポイントは、エラーを潰すのではなく、一箇所に集約して記録することです。ここで握った
例外を握りつぶしてしまうと、原因の分からないクラッシュだけが残ります。presentError で
これまでどおりコンソールにも出しつつ、レポート送信を一本化するのがバランスの良い形です。
§ 05CONFIG実行時に切り替える値
API のエンドポイントや、本番/ステージングの区別のように、ビルドごとに差し替えたい値が
あります。これをソースコードに直書きすると、環境を変えるたびにコードを書き換えることに
なります。Flutter では --dart-define でビルド時に値を注入できます。
flutter run --dart-define=API_BASE_URL=https://staging.example.com --dart-define=STAGING=true
注入した値は、String.fromEnvironment などのコンパイル時定数として読み出します。
class AppConfig {static const apiBaseUrl = String.fromEnvironment('API_BASE_URL',defaultValue: 'https://api.example.com',);static const isStaging = bool.fromEnvironment('STAGING');}
fromEnvironment 系は const 文脈で使ってこそ確実に効きます(コンパイル時に値が
埋め込まれるため)3const にすると値がコンパイル時に確定するため、bool.fromEnvironment による分岐は未使用側のコードごと削除できる(ツリーシェイキング)。非 const の呼び出しは動作が保証されず、多くの AOT ビルドでは compiler option を実行時に参照できないため既定値が返る。必ず const 文脈で使う。。String.fromEnvironment / bool.fromEnvironment / int.fromEnvironment
の 3 種があり、フラグや切り替えに向きます。ただし --dart-define の値はビルド成果物に
埋め込まれます。ソースコードに直書きせずビルド側で渡せる、という意味であって、配布物から
値を隠す仕組みではありません。API base URL・環境名・feature flag のような公開してよい設定に
使い、API キーやトークンのような秘密情報はクライアントに埋め込まず、サーバー側で保持します。
もうひとつ、起動時によく出てくるのがプラットフォーム分岐です。UI の出し分け程度なら、
package:flutter/foundation.dart の kIsWeb と defaultTargetPlatform だけで判定できます。
import 'package:flutter/foundation.dart';bool get isMobile =>!kIsWeb &&(defaultTargetPlatform == TargetPlatform.iOS ||defaultTargetPlatform == TargetPlatform.android);
注意したいのは dart:io の Platform です。こちらは Web 向けビルドでは import 自体ができず
(dart:io はブラウザ向けでは利用不可)、kIsWeb の実行時ガードでは回避できません。OS 固有の
API がどうしても必要なときは、conditional import で Web 用と非 Web 用の実装を分けます。UI レベル
の分岐なら、上記の defaultTargetPlatform で十分です。
§ 06SUMMARY起動フローの全体像
ここまでを一本の流れに戻すと、こうなります。
main()が実行されるWidgetsFlutterBinding.ensureInitialized()で足場を立てるbootstrap()で必要な初期化をawaitするrunApp(root)でルート Widget をツリーに載せ、最初のフレームを描く- 全体を
runZonedGuarded+FlutterError.onErrorで囲み、例外を集約する - 環境ごとに変わる値は
--dart-defineでビルド時に注入する
main() から最初のフレームまでの間に「何が・どの順で」起きるかが見えていれば、初期化コードも
エラー処理も設定値も、置き場所に迷わなくなります。
次回は、runApp() に載ったあとの世界——Widget ツリー・build()・BuildContext を扱います。
「Widget は画面そのものではない」という、Flutter を読むうえで最初の壁になりやすい考え方を
ほどいていきます。
- [1]
Future.waitは渡した処理を並行に走らせる。既定(eagerError: false)では全処理の完了を待ってから最初のエラーで完了し、eagerError: trueを渡すと最初の失敗で即座にエラー完了する。いずれの場合も、開始済みの処理はキャンセルされない。失敗を個別に扱いたいなら各Futureを try/catch で包むか、順にawaitする。 ↩ - [2]
PlatformDispatcher.instance.onErrorは Flutter 3.3 以降で使える、エンジンに届いた未捕捉例外の最終捕捉点。多くの場合runZonedGuardedの代わりになり、より軽量。処理済みとして扱うならtrueを返す。 ↩ - [3]
constにすると値がコンパイル時に確定するため、bool.fromEnvironmentによる分岐は未使用側のコードごと削除できる(ツリーシェイキング)。非constの呼び出しは動作が保証されず、多くの AOT ビルドでは compiler option を実行時に参照できないため既定値が返る。必ずconst文脈で使う。 ↩