Friday, July 14, 2023

Flutter Navigator 2.0 徹底解説:宣言的ルーティングの核心

Flutterにおけるナビゲーションは、アプリケーションのユーザーエクスペリエンスを決定づける中心的な要素です。初期のFlutterでは、Navigator.push()Navigator.pop()といった命令的なAPI、通称「Navigator 1.0」が主流でした。このアプローチはシンプルで直感的でしたが、アプリケーションが複雑化するにつれて、特にWeb対応やディープリンク、ネストされたナビゲーションといった高度な要件を満たす上で限界が見え始めました。

これらの課題を解決するために登場したのが、宣言的なアプローチを採用した「Navigator 2.0」です。Navigator 2.0は、アプリケーションの状態(State)を唯一の信頼できる情報源(Single Source of Truth)とし、その状態に基づいてナビゲーションスタックを動的に構築します。これは単なるAPIのアップデートではなく、Flutterにおける画面遷移の考え方を根本から変えるパラダイムシフトです。本記事では、Navigator 2.0がなぜ必要なのかという背景から、その核心をなすコンポーネント、そして具体的な実装方法までを深く掘り下げ、現在のページ情報をどのように特定し、活用するのかを解説します。

なぜNavigator 2.0が必要だったのか? 命令型から宣言型への移行

Navigator 2.0の真価を理解するためには、まずNavigator 1.0が抱えていた課題を明確にする必要があります。

Navigator 1.0の限界

Navigator 1.0は、pushpopといったメソッドを直接呼び出すことで、画面スタックを積み上げたり、取り除いたりする命令型(Imperative)のアプローチです。これは「Aボタンが押されたら、詳細画面に遷移せよ」というように、具体的な操作をコードで指示するスタイルです。

  • 状態とUIの不一致: アプリケーションのロジックが複雑になると、現在の画面スタックとアプリケーション内部の状態が乖離しやすくなります。例えば、認証状態が変わったときに、ログイン画面からホーム画面へ強制的に遷移させたり、スタックをすべてクリアしたりする処理は煩雑になりがちでした。
  • Web対応の困難さ: WebブラウザのURLは、アプリケーションの状態を表現する重要な要素です。しかし、命令型のpush/popでは、ブラウザの「戻る」「進む」ボタンの挙動や、URLと画面スタックを同期させることが非常に困難でした。特定のURLを直接入力してアプリの特定のページにアクセスする「ディープリンク」の実装も一筋縄ではいきませんでした。
  • テストの複雑性: ナビゲーションのロジックがUIのイベントハンドラ内に散在するため、特定のナビゲーション状態を再現してテストすることが難しくなります。

宣言型アプローチの登場

Navigator 2.0は、これらの問題を宣言型(Declarative)のアプローチで解決します。宣言型プログラミングの考え方は、FlutterのUI構築そのもの(ウィジェットは現在のStateの関数である)と一致しています。

「現在のアプリケーションの状態が『ログイン済み』で『商品ID: 123の詳細を表示中』であるならば、ナビゲーションスタックは[ホームページ、商品詳細ページ]になるべきだ」

このように、「何をすべきか(How)」ではなく、「どうあるべきか(What)」を記述するのが宣言型ナビゲーションです。アプリケーションの状態が変化すると、Flutterフレームワークはそれに応じてナビゲーションスタック全体を再構築します。これにより、URL、アプリケーションの状態、そして表示されているUIが常に一貫性を保つことが可能になります。

Navigator 2.0の核心をなすコンポーネント群

Navigator 2.0は単一のクラスではなく、連携して動作する複数のコンポーネントから構成されています。これらの役割を理解することが、宣言的ルーティングをマスターする鍵となります。

Navigator 2.0 Component Diagram

Navigator 2.0の主要コンポーネント間のデータの流れ

  1. Page:

    これはナビゲーションスタックの個々のエントリーを表す設定オブジェクトです。Widgetそのものではなく、Widgetをどのようにルートとして表示するかの情報(キーや引数など)を保持します。MaterialPageCupertinoPageが一般的に使用され、プラットフォームに応じた画面遷移アニメーションを提供します。

  2. Router:

    アプリケーションのウィジェットツリーに配置され、後述するRouterDelegateRouteInformationParserをシステムに接続する役割を持つウィジェットです。通常、MaterialApp.router()コンストラクタを使用することで暗黙的にセットアップされます。

  3. RouteInformationParser:

    OS(WebブラウザのURLやモバイルOSからのディープリンク)からのルート情報(RouteInformation)と、アプリケーション内部で使われるデータ型(通常はカスタムのパス情報クラス)との間で双方向の変換を行う「翻訳者」です。

    • parseRouteInformation: RouteInformation(例: location: '/book/123')を受け取り、アプリの状態オブジェクト(例: BookDetailsPath(id: '123'))に変換します。
    • restoreRouteInformation: アプリの状態オブジェクトを受け取り、それをRouteInformationに変換してOSに伝えます。これにより、ブラウザのURLが更新されます。
  4. RouterDelegate:

    Navigator 2.0の「頭脳」であり、最も重要なコンポーネントです。その責務は多岐にわたります。

    • 状態管理: アプリケーションのナビゲーション状態を保持し、変更をリッスンします。(例: ユーザーがどのページを見ているか、どのアイテムが選択されているか)。
    • Navigatorの構築: 現在のナビゲーション状態に基づいて、Navigatorウィジェットとその画面スタック(List<Page>)を構築(build)します。
    • 状態の更新: RouteInformationParserから新しい状態を受け取ったとき(setNewRoutePath)、内部の状態を更新します。
    • OSへの通知: 内部の状態が変化したとき、フレームワークに通知(notifyListeners)し、RouteInformationParserを介してURLの更新をトリガーします。
    • OSからのイベント処理: OSの「戻る」ボタンが押された際の処理(popRoute)をハンドリングします。
  5. RouteInformationProvider:

    OSからの新しいルート情報をアプリケーションに提供する役割を担います。通常はフレームワークによって自動的に提供されるため、直接意識することは少ないですが、このコンポーネントが存在することでURLの変更が検知されます。

これらのコンポーネントが連携することで、URL、アプリケーション状態、UIが一貫して同期する、堅牢なナビゲーションシステムが実現します。

実践:Navigator 2.0によるナビゲーションの実装

概念を理解したところで、次は具体的なコードを見ていきましょう。ここでは、書籍のリストを表示し、リストの項目をタップするとその書籍の詳細ページに遷移する、というシンプルなマスターディテール型のアプリケーションを構築します。

ステップ1: アプリケーションのナビゲーション状態を定義する

まず、URLと1対1で対応するナビゲーション状態を表現するクラスを定義します。これは単純なenumよりも、パラメータを持てるクラスとして定義する方が拡張性が高く、明瞭です。


// app_route_path.dart

// ナビゲーションの状態を表現する不変クラス群
abstract class AppRoutePath {}

// ホーム画面(書籍リスト)の状態
class HomePath extends AppRoutePath {}

// 書籍詳細画面の状態
class DetailPath extends AppRoutePath {
  final int id;
  DetailPath(this.id);
}

// 404 Not Found ページの状態
class UnknownPath extends AppRoutePath {}

この設計により、/HomePath/book/1DetailPath(1)、そして未知のURLはUnknownPathといったように、URLとアプリケーションの状態を明確に対応付けできます。

ステップ2: RouteInformationParserを実装する

次に、「翻訳者」であるRouteInformationParserを実装します。URLの文字列を先ほど定義したAppRoutePathオブジェクトに変換し、その逆も行います。


// app_route_information_parser.dart

import 'package:flutter/material.dart';
import 'app_route_path.dart';

class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
  
  // OSから新しいルート情報が来たときに呼ばれる (例: URL直接入力)
  // URLの文字列を解析して、AppRoutePathオブジェクトを返す
  @override
  Future<AppRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.location ?? '/');

    // Handle '/'
    if (uri.pathSegments.isEmpty) {
      return HomePath();
    }

    // Handle '/book/:id'
    if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'book') {
      final idString = uri.pathSegments[1];
      final id = int.tryParse(idString);
      if (id != null) {
        return DetailPath(id);
      }
    }

    // 上記のいずれにも一致しない場合はUnknownPathを返す
    return UnknownPath();
  }

  // RouterDelegateの状態が変化したときに呼ばれる
  // AppRoutePathオブジェクトからURL文字列を復元する
  @override
  RouteInformation? restoreRouteInformation(AppRoutePath configuration) {
    if (configuration is HomePath) {
      return RouteInformation(location: '/');
    }
    if (configuration is DetailPath) {
      return RouteInformation(location: '/book/${configuration.id}');
    }
    if (configuration is UnknownPath) {
      return RouteInformation(location: '/404');
    }
    // nullを返すとURLは更新されない
    return null;
  }
}

このクラスでは、Uri.parseを使ってURLをセグメントに分割し、その構造に基づいて適切なAppRoutePathインスタンスを生成しています。restoreRouteInformationではその逆の処理を行い、アプリケーションの状態をブラウザのURLに反映させます。

ステップ3: RouterDelegateを実装する

いよいよ「頭脳」部分であるRouterDelegateの実装です。このクラスが、現在の状態を保持し、状態に基づいて画面スタックを構築します。


// app_router_delegate.dart

import 'package:flutter/material.dart';
import 'app_route_path.dart';
// ... (HomePage, DetailPage, UnknownPageのウィジェットをインポート)

class AppRouterDelegate extends RouterDelegate<AppRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {
  
  // Navigatorウィジェットに紐付けるためのグローバルキー
  @override
  final GlobalKey<NavigatorState> navigatorKey;

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

  // 現在のナビゲーション状態
  int? _selectedBookId;
  bool _is404 = false;

  // RouteInformationParserに現在の状態を伝えるゲッター
  @override
  AppRoutePath get currentConfiguration {
    if (_is404) {
      return UnknownPath();
    }
    if (_selectedBookId != null) {
      return DetailPath(_selectedBookId!);
    }
    return HomePath();
  }

  // アプリケーションの状態に基づいてNavigatorのスタックを構築する
  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        // ベースとなるホームページは常にスタックに存在する
        MaterialPage(
          key: ValueKey('HomePage'),
          child: HomePage(
            onBookTapped: (id) {
              // 書籍がタップされたら状態を更新
              _selectedBookId = id;
              notifyListeners(); // 変更をフレームワークに通知
            },
          ),
        ),
        // 404状態なら、404ページをスタックに追加
        if (_is404)
          MaterialPage(key: ValueKey('UnknownPage'), child: UnknownPage()),
        // 書籍が選択されているなら、詳細ページをスタックに追加
        else if (_selectedBookId != null)
          MaterialPage(
            key: ValueKey('DetailPage-$_selectedBookId'),
            child: DetailPage(bookId: _selectedBookId!),
          ),
      ],
      // ブラウザの戻るボタンや、AppBarのバックボタンが押された時の処理
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }

        // スタックの最上位ページがpopされるときに状態を更新する
        if (_selectedBookId != null) {
          _selectedBookId = null;
        }
        _is404 = false;
        notifyListeners(); // 変更を通知

        return true;
      },
    );
  }

  // Parserから新しいパスが渡されたときに呼ばれる
  @override
  Future<void> setNewRoutePath(AppRoutePath configuration) async {
    if (configuration is HomePath) {
      _selectedBookId = null;
      _is404 = false;
    } else if (configuration is DetailPath) {
      _selectedBookId = configuration.id;
      _is404 = false;
    } else if (configuration is UnknownPath) {
      _selectedBookId = null;
      _is404 = true;
    }
    // setNewRoutePath内ではnotifyListeners()は不要
  }
}

このコードのポイントはbuildメソッドです。ここでは、_selectedBookId_is404といった内部の状態変数に基づいて、条件分岐でpagesリストを構築しています。例えば、_selectedBookIdに値が入ると、HomePageの上にDetailPageが追加されたスタックが生成され、画面遷移が起こります。逆にonPopPage_selectedBookIdnullにすると、DetailPageがリストから消え、結果として「戻る」ナビゲーションが実現します。これが宣言的ナビゲーションの核心です。

ステップ4: MaterialApp.routerへ統合する

最後に、作成したコンポーネントをmain.dartで統合します。MaterialAppの代わりにMaterialApp.routerコンストラクタを使用します。


// main.dart

import 'package:flutter/material.dart';
import 'app_router_delegate.dart';
import 'app_route_information_parser.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final AppRouterDelegate _routerDelegate = AppRouterDelegate();
  final AppRouteInformationParser _routeInformationParser = AppRouteInformationParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Navigator 2.0 Example',
      routerDelegate: _routerDelegate,
      routeInformationParser: _routeInformationParser,
    );
  }
}

これで、Navigator 2.0に基づいたナビゲーションシステムが完成しました。ブラウザで//book/1/invalid-urlなどを試すと、URLと画面が正しく同期し、ブラウザの「戻る」「進む」ボタンも期待通りに動作することが確認できます。

現在のページ情報を特定・活用する方法

さて、本題である「現在のページを特定する方法」についてです。Navigator 2.0のアーキテクチャでは、この問いへの答えは非常に明確です。

「現在のページ」は、RouterDelegateが保持する「現在のナビゲーション状態」そのものです。

上記の例では、AppRouterDelegateクラス内の_selectedBookId_is404といった変数がそれに該当します。また、currentConfigurationゲッターは、その状態をAppRoutePathオブジェクトとして抽象化したものです。これを利用することで、アプリケーションのどこからでも現在のナビゲーション状態を正確に知ることができます。


// RouterDelegateのインスタンスを取得する
// (ProviderやRiverpod、InheritedWidgetなどを使ってウィジェットツリーの下層に渡すのが一般的)
final delegate = myAppRouterDelegate;

// currentConfigurationプロパティから現在のパス情報を取得
final currentPagePath = delegate.currentConfiguration;

// 型チェックで現在のページを判定
if (currentPagePath is HomePath) {
  print('現在はホームページです。');
} else if (currentPagePath is DetailPath) {
  print('現在は書籍ID ${currentPagePath.id} の詳細ページです。');
} else if (currentPagePath is UnknownPath) {
  print('ページが見つかりません(404)。');
}

このアプローチの利点は、単に「どのウィジェットが表示されているか」という表層的な情報だけでなく、「なぜそのウィジェットが表示されているのか」という文脈を含んだ状態を取得できる点にあります。例えば、DetailPageが表示されていることと同時に、その詳細ページの対象が書籍ID `123` であることまで分かります。これにより、状態に基づいた動的なUIの変更(例:選択中のアイテムをハイライトする)などが容易に実装できます。

高度なトピックとベストプラクティス

状態管理ライブラリとの連携

上記の例ではRouterDelegateが状態を直接持っていましたが、より大規模なアプリケーションでは、ナビゲーション状態をビジネスロジックの状態から分離し、ProviderやRiverpod、BLoCといった状態管理ライブラリで管理するのが一般的です。その場合、RouterDelegateは状態管理ライブラリの変更をリッスンし、状態が変化したらnotifyListeners()を呼び出してUIを再構築する、という役割に徹します。

ネストされたナビゲーション

Navigator 2.0は、ネストされた(入れ子構造の)ナビゲーションも得意としています。例えば、BottomNavigationBarを持つ画面で、各タブが独立したナビゲーションスタックを持つようなUIを構築できます。これは、メインのRouterとは別に、子のウィジェットツリー内に別のRouterウィジェットを配置することで実現します。それぞれのRouterが独自のRouterDelegateを持つことで、親子関係のある複雑なルーティングスコープを管理できます。

宣言的ナビゲーションの利点と考慮事項

  • 利点:
    • 堅牢性: 状態とUIが常に同期するため、予測可能で安定した動作を実現します。
    • テスト容易性: ナビゲーションロジックが状態の変更に集約されるため、ユニットテストやウィジェットテストが容易になります。
    • 完全なWeb/ディープリンク対応: URLとアプリ状態の双方向バインディングにより、Webプラットフォームの機能を最大限に活用できます。
  • 考慮事項:
    • 学習コスト: Navigator 1.0と比較して、複数のコンポーネントの役割を理解する必要があり、初期の学習コストは高めです。
    • ボイラープレート: シンプルなナビゲーションのためには、記述するコード量(ボイラープレート)が多くなりがちです。

このボイラープレート問題を解決するために、Navigator 2.0のAPIをよりシンプルに扱えるようにしたgo_routerなどのサードパーティパッケージも人気です。これらのパッケージは、内部でNavigator 2.0の仕組みを使いつつ、より直感的なAPIを提供します。しかし、複雑な要件に対応する場合や問題解決のためには、その背後にあるNavigator 2.0の基本原理を理解しておくことが非常に重要です。

まとめ

FlutterのNavigator 2.0は、単なるAPIの変更ではなく、アプリケーションのナビゲーションを状態中心で考えるという、より現代的で堅牢なアプローチへの移行を促すものです。RouteInformationParserRouterDelegateという二つの中心的なコンポーネントを実装することで、アプリケーションの状態、URL、そしてユーザーが見ている画面を完全に同期させることができます。

現在のページを特定するという行為は、このパラダイムにおいては、RouterDelegateが管理するナビゲーション状態を確認することと同義になります。最初は複雑に感じるかもしれませんが、この宣言的なアプローチを一度マスターすれば、ディープリンク、ブラウザ履歴の制御、認証フローといった複雑な要件にも自信を持って対応できるようになるでしょう。Navigator 2.0は、今日の高度なクロスプラットフォームアプリケーションに不可欠な、強力で柔軟なナビゲーション基盤を提供してくれるのです。


0 개의 댓글:

Post a Comment