Flutter Navigator 2.0 指南與原理解析

文/ 楊加康

Flutter 1.22 發布後,大家可以發現,官方對路由相關 API 的改動很大,設計文件中表示,由於傳統的指令式 API,如 Navigator.pop()Navigator.push() 等介面,並沒有給開發者一種靈活的方式去直接管理路由棧,甚至覺得已經過時了,一點也不 Flutter。

而 Navigator 2.0 引入了一套全新的宣告式 API,全新的實現方式與呼叫方法與以往都截然不同,在官方推薦的 Learning Flutter」s new navigation and routing system (譯文:Flutter Navigator 2.0 全面解析)文章中,許多讀者也表示並不能立即適應 Navigator 2.0 的一些反差。

本文就來帶領讀者們進一步深入 Navigator 2.0 的基本原理,幫助大家從中探索出最佳的使用方式。

為什麼需要新的 API

在探究具體細節之前,我們有必要瞭解一下 Flutter 團隊為什麼要不惜這些代價對 Navigator API 做這次的重構,主要有如下幾點原因。

  • 原始 API 中的 initialRoute 引數,即系統預設的初始頁面,在應用執行後就不能再更改了。這種情況下,如果使用者接收到一個系統通知,點選後想要從當前的路由棧狀態 [Main -> Profile -> Settings] 重啟切換到新的 [Main -> List -> Detail[id=24] 路由棧,舊的 Navigator API 並沒有一種優雅的實現方式實現這種效果。

  • 原始的指令式 Navigator API 只提供給了開發者一些非常針對性的介面,如 push()pop() 等,而沒有給出一種更靈活的方式讓我們直接操作路由棧。這種做法其實與 Flutter 理念相違背,試想如果我們想要改變某個 widget 的所有子元件只需要重建所有子元件並且建立一系列新的 widget 即可,而將此概念應用在路由中,當應用中存在一系列路由頁面並想要更改時,我們只能呼叫 push()pop() 這類介面來回操作, 這樣的 Flutter 食之無味

  • 巢狀路由下,手機裝置自帶的回退按鈕只能由根 Navigator 回應。在目前的應用中,我們很多場景都需要在某個子 tab 內單獨管理一個子路由棧。假設有這個場景,使用者在子路由棧中做一系列路由操作之後,點選系統回退按鈕,消失的將是整個上層的根路由,我們當然可以使用某種措施來避免這種狀況,但歸咎起來,這也不應該是應用開發者應該考慮的問題。

於是,Navigator 2.0 就肩負著這千里之任來了。

Navigator 2.0 新增的宣告式 API 主要包含 Page API、Router API 兩個部分,它們各自強大的功能為 Navigator 2.0 提供了強有力的基石,本節我就帶讀者們看看它們各自的實現細節。

Page

Page 是 Navigator 2.0 中最常見的類之一,從名字就能知道它的含義就是『頁面』,正如 widget 就是 元件 一樣,但 Page 與 Widget 的關係也更加微妙。

與 Flutter 中 Widget、Element、 RenderObject 三棵樹的概念保持一致。 Widget 只儲存元件設定訊息,框架層內建了一個 createElement() 可以建立與之對應的 Element 實例。 Page 同樣只儲存頁面路由相關訊息,框架層也存在一個 createRoute() 方法可以建立與之對應的 Route 實例。

English placeholder for the translation toggle tools issue.

Widget 和 Page 中也都有一個 canUpdate() 方法,幫助 Flutter 判斷其是否已更新或改變:

// Page
bool canUpdate(Page<dynamic> other) {
  return other.runtimeType == runtimeType &&
         other.key == key;
}

// Widget
static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
      && oldWidget.key == newWidget.key;
}

甚至連比較的條件都是 執行時型別與 key

而在程式碼層面,Page 類就繼承自我們在舊的 Navigator API 用過的 RouteSettings

abstract class Page<T> extends RouteSettings

其中就儲存了包含路由名稱(name,如 “/settings”)和路由引數 (arguments) 等訊息。

pages 引數

在新的 Navigator 元件中,新增了一個 pages 引數,它接受的就是一個 Page 物件清單,如下這段程式碼:

class _MyAppState extends State<MyApp> {
  final pages = [
    MyPage(
      key: Key('/'),
      name: '/',
      builder: (context) => HomeScreen(),
    ),
    MyPage(
      key: Key('/category/5'),
      name: '/category/5',
      builder: (context) => CategoryScreen(id: 5),
    ),
    MyPage(
      key: Key('/item/15'),
      name: '/item/15',
      builder: (context) => ItemScreen(id: 15),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return //...
      Navigator(
          key: _navigatorKey,
          pages: List.of(pages),
        ),
  }
}

此時,執行應用,Flutter 就會根據這裡 pages 清單中的所有 Page 物件在底層的路由棧生成對應的 Route 實例,即與 pages 對應的三個路由頁面。

應用開啟某個頁面,就表示在 pages 中新增一個 Page 物件,系統接收到上層的 pages 改變的通知後就會 比較新的 pages 與舊的 pages,根據比較結果,Flutter 就會在底層路由棧中新生成一個 Route 實例,這樣一個新的頁面就算開啟成功了。

void addPage(MyPage page) {
  setState(() => pages.add(page));
}

Navigator 元件同樣也新增了一個 onPopPage 引數,接受一個回呼函式來回應頁面的 pop 事件,如下面程式碼中的 _onPopPage 函式:

class _MyAppState extends State<MyApp> {
  bool _onPopPage(Route<dynamic> route, dynamic result) {
    setState(() => pages.remove(route.settings));
    return route.didPop(result);
  }

  @override
  Widget build(BuildContext context) {
    print('build: $pages');
    return // ...
      Navigator(
        key: _navigatorKey,
        onPopPage: _onPopPage,
        pages: List.of(pages),
      )
  }
}

當我們呼叫 Navigator.pop() 關閉某個頁面時,即能觸發這個函式呼叫,而函式接受到的 route 物件引數就表示需要在 pages 中被移除的頁面,在這裡,我們順勢更新 pages 清單做移除操作即可。

_onPopPage 中,如果我們同意關閉該頁面,則呼叫 route.didPop(result),該函式預設回傳 true。

當然,我們也完全可以選擇在接收到通知時不更新 pages 清單,這完全由我們控制,如下這段程式碼:

bool _onPopPage(Route<dynamic> route, dynamic result) {
  // setState(() => pages.remove(route.settings));
  return route.didPop(result);
}

那麼,此時會導致什麼現象? route.didPop(result) 函式被直接觸發,表示在底層路由棧中彈出該頁面,這時,Flutter 就會比較 底層已經關閉了一個頁面的路由棧當前 Navigator 中存有的 pages,發現不一致,就會按照現有的 pages 將多餘的一個 Page 當做新頁面,再生成一個 Route 物件,這樣,底層路由棧中的內容就能隨時保持與上層 pages 資料一致了。

也就是說,某個頁面是否能夠關閉完全由開發者掌控,而不是單純地交給系統的 Navigator.pop()。這裡,如果我們不想關閉某個頁面,也可以在 onPopPage 的回呼函式中直接回傳 false:

bool _onPopPage(Route<dynamic> route, dynamic result) {
  if (...) {
    return false;
  }
  setState(() => pages.remove(route.settings));
  return route.didPop(result);
}

需要注意的是,onPopPage 只回應路由棧頂層頁面的推出,中間頁面的移除不會呼叫這個回呼函式。

這也合情合理,如果我們想要移除非頂層頁面,那麼下次彈出頁面時候,底層路由棧會直接與新的 pages 清單比較來做出相應改變。

要執行上述完整案例,檢視 完整程式碼

Flutter 框架中預先內建了 MaterialPageCupertinoPage 兩種 Page,分別表示 Material 和 Cupertino 風格下的頁面,與之前我們常用的 MaterialPageRouteCupertinoPageRoute 相呼應,它們都接受一個 child 元件表示該頁面所要呈現的內容。例如下面這個例子,我們可以直接在 pages 中使用 MaterialPage 建立頁面:

List<Page> pages = <Page>[
  MaterialPage(
    key: ValueKey('VeggiesListPage'),
    child: VeggiesListScreen(
      veggies: veggies,
      onTapped: _handleVeggieTapped,
    ),
  ),
  if (show404)
    MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
  else if (_selectedVeggie != null)
    VeggieDetailsPage(veggie: _selectedVeggie)
];

我們也可以直接繼承 Page 定義自己的頁面型別,如下:

class MyPage extends Page {
  final Veggie veggie;

  MyPage({
    this.veggie,
  }) : super(key: ValueKey(veggie));

  Route createRoute(BuildContext context) {
    return MaterialPageRoute(
      settings: this,
      builder: (BuildContext context) {
        return VeggieDetailsScreen(veggie: veggie);
      },
    );
  }
}

這裡,我們重寫了 createRoute() 回傳一個 MaterialPageRoute 物件即可。

Router

Router 是 Navigator 2.0 中新增的另一個非常重要的元件,繼承自 StatefulWidget,可以管理自己的狀態。

它所管理的狀態就是應用的 路由狀態,結合上節中提到的 Page 的概念,我們就可以將其中的 pages 看做這裡的路由狀態,當我們改變 pages 的內容或狀態時, Router 就會將該狀態分發給子元件,狀態改變導致子元件重建應用最新的狀態。

所以當 Navigator 作為 Router 的子元件時,就會天然具有感知路由狀態改變的能力了,如下圖所示:

當用戶點選某個按鈕就會觸發類似下面這個函式的呼叫,該函式又會導致狀態改變而重建子元件。

void _pushPage() {
  MyRouteDelegate.of(context).push('Route$_counter');
}

Navigator 2.0 所強調的宣告式 API 的核心就在於此,我們操作路由的方式並非再是 push 或者 pop,而是改變應用的狀態了!我們需要從觀念上理解宣告式 API 與以往的不同之處。

Router 代理

Router 要完成上面所說的功能主要需要透過設定 RouterDelegate(路由代理)實現。

Navigator 2.0 之後,Flutter 也提供了 MaterialApp 的新建構式函式 router 來幫助我們直接在應用頂層構造出全域的 Router 元件,使用方式如下:

MaterialApp.router(
  title: 'Flutter Demo',
  theme: ThemeData(
    primarySwatch: Colors.blue,
    visualDensity: VisualDensity.adaptivePlatformDensity,
  ),
  routeInformationParser: MyRouteParser(),
  routerDelegate: delegate,
)

該建構式函式接受一個 routerDelegate 引數,這裡,就可以傳入我們自己建立的 MyRouteDelegate 物件,具體程式碼如下:

class MyRouteDelegate extends RouterDelegate<String>
    with PopNavigatorRouterDelegateMixin<String>, ChangeNotifier {
  final _stack = <String>[];

  static MyRouteDelegate of(BuildContext context) {
    final delegate = Router.of(context).routerDelegate;
    assert(delegate is MyRouteDelegate, 'Delegate type must match');
    return delegate as MyRouteDelegate;
  }

  MyRouteDelegate({
    @required this.onGenerateRoute,
  });

  // ...
  @override
  Widget build(BuildContext context) {
    print('${describeIdentity(this)}.stack: $_stack');
    return Navigator(
      key: navigatorKey,
      onPopPage: _onPopPage,
      pages: [
        for (final name in _stack)
            MyPage(
              key: ValueKey(name),
              name: name,
              routeFactory: onGenerateRoute,
            ),
      ],
    );
  }
}

上面的 MyRouteDelegate 繼承自 RouterDelegate,內部可以實現它的 setInitialRoutePathsetNewRoutePathbuild 三個方法與 currentConfiguration 的 getter 方法,並且也混入了 PopNavigatorRouterDelegateMixin 類,它的主要作用是回應 Android 裝置的回退按鈕,而 ChangeNotifier 作用便是做事件通知,下文的『實現 RouterDelegate』中就會分析這些方法各自的作用。

這裡,我們先看 MyRouteDelegate.build 方法,與上一小節一樣,我們可以透過傳入 pagesonPopPage 引數建立一個 Navigator 元件回傳,這樣,當 MyRouteDelegate 元件傳入到 MaterialApp.router() 建構式函式後,這裡的 Navigator 就順利成為了 Router 的子元件了。

大部分情況下,一個自定義的路由代理就可以這樣實現完成了。

Router 事件

在應用開發中,Router 最根本的作用還是監聽各種來自系統的路由相關事件,包括:

  • 首次啟動應用程式時,系統請求的初始路由。
  • 監聽來自系統的新 intent,即開啟一個新路由頁面。
  • 監聽裝置回退,關閉路由棧中頂部路由。

而要想完整的回應這些事件,還得為 Router 設定 RouteNameProvider DelegateBackButtonDispatcher Delegate

最初,應用啟動或者開啟新頁面的事件從系統發出時, 會轉發給應用層一個表示該事件的字串, RouteNameParser Delegate 會將該字串傳遞給 RouteNameParser,進而會解析成一個型別 T 的物件,型別 T 預設為 RouteSetting,其中就會包含傳遞的路由名稱和引數等訊息了。

類似地,使用者點選裝置回退按鈕後,會將該事件傳遞給 BackButtonDispatcher Delegate。

最終,RouteNameParser 解析的物件資料和 BackButtonDispatcher Delegate 回退事件都會轉發給上文中的 RouteDelegate,RouteDelegate 接收到這些事件通知後,就會執行回應,改變狀態,從而導致含有 pages 的 Navigator 元件重建,在應用層中呈現最新的路由狀態。

整個過程可以用下圖表示:

從以上部分可以看出,一系列的操作只是將最終事件傳遞給 RouterDelegate 而已,之後狀態更新等操作都可以由我們自定義的 RouterDelegate 決定。

實現 RouterDelegate

正如我們上文說的,Flutter 為 RouteNameProvider Delegate 和 BackButtonDispatcher Delegate 都提供了預設實現,而 RouterDelegate 則必須要我們手動實現,並傳遞給 MaterialApp.router() 建構式函式才行。

我們可以在這裡完成各種業務相關的操作, RouteDelegate 本身實現自 Listenable,即可監聽物件,也可以叫做被觀察者,每當狀態改變時,觀察者們就能通知它回應該事件,從而促使 Navigator 元件重建,更新路由狀態。

RouterDelegate 中的路由事件的通知主要由下面幾個函式接收:

  • backButtonDispatcher 發出回退按鈕事件時,會呼叫 RouterDelegate 的 popRoute() 方法,由混入的 PopNavigatorRouterDelegateMixin 實現。
  • 發出應用初始路由的通知時,會呼叫 RouterDelegate 的 setInitialRoutePath() 方法,該方法接受路由名稱作為引數,預設此方法會直接呼叫 RouterDelegate 的 setNewRoutePath() 函式。
  • 系統透過 routeNameProvider 發出開啟新路由頁面的通知時,直接呼叫 setNewRoutePath() 方法,引數就是由 routeNameParser 解析的結果。

因此,我們最終就可以實現如下這樣的 RouterDelegate:

class MyRouteDelegate extends RouterDelegate<String>
    with PopNavigatorRouterDelegateMixin<String>, ChangeNotifier {
  final _stack = <String>[];

  static MyRouteDelegate of(BuildContext context) {
    final delegate = Router.of(context).routerDelegate;
    assert(delegate is MyRouteDelegate, 'Delegate type must match');
    return delegate as MyRouteDelegate;
  }

  MyRouteDelegate({
    @required this.onGenerateRoute,
  });

  final RouteFactory onGenerateRoute;

  @override
  GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  @override
  String get currentConfiguration => _stack.isNotEmpty ? _stack.last : null;

  List<String> get stack => List.unmodifiable(_stack);

  void push(String newRoute) {
    _stack.add(newRoute);
    notifyListeners();
  }

  void pop() {
    if (_stack.isNotEmpty) {
      _stack.remove(_stack.last);
    }
    notifyListeners();
  }

  @override
  Future<void> setInitialRoutePath(String configuration) {
    return setNewRoutePath(configuration);
  }

  @override
  Future<void> setNewRoutePath(String configuration) {
    print('setNewRoutePath $configuration');
    _stack
      ..clear()
      ..add(configuration);
    return SynchronousFuture<void>(null);
  }

  bool _onPopPage(Route<dynamic> route, dynamic result) {
    if (_stack.isNotEmpty) {
      if (_stack.last == route.settings.name) {
        _stack.remove(route.settings.name);
        notifyListeners();
      }
    }
    return route.didPop(result);
  }

  @override
  Widget build(BuildContext context) {
    print('${describeIdentity(this)}.stack: $_stack');
    return Navigator(
      key: navigatorKey,
      onPopPage: _onPopPage,
      pages: [
        for (final name in _stack)
            MyPage(
              key: ValueKey(name),
              name: name,
            ),
      ],
    );
  }
}

這裡的 _stack 表示一個資料集,每個資料會在 build 函式中建立出一個 MyPage,預設為空。應用啟動時,會先呼叫這裡的 setInitialRoutePath(String configuration) 方法,引數為 /,此時路由棧就會存在一個首頁了。

完整程式碼,請 參考這裡

在子元件中,我們也可以使用 MyRouteDelegate,透過如下方式開啟或者關閉一個頁面:

MyRouteDelegate.of(context).push('Route$_counter');

MyRouteDelegate.of(context).pop();

與 InheritWidget 的性質相同,這裡會觸發 MyRouteDelegate 中,我們自定義的 push()pop() 方法操作宣告的路由棧,最終通知 Navigator 更新路由狀態。

實現 RouteInformationParser

MaterialApp.router 除了需要接受路由代理 routerDelegate 這個必要引數外,還需要同時指定 routeInformationParser 引數,如下:

MaterialApp.router(
  title: 'Flutter Demo',
  routeInformationParser: MyRouteParser(), 	// 傳入 MyRouteParser
  routerDelegate: delegate,
)

該引數接受一個 RouteInformationParser 物件,定義該類別通常有一個最簡單直接的實現,如下:

class MyRouteParser extends RouteInformationParser<String> {
  @override
  Future<String> parseRouteInformation(RouteInformation routeInformation) {
    return SynchronousFuture(routeInformation.location);
  }

  @override
  RouteInformation restoreRouteInformation(String configuration) {
    return RouteInformation(location: configuration);
  }
}

MyRouteParser 繼承自 RouteInformationParser,並重寫了父類別 parseRouteInformation()restoreRouteInformation() 兩個方法。

如上文所述,parseRouteInformation() 方法的作用就是接受系統傳遞給我們的路由訊息 routeInformation,然後,回傳轉發給我們之前定義的路由代理 RouterDelegate,解析後的型別為 RouteInformationParser 的泛型型別,即這裡的 String。也就是說,下面這個 RouterDelegate 中 setNewRoutePath() 方法的引數 configuration 就是從那裡轉發而來的:

@override
Future<void> setNewRoutePath(String configuration) {
  print('setNewRoutePath $configuration');
  _stack
    ..clear()
    ..add(configuration);
  return SynchronousFuture<void>(null);
}

restoreRouteInformation() 方法回傳一個 RouteInformation 物件,表示從傳入的 configuration 恢復路由訊息。與 parseRouteInformation() 相呼應。

例如,在瀏覽器中,Flutter 應用所在的標籤被關閉,此時如果我們想要恢復整個頁面的路由棧則需要重寫此方法,

上面 MyRouteParser 的實現,是最簡單的實現方式,功能就是在 parseRouteInformation() 中接受底層的 routeInformation,在 restoreRouteInformation() 中恢復上層的 configuration

我們也可以繼續為這兩個方法賦能,實現更符合業務需求的邏輯,如下這程式碼:

import 'package:flutter/material.dart';
import 'package:flutter_navigator_v2/navigator_v2/model.dart';

class VeggieRouteInformationParser extends RouteInformationParser<VeggieRoutePath> {
  @override
  Future<VeggieRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    print("parseRouteInformation");
    final uri = Uri.parse(routeInformation.location);
    // Handle '/'
    if (uri.pathSegments.length == 0) {
      return VeggieRoutePath.home();
    }

    // Handle '/veggie/:id'
    if (uri.pathSegments.length == 2) {
      if (uri.pathSegments[0] != 'veggie') return VeggieRoutePath.unknown();
      var remaining = uri.pathSegments[1];
      var id = int.tryParse(remaining);
      if (id == null) return VeggieRoutePath.unknown();
      return VeggieRoutePath.details(id);
    }

    // Handle unknown routes
    return VeggieRoutePath.unknown();
  }

  @override
  RouteInformation restoreRouteInformation(VeggieRoutePath path) {
    print("restoreRouteInformation");
    if (path.isUnknown) {
      return RouteInformation(location: '/404');
    }
    if (path.isHomePage) {
      return RouteInformation(location: '/');
    }
    if (path.isDetailsPage) {
      return RouteInformation(location: '/veggie/${path.id}');
    }
    return null;
  }
}

這裡的 VeggieRouteInformationParser 繼承的 RouteInformationParser 泛型型別被指定為了我們自定義的 VeggieRoutePath,在 Navigator 2.0 中我們稱這個解析後的形式為 路由 Model

此時 VeggieRouteInformationParser 作用就凸顯出來了,它在 parseRouteInformation() 方法中接受到系統傳遞過來的 RouteInformation 訊息後就可以將其轉換成我們上層熟悉的 VeggieRoutePath Model 物件。 VeggieRoutePath 類內容如下:

class VeggieRoutePath {
  final int id;
  final bool isUnknown;

  VeggieRoutePath.home()
      : id = null,
        isUnknown = false;

  VeggieRoutePath.details(this.id) : isUnknown = false;

  VeggieRoutePath.unknown()
      : id = null,
        isUnknown = true;

  bool get isHomePage => id == null;

  bool get isDetailsPage => id != null;
}

此時,在 RouterDelegate<VeggieRoutePath> 中,我們就可以根據該物件做路由狀態的更新了。

最佳實踐

Navigator 2.0 與以往不同的方面主要體現在,將路由狀態轉換成了應用本身的狀態,給了開發者更大的自由與想象空間,此後,我們可以將路由邏輯及其狀態的管理與我們的業務邏輯緊密相連,形成自己的一套方案,相信這又會是以後 Flutter 體系中一塊大主題。

上述提及的所有程式碼包含三個案例,分別是:

範例完整原始碼地址

寫在最後

感謝 @Vadaski@Alex Li 對本文的 Review。

如果你對本文還有任何疑問或者文章的建議,歡迎向我的 Github 中的範例倉庫送出 issue 或者透過郵箱與我聯絡,我會及時回覆。

本文作者: 楊加康

《Flutter 開發之旅從南到北》作者,現就職於小米瀏覽器團隊。

聯繫方式:yangjiakay@gmail.com