Friday, July 14, 2023

Flutterにおけるナビゲーションの進化: Navigator 2.0の設計思想

Flutterアプリケーション開発において、画面遷移、すなわちナビゲーションはユーザー体験の根幹をなす要素です。初期のFlutterでは、Navigator.push()Navigator.pop()といった命令的なAPI(後にNavigator 1.0と呼ばれる)が主流でした。これは直感的でシンプルなアプリケーションには十分でしたが、アプリケーションが複雑化するにつれて、いくつかの本質的な課題が浮き彫りになりました。特に、WebアプリケーションにおけるURLとの同期、OSレベルでのディープリンク対応、そして何よりもアプリケーションの状態とナビゲーションスタックの一貫性を保つことが困難でした。

これらの課題に応えるべく、Flutterチームはナビゲーションに対する考え方を根本から見直し、宣言的なアプローチを採用した新しいAPI、Navigator 2.0を導入しました。これは単なるAPIの追加ではなく、ナビゲーションをアプリケーションの状態から一元的に導き出すという、パラダイムシフトを意味します。本稿では、Navigator 2.0が解決しようとした課題から説き起こし、その中核をなす設計思想とコンポーネント群を深く掘り下げ、実践的な実装パターンを通じて、この強力なシステムの全体像を解き明かしていきます。

パラダイムシフト: 命令型から宣言型へ

Navigator 2.0を理解する上で最も重要な概念は、「命令型(Imperative)」から「宣言型(Declarative)」への移行です。この違いを理解することが、新しいAPIを使いこなすための第一歩となります。

Navigator 1.0: 命令型の世界

従来のNavigator 1.0は、命令型のアプローチを取ります。これは「どのように(How)」画面を遷移させるかをコードで直接指示する方法です。


// 詳細画面に遷移する
Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => const DetailScreen()),
);

// 現在の画面を閉じて前の画面に戻る
Navigator.pop(context);

このコードは、「ボタンが押されたら、詳細画面をスタックに積め(push)」、「戻るボタンが押されたら、一番上の画面をスタックから取り除け(pop)」という具体的な命令です。これは、まるでタクシーの運転手に「次の角を右に曲がって、その次の信号を左」と一つずつ指示を出すようなものです。シンプルで分かりやすい反面、以下のような問題点を抱えていました。

  • 状態との乖離: ナビゲーションスタックはUIの内部で独立して管理され、アプリケーション全体のビジネスロジックや状態と直接結びついていません。そのため、状態の変更に応じてナビゲーションを更新したり、逆にナビゲーションの状態からアプリの状態を復元したりすることが困難でした。
  • Webとディープリンクの課題: ブラウザのURLバーやOSからのディープリンクは、アプリケーションのあるべき「状態」を示しています。例えば、/books/123というURLは「IDが123の本の詳細ページが表示されている状態」を意味します。しかし、命令型APIでは、このURLから「どのpushを何回実行すればその状態になるか」を逆算する必要があり、実装が非常に煩雑になります。ブラウザの「戻る」「進む」ボタンとの同期も同様に困難です。
  • テストの複雑性: ナビゲーションのテストを行うには、実際にNavigator.pushが呼ばれたかをモックするなど、UIの動作に密結合したテストが必要になりがちでした。

Navigator 2.0: 宣言型の世界

対してNavigator 2.0は、宣言型のアプローチを取ります。これは「どのような(What)」画面スタックであるべきかを定義する方法です。


// アプリケーションの状態に基づいて表示すべきページのリストを宣言する
Navigator(
  pages: [
    MaterialPage(key: ValueKey('HomePage'), child: HomeScreen()),
    if (appState.selectedBookId != null)
      MaterialPage(key: ValueKey(appState.selectedBookId), child: BookDetailsScreen(bookId: appState.selectedBookId!)),
  ],
  onPopPage: (route, result) {
    // ...
  },
)

このコードは、「もし選択された本のIDが存在するなら、ホームページの上に詳細ページを重ねて表示する。そうでなければ、ホームページのみを表示する」という、あるべき状態を宣言しています。これは、タクシーの運転手に行き先の住所(最終的な状態)だけを伝えるようなものです。そこに至るまでの具体的な道順(pushやpopの実行)は、Flutterフレームワークが状態の変化を検知して自動的に計算し、最適なアニメーションで画面遷移を実行してくれます。

このアプローチがもたらす利点は、Navigator 1.0の課題を直接的に解決します。

  • 状態との完全な同期: ナビゲーションスタックはアプリケーションの状態(例: appState)から一意に決定されます。状態を変更するだけで、ナビゲーションは自動的に更新されます。これは、Flutterの「UI = f(state)」という基本理念をナビゲーションにも拡張したものです。
  • Webとディープリンクへのネイティブ対応: URL(例: /books/123)をアプリケーションの状態(例: appState.selectedBookId = 123)に変換し、その状態からページのリストを宣言するだけで、正しい画面スタックを一度に構築できます。逆もまた然りで、状態からURLを生成することも容易です。
  • テストの容易性: ナビゲーションのロジックは、状態を操作し、結果として得られるページのリストを検証するだけでテストできます。UIの動作に依存しない、純粋なロジックのテストが可能です。

Navigator 2.0を構成する中核コンポーネント

この宣言的なナビゲーションを実現するために、Navigator 2.0はいくつかの新しいクラスと概念を導入しました。これらは互いに連携し、OS(ブラウザを含む)とアプリケーションの間の情報の橋渡しを行い、状態に基づいたナビゲーションスタックを構築します。最初は複雑に見えるかもしれませんが、それぞれの役割を理解すれば、全体の流れが見えてきます。

データの流れは、基本的には以下のようになります。
OS/ブラウザ (URL) → RouteInformationProviderRouteInformationParserRouterDelegateRouterNavigator (UI)

それぞれの役割を詳しく見ていきましょう。

1. Page

Pageは、Navigator 2.0における最も基本的な構成要素です。これはUIを直接表すWidgetとは異なり、ナビゲーションスタック内の一つの画面(Route)を生成するための設定オブジェクトと考えることができます。Pageは不変(immutable)であり、それ自体がアニメーションや画面の表示方法に関する情報を持っています。

MaterialPageCupertinoPageが一般的に使用され、これらは内部でMaterialPageRouteCupertinoPageRouteを生成します。重要なのは、各Pageに一意のkeyを設定することです。Flutterは、このkeyを使ってページのリストが変更された際に、どのページが追加され、削除され、あるいは順序が変わったのかを効率的に判断し、適切な遷移アニメーションを適用します。

2. RouterDelegate<T>

RouterDelegateは、Navigator 2.0の心臓部です。その主な責務は以下の通りです。

  • アプリケーションの状態を保持または監視する: ナビゲーションに必要な状態(例: 選択されたアイテムのID、ログイン状態など)を管理します。
  • 状態に基づいてNavigatorウィジェットを構築する: 現在の状態に応じて、Pageのリストを動的に生成し、それをNavigatorウィジェットのpagesプロパティに渡します。
  • 状態の変更をフレームワークに通知する: 状態が変更された際にnotifyListeners()を呼び出すことで(通常はChangeNotifierをmixinして実装)、フレームワークに再ビルドを促し、ナビゲーションスタックを更新させます。
  • 新しいルート情報を処理する: RouteInformationParserから渡された新しいルート情報(アプリケーションの状態を表すオブジェクト)を受け取り、内部の状態を更新します(setNewRoutePathメソッド)。

開発者が最も多くのロジックを記述するのが、このRouterDelegateです。

3. RouteInformationParser<T>

RouteInformationParserは、OSからのルート情報と、RouterDelegateが理解できるアプリケーション固有のデータ型との間の翻訳者の役割を担います。

具体的には、2つの主要なメソッドを実装します。

  • Future<T> parseRouteInformation(RouteInformation routeInformation): OSから受け取ったRouteInformation(主にURLの文字列を含む)を解析し、RouterDelegateが扱うデータ型T(例えば、パスやクエリパラメータを保持するカスタムクラス)に変換します。例えば、/books/123というURLをBookRoutePath(id: 123)のようなオブジェクトに変換します。
  • RouteInformation? restoreRouteInformation(T configuration): RouterDelegateが持つ現在の設定(データ型Tのオブジェクト)を、OSが理解できるRouteInformation(URL文字列)に逆変換します。これにより、Webブラウザのアドレスバーがアプリケーションの状態と同期されます。

4. Router

Routerウィジェットは、これらすべてのコンポーネントを統合し、実際にナビゲーションを機能させるための配線役です。MaterialApp.routerコンストラクタを使用すると、このRouterウィジェットが内部で自動的に設定されます。

Routerは、RouterDelegateRouteInformationParserを引数に取り、以下の処理を行います。

  • OSからの新しいルート通知をリッスンし、RouteInformationParserに渡して解析させます。
  • 解析された結果をRouterDelegatesetNewRoutePathに渡します。
  • RouterDelegateが状態変更を通知(notifyListeners)すると、そのbuildメソッドを呼び出して新しいNavigatorウィジェットを取得し、画面に表示します。

5. RouteInformationProvider

このコンポーネントは、OSからのルート情報の供給源です。通常、開発者が直接これを実装することは稀で、Flutterが提供するPlatformRouteInformationProviderが自動的に使用されます。これは、Webではブラウザのアドレスバーの変更を、モバイルアプリではOSからのディープリンクやナビゲーションイベントをリッスンし、RouteInformationオブジェクトとしてRouterに通知します。

これらのコンポーネントが連携することで、URLからアプリケーションの状態へ、そして状態からUIへと、一方向のクリーンなデータフローが構築され、宣言的なナビゲーションが実現されるのです。

実装への道筋: 書籍リストアプリケーションの構築

理論だけでは掴みどころがないため、具体的なアプリケーションを例に、Navigator 2.0の実装プロセスを追ってみましょう。ここでは、書籍のリストを表示し、リスト項目をタップするとその書籍の詳細ページに遷移する、というシンプルなアプリを作成します。このアプリは以下のURLに対応します。

  • /: 書籍リストページ
  • /book/{id}: 特定のIDの書籍詳細ページ
  • /unknown: 不正なURLの場合の404ページ

ステップ1: アプリケーションの状態とルートパスの定義

まず、ナビゲーションの状態を表すためのデータ構造を定義します。URLを解析した結果を格納するクラス(ルートパス)を作成するのが一般的です。


// abstract classで共通の型を定義
abstract class AppRoutePath {}

// ホーム(書籍リスト)を表すパス
class HomePath extends AppRoutePath {}

// 書籍詳細を表すパス
class BookDetailsPath extends AppRoutePath {
  final int id;
  BookDetailsPath(this.id);
}

// 不明なパス
class UnknownPath extends AppRoutePath {}

ステップ2: `RouteInformationParser`の実装

次に、URL文字列と上で定義したAppRoutePathを相互に変換するパーサーを実装します。


class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
  
  // 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 id = int.tryParse(uri.pathSegments[1]);
      if (id != null) {
        return BookDetailsPath(id);
      }
    }
    
    // それ以外はすべてUnknownPathとして処理
    return UnknownPath();
  }

  // AppRoutePathをURLに変換
  @override
  RouteInformation? restoreRouteInformation(AppRoutePath path) {
    if (path is HomePath) {
      return RouteInformation(location: '/');
    }
    if (path is BookDetailsPath) {
      return RouteInformation(location: '/book/${path.id}');
    }
    if (path is UnknownPath) {
      return RouteInformation(location: '/unknown');
    }
    return null;
  }
}

ステップ3: `RouterDelegate`の実装

心臓部であるRouterDelegateを実装します。ここでは、現在のナビゲーション状態(選択された本のIDなど)を管理し、それに基づいてページのリストを構築します。


class AppRouterDelegate extends RouterDelegate<AppRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {
      
  // このMixinは、Navigatorのキーをグローバルに管理するために必要
  @override
  final GlobalKey<NavigatorState> navigatorKey;

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

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

  // 現在の状態をAppRoutePathに変換
  @override
  AppRoutePath get currentConfiguration {
    if (_isUnknown) {
      return UnknownPath();
    }
    if (_selectedBookId != null) {
      return BookDetailsPath(_selectedBookId!);
    }
    return HomePath();
  }
  
  // Navigatorを構築
  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        // ベースとなるホームページは常に表示
        MaterialPage(
          key: ValueKey('BookListPage'),
          child: BookListScreen(
            onBookTapped: (id) {
              // 状態を更新して再ビルドを通知
              _selectedBookId = id;
              notifyListeners();
            },
          ),
        ),
        // 不明なパスの場合
        if (_isUnknown)
          MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen()),
        // 本が選択されている場合
        else if (_selectedBookId != null)
          MaterialPage(
            key: ValueKey('BookDetailsPage_$_selectedBookId'),
            child: BookDetailsScreen(bookId: _selectedBookId!),
          )
      ],
      onPopPage: (route, result) {
        // ページがpopされたときの処理(Androidの戻るボタンなど)
        if (!route.didPop(result)) {
          return false;
        }

        // 詳細ページから戻る場合は、選択状態を解除
        if (_selectedBookId != null) {
          _selectedBookId = null;
        }
        _isUnknown = false;
        notifyListeners();
        return true;
      },
    );
  }

  // パーサーからの新しいパスを処理
  @override
  Future<void> setNewRoutePath(AppRoutePath path) async {
    if (path is HomePath) {
      _selectedBookId = null;
      _isUnknown = false;
    } else if (path is BookDetailsPath) {
      _selectedBookId = path.id;
      _isUnknown = false;
    } else if (path is UnknownPath) {
      _selectedBookId = null;
      _isUnknown = true;
    }
    // ここではnotifyListeners()は不要。フレームワークが自動でbuildを呼び出す
  }
}

// 画面のダミークラス
class BookListScreen extends StatelessWidget {
  final ValueChanged<int> onBookTapped;
  const BookListScreen({Key? key, required this.onBookTapped}) : super(key: key);
  @override
  Widget build(BuildContext context) { /* ... 書籍リストUI ... */ return Container(); }
}
class BookDetailsScreen extends StatelessWidget {
  final int bookId;
  const BookDetailsScreen({Key? key, required this.bookId}) : super(key: key);
  @override
  Widget build(BuildContext context) { /* ... 書籍詳細UI ... */ return Container(); }
}
class UnknownScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) { /* ... 404 UI ... */ return Container(); }
}

このRouterDelegatebuildメソッド内にあるpagesリストの定義が、宣言的ナビゲーションの核心です。アプリケーションの状態(_selectedBookId, _isUnknown)が変わるたびに、このリストが再評価され、Flutterが差分を計算して画面を更新します。

ステップ4: `MaterialApp.router`での統合

最後に、作成したコンポーネントをMaterialApp.routerコンストラクタに渡してアプリケーションを起動します。


void main() {
  // WebでPathベースのURLを使用するための設定
  // import 'package:flutter_web_plugins/flutter_web_plugins.dart';
  // setUrlStrategy(PathUrlStrategy());
  runApp(MyApp());
}

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

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

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

これで、URLの変更、UIイベント(タップ)、OSの戻るボタン操作のすべてが、RouterDelegate内の状態を介して一元管理され、ナビゲーションスタックと完全に同期するアプリケーションが完成しました。

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

上記の例では、RouterDelegate自身がナビゲーションの状態を保持していました。しかし、アプリケーションが大規模になると、ナビゲーションの状態もアプリケーション全体のビジネスロジックの一部となります。そのため、Provider, Riverpod, BLoCなどの状態管理ライブラリと統合することが推奨されます。

例えばRiverpodを使用する場合、RouterDelegateは状態を直接保持せず、ConsumerWidget(またはConsumerStatefulWidget)のようにプロバイダをリッスンする形になります。


// 状態管理 (Riverpod)
final selectedBookIdProvider = StateProvider<int?>((ref) => null);

// RouterDelegateは状態を持たず、リッスンに徹する
class AppRouterDelegate extends RouterDelegate<...> with ... {
  final WidgetRef ref;
  AppRouterDelegate(this.ref) : navigatorKey = GlobalKey() {
    // 状態の変更をリッスンして、フレームワークに通知する
    ref.listen(selectedBookIdProvider, (_, __) => notifyListeners());
  }

  // ...

  @override
  Widget build(BuildContext context) {
    // プロバイダから現在の状態を読み取る
    final selectedBookId = ref.watch(selectedBookIdProvider);

    return Navigator(
      pages: [
        MaterialPage(
          child: BookListScreen(onBookTapped: (id) {
            // UIイベントではプロバイダの状態を更新する
            ref.read(selectedBookIdProvider.notifier).state = id;
          }),
        ),
        if (selectedBookId != null)
          MaterialPage(child: BookDetailsScreen(bookId: selectedBookId)),
      ],
      onPopPage: (route, result) {
        // ...
        ref.read(selectedBookIdProvider.notifier).state = null;
        // ...
      },
    );
  }

  // setNewRoutePathでは、プロバイダの状態を更新する
  @override
  Future<void> setNewRoutePath(AppRoutePath path) async {
    // ...
    ref.read(selectedBookIdProvider.notifier).state = (path as BookDetailsPath).id;
  }
}

このように責務を分離することで、RouterDelegateは純粋に状態とUIの間の変換層となり、状態管理のロジックは状態管理ライブラリの作法に則って一元化され、よりクリーンでスケーラブルな設計が可能になります。

Navigator 1.0との比較と使い分け

Navigator 2.0の強力さを解説してきましたが、これはすべてのアプリケーションにとっての銀の弾丸ではありません。学習コストや実装の複雑さを考慮すると、適切な場面で使い分けることが重要です。

特徴 Navigator 1.0 (命令型) Navigator 2.0 (宣言型)
パラダイム 命令型 (Imperative) 宣言型 (Declarative)
主なAPI Navigator.push(), .pop(), .pushNamed() Router, RouterDelegate, RouteInformationParser
学習コスト 低い 高い
コード量 少ない(シンプルな場合) 多い(ボイラープレートが必要)
Web/ディープリンク対応 困難または手動での複雑な実装が必要 ネイティブにサポート
状態管理との親和性 低い(ナビゲーションスタックが独立) 非常に高い(状態がナビゲーションを駆動)
適したユースケース ・モバイル専用の小規模アプリ
・直線的な画面遷移のみのアプリ
・プロトタイピング
・Web対応が必要なアプリ
・ディープリンク対応が必要なアプリ
・複雑な状態に依存する画面遷移(タブ、ダイアログなど)
・大規模でスケーラブルなアプリ

Navigator 2.0を簡略化するパッケージ

Navigator 2.0のボイラープレートの多さはコミュニティでも認識されており、その複雑さをラップして、より直感的なAPIを提供するパッケージが数多く開発されています。中でもgo_routerはFlutterチームによって公式にサポートされており、現在では最も推奨される選択肢の一つです。

go_routerは、URLベースのルーティング設定をシンプルに記述するだけで、内部的にNavigator 2.0のコンポーネントを自動生成してくれます。これにより、開発者はNavigator 2.0の恩恵を享受しつつ、その実装の複雑さから解放されます。


// go_routerを使ったルーティング設定の例
final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => BookListScreen(),
    ),
    GoRoute(
      path: '/book/:id',
      builder: (context, state) {
        final id = int.parse(state.params['id']!);
        return BookDetailsScreen(bookId: id);
      },
    ),
  ],
);

// MaterialApp.routerに設定
MaterialApp.router(
  routerConfig: _router,
);

これから新しいプロジェクトを始める場合、特にWebやディープリンクが要件に含まれるのであれば、素のNavigator 2.0から学習を始めるよりも、go_routerのようなパッケージの利用をまず検討するのが効率的かもしれません。

まとめ

Navigator 2.0は、Flutterにおけるナビゲーションを、単なる画面遷移の仕組みから、アプリケーションの状態をUIとして表現するための一貫したシステムへと昇華させました。命令的な操作から脱却し、状態に基づいてナビゲーションスタックを宣言的に構築することで、状態とUIの同期、プラットフォーム連携(Web URLやディープリンク)、そしてテストの容易性といった、現代的なアプリケーションに不可欠な要件を見事に解決しています。

確かに、その学習曲線はNavigator 1.0に比べて急であり、導入には多くのボイラープレートコードを必要とします。しかし、その設計思想の根底にある「UI = f(state)」という原則を理解すれば、一見複雑に見えるコンポーネント群が、いかにして堅牢で予測可能なナビゲーションを実現しているかが見えてくるはずです。そして、go_routerのような優れた抽象化ライブラリの存在により、その強力な基盤を、より少ない労力で活用できる道も開かれています。

Navigator 2.0を使いこなすことは、単に新しいAPIを学ぶこと以上の意味を持ちます。それは、Flutterの宣言的な思想をより深く理解し、複雑さに負けないスケーラブルなアプリケーションを設計する能力を身につけることにつながるのです。


0 개의 댓글:

Post a Comment