Flutter Navigator 2.0 宣言的ルーティングの実装

来のFlutter開発、いわゆるNavigator 1.0におけるNavigator.push()pop()を用いた命令的な画面遷移は、小規模なアプリにおいては直感的で高速な実装が可能でした。しかし、Web対応(URLとの同期)や複雑なディープリンク、ネストされたナビゲーション要件が増加するにつれ、命令的アプローチは限界を迎えます。画面スタックとアプリケーションの状態(State)が乖離し、メンテナンス性が著しく低下する「状態の不整合」が頻発するためです。

本稿では、FlutterのRouter API(通称Navigator 2.0)を採用し、アプリケーションの状態を「唯一の信頼できる情報源(Single Source of Truth)」として扱う宣言的ルーティングのアーキテクチャと実装戦略について解説します。

1. 命令的APIの限界とRouter APIの設計思想

Navigator 1.0の最大の問題点は、ナビゲーションロジックがUIイベント(ボタンのタップなど)に分散することです。これにより、以下の技術的負債が発生します。

  • 状態の非同期: ブラウザの「戻る」ボタンやAndroidのバックキー操作は、アプリ内のビジネスロジックを経由せずにスタックを変更する場合があり、アプリ内部の状態変数と実際の画面スタックが一致しなくなります。
  • ディープリンクの複雑性: 特定のネストされた画面(例: /home/products/detail/123)を初期表示する場合、命令的APIでは初期化時にpushを連鎖させる必要があり、コードが複雑化します。

宣言的アプローチ(Navigator 2.0)への転換

Navigator 2.0は、「画面スタックはアプリケーション状態の関数である」というFlutterのUI構築思想をナビゲーションにも適用します。

Architecture Note: Navigator 2.0における画面描画は Pages = f(State) で表されます。状態が変化すれば、フレームワークが自動的に適切なページリストを再構築(Rebuild)します。

このアーキテクチャは主に以下のコンポーネントで構成されます。

コンポーネント 役割 主な責務
RouterDelegate ランタイム制御 現在の状態に基づいてNavigatorウィジェットをビルドし、OSイベント(戻るボタン)を処理する。
RouteInformationParser データ変換 OSからのルート情報(URL)とアプリ内の状態オブジェクトの相互変換を行う。
Page 構成設定 Routeを生成するための不変(Immutable)な設定オブジェクト。

2. 実装および最適化戦略

具体的な実装パターンとして、書籍リストから詳細画面へ遷移するフローを構築します。ここではボイラープレートを最小限に抑えつつ、拡張性を維持した設計を示します。

Step 1: 状態定義(Typed Path)

URL文字列を直接扱うのではなく、型安全なクラスとしてルート状態を定義します。


abstract class AppRoutePath {}

class HomePath extends AppRoutePath {}

class DetailsPath extends AppRoutePath {
final int id;
DetailsPath(this.id);
}

class UnknownPath extends AppRoutePath {}

Step 2: RouteInformationParserの実装

URLと状態オブジェクトの双方向変換(Parsing / Restoring)を担当します。これにより、WebのURL直打ちやブラウザバックが正常に動作します。


import 'package:flutter/material.dart';

class MyRouteInformationParser extends RouteInformationParser<AppRoutePath> {

// URL -> State
@override
Future<AppRoutePath> parseRouteInformation(
RouteInformation routeInformation) async {
final uri = Uri.parse(routeInformation.location ?? '/');

// パスセグメント解析のロジック
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'book') {
final id = int.tryParse(uri.pathSegments[1]);
if (id != null) return DetailsPath(id);
}

if (uri.pathSegments.isEmpty) return HomePath();

return UnknownPath();
}

// State -> URL
@override
RouteInformation? restoreRouteInformation(AppRoutePath configuration) {
if (configuration is HomePath) return const RouteInformation(location: '/');
if (configuration is DetailsPath) {
return RouteInformation(location: '/book/${configuration.id}');
}
return const RouteInformation(location: '/404');
}
}

Step 3: RouterDelegateの実装

ここがナビゲーションの中核です。状態の変化を検知し、Navigatorウィジェットのpagesリストを再構築します。


class MyRouterDelegate extends RouterDelegate<AppRoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {

@override
final GlobalKey<NavigatorState> navigatorKey;

// アプリケーションの状態保持
int? _selectedBookId;
bool _show404 = false;

MyRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

// 現在のパス状態を返す(ブラウザのURL更新に使用)
@override
AppRoutePath get currentConfiguration {
if (_show404) return UnknownPath();
if (_selectedBookId != null) return DetailsPath(_selectedBookId!);
return HomePath();
}

// 外部(ParserやOS)からの新しいパス反映
@override
Future<void> setNewRoutePath(AppRoutePath configuration) async {
if (configuration is UnknownPath) {
_selectedBookId = null;
_show404 = true;
return;
}

if (configuration is DetailsPath) {
_selectedBookId = configuration.id;
} else {
_selectedBookId = null;
}
_show404 = false;
}

@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
// 状態に基づいたページスタックの宣言的定義
pages: [
const MaterialPage(
key: ValueKey('HomePage'),
child: HomeScreen(),
),
if (_show404)
const MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
else if (_selectedBookId != null)
MaterialPage(
key: ValueKey('BookDetailsPage'),
child: DetailsScreen(id: _selectedBookId!),
),
],
// 戻る操作(Pop)のハンドリング
onPopPage: (route, result) {
if (!route.didPop(result)) return false;

// スタックが減る際のロジック(詳細 -> ホーム)
if (_selectedBookId != null) {
_selectedBookId = null;
notifyListeners(); // 状態更新を通知
}
return true;
},
);
}
}
Implementation Warning: RouterDelegate内でのnotifyListeners()呼び出しは慎重に行ってください。不要なリビルドはパフォーマンス低下を招きます。また、pagesリスト内の各Pageには一意のLocalKeyValueKeyなど)を付与し、フレームワークが差分更新を正しく認識できるようにする必要があります。

3. 現在のページ情報の特定と活用

「現在どのページにいるか」を特定する場合、Navigator 1.0ではModalRoute.of(context)を使用していましたが、Navigator 2.0ではより構造化されたアプローチが可能です。

RouterDelegateが保持する状態こそが真実(Source of Truth)であるため、UI側からはDelegateの状態を参照することで現在のコンテキストを把握します。


// 実際の運用ではProviderやRiverpod経由でDelegateの状態を参照することを推奨
void checkCurrentLocation(MyRouterDelegate delegate) {
final config = delegate.currentConfiguration;

if (config is DetailsPath) {
// 現在は詳細画面(ID: config.id)にいる
print('Current ID: ${config.id}');
}
}

Navigator 2.0導入のトレードオフ

宣言的ルーティングは強力ですが、すべてのケースで最適解とは限りません。

評価項目 Navigator 1.0 Navigator 2.0
実装コスト 低(APIを呼ぶだけ) 高(Delegate, Parserの実装が必要)
Web/Deep Link 困難 完全対応
状態管理 UIと疎結合(不整合リスクあり) UIと密結合(一貫性保証)
テスト容易性 低い 高い(状態ベースでテスト可能)

結論: ラッパーライブラリの検討とアーキテクチャの選択

Navigator 2.0のAPIは冗長であり、小規模なアプリでフルスクラッチ実装することは生産性を阻害する可能性があります。実務においては、Navigator 2.0の仕組みを内部的に使用しつつ、APIを簡略化したgo_routerなどのパッケージ利用が標準的になりつつあります。

しかし、複雑な認証フローや独自のネスト構造を持つナビゲーションを制御する場合、ラップされたライブラリでは対応できないケースが存在します。その際、基盤となるRouter APIの理解と、本稿で解説したRouterDelegateの直接制御が必要となります。プロジェクトの要件(特にWeb対応の優先度とルーティングの複雑度)に応じて、生のNavigator 2.0を使用するか、ライブラリを採用するかを判断してください。

Post a Comment