Flutter Navigator2.0 徹底指南與原理解析

本文首發於微信公衆號 「MeandNi」點擊閱讀html

Flutter 1.22 後,你們能夠發現,官方對路由相關 API 的改動很大,設計文檔中表示,因爲傳統的命令式並沒有給開發者一種靈活的方式去直接管理路由棧,甚至以爲已通過時了,一點也不 Flutter。git

As mentioned by a participant in one of Flutter's user studies, the API also feels outdated and not very Flutter-y.github

而 Navigator 2.0 引入了一套全新的聲明式 API,全新的實現方式與調用方法與以往都大相徑庭,在 Flutter Navigator 2.0 全面解析 文章中,許多讀者都直呼不適應,不會用。瀏覽器

juejin.im_post_6880388303745449991

本文,我就來帶領讀者們逐步深刻 Navigator2.0 的基本原理,幫助你們探索出最佳的使用方法。微信

爲何須要新的 API

在探究具體細節以前,咱們有必要了解一下 Flutter 團隊爲何要不惜這些代價對 Navigator API 作這麼大的重構,主要有以下幾點緣由。markdown

原始 API 中的 initialRoute 參數,即系統默認的初始頁面,在應用運行後就不能在更改了。這種狀況下,若是用戶接收到一個系統通知,點擊後想要從當前的路由棧狀態 [Main -> Profile -> Settings] 切換到新的 [Main -> List -> Detail[id=24],Navigator1.0 並無一種優雅的實現方式實現這種效果。app

原始的命令式 Navigator API 只提供給了開發者一些很是針對性的接口,如 push、pop 等,而沒有給出一種更靈活的方式讓咱們直接操做路由棧。這也是我上一篇文章中提到的,這種作法其實與 Flutter 理念相違背,試想若是咱們想要改變某個 Widget 的全部子組件只須要重建全部子組件而且建立一系列新的 Widget 便可,而將此概念應用在路由中...en?當應用中存在一系列路由頁面並想要更改時,咱們只能調用 push、pop 這類接口來回操做,Flutter 味道全無框架

嵌套路由下,手機設備自帶的回退按鈕只能由根 Navigator 響應。在目前的應用中,咱們不少場景都須要在某個子 tab 內單獨管理一個子路由棧,假設有這個場景,用戶在子路由棧中作一系列路由操做以後,點擊系統回退按鈕,消失的將是整個上層的根路由,咱們固然可使用某種措施來避免這種情況,但歸咎起來,這也不該該是應用開發者應該考慮的問題。異步

因而,Navigator2.0 就過山車似的來了~async

Navigator2.0

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

Page

Page 是 Navigator2.0 中最多見的類之一,從名字就能知道它的含義就是 「頁面」,這就好像 Widget 就是組件同樣,但 Page 與 Widget 的關係也很微妙。

Flutter 中三棵樹 的概念保持一致。Widget 只保存組件配置信息,框架層內置了一個 createElement() 能夠建立與之對應的 Element 實例。Page 一樣只保存頁面路由相關信息,框架層也存在一個 createRoute() 方法能夠建立與之對應的 Route 實例。

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 繼承自咱們以前就用過的 RouteSettings:

abstract class Page<T> extends RouteSettings
複製代碼

其中就保存包含路由名稱(name,如 "/settings"),參數(arguments)等信息。

pages

下面,咱們來看一下 Page 的使用場景。在新的 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 比較,此時就會在底層路由棧中新生成一個 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 移除相應的 Page 對象怎麼辦,以下這段代碼:

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() ,這裏若是咱們不想關閉某個頁面直接返回 false 便可:

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

須要注意的是,onPopPage 只響應路由棧頂層頁面的推出,中間頁面的移除不會調用這個回調函數。

這也合情合理,若是咱們想要移除非頂層頁面,那麼下次彈出頁面時候,底層路由棧會直接與新的 pages 列表比較來作出相應改變。

要運行上述完整案例,查看完整代碼:github.com/MeandNi/flu…

Flutter 框架中預先內置了 MaterialPage 和 CupertinoPage 兩種 Page,分別表示 Material 和 Cupertino 風格下的頁面,與 Navigator1.0 中的 MaterialPageRoute 和 CupertinoPageRoute 相呼應,它們都接受一個 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 是 Navigator2.0 中新增的另外一個很是重要的組件,繼承自 StatefulWidget 管理本身的狀態(存在一個 of 函數,與可遺傳組件(InheritedWidget)搭配實現狀態的統一管理,打個廣告 😁,即將出版的新書《Flutter 開發之旅從南到北》第九章)。

它所管理的狀態就是應用的路由狀態,結合上節中提到的 Page 的概念,咱們就能夠將其中的 pages 看作這裏的路由狀態,當咱們改變 pages 內容/狀態時,Router 就會將該狀態分發給子組件,狀態改變致使子組件重建應用最新的狀態。(第九章確實須要讀者們仔細閱讀,其中涵蓋狀態管理的知識點貫穿了 Flutter 框架層的設計)。

因此當 Navigator 做爲 Router 的子組件時,就會自然具備感知路由狀態改變的能力了,以下圖所示:

當用戶點擊某個按鈕就會觸發相似下面這個函數的調用,該函數又會致使狀態改變而重建子組件。

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

Navigator2.0 所強調的聲明式 API 的核心就在於此,咱們操做路由的方式並不是再是 push 或者 pop,而是改變應用的狀態了!咱們須要從觀念上理解聲明式 API 與以往的不一樣之處。

Router 代理

Router 要完成上面所說的功能主要須要經過配置 RouterDelegate(路由代理)實現。

Navigator2.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,內部能夠實現它的 setInitialRoutePath、setNewRoutePath、build 與 currentConfiguration getter 四個方法,而且也混入了 PopNavigatorRouterDelegateMixin 類,它的主要做用是響應 Android 設備的回退按鈕,而 ChangeNotifier 做用即是作事件通知,下文的 「實現 RouterDelegate」 中咱們就會分析這些方法各自的做用。

這裏,咱們先看 MyRouteDelegate.build 方法,與上一小節同樣,咱們能夠經過傳入 pages 和 onPopPage 參數建立一個 Navigator 組件返回,這樣,當 MyRouteDelegate 對象傳入 MaterialApp.router() 構造函數後,這裏的 Navigator 就順利成爲了 Router 的子組件了。

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

Router 事件

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

  • 首次啓動應用程序時,系統請求的初始路由。

  • 監聽來自系統的新 intent,即打開一個新路由頁面。

  • 監聽設備回退,關閉路由棧中頂部路由。

而要想完整的響應這些事件,還得爲 Router 配置 RouteNameProvider Delegate 和 BackButtonDispatcher Delegate。

最初,應用啓動或者打開新頁面的事件從系統發出時,會轉發給應用層一個該事件相關的字符串,RouteNameParser Delegate 會將該字符串傳遞給 RouteNameParser,經而會解析成一個類型 T 的對象,類型 T 默認爲 RouteSetting,其中就會包含傳遞的路由名稱和參數等信息了。

相似地,用戶點擊設備回退按鈕後,會將該事件傳遞給 BackButtonDispatcher Delegate。

最終,RouteNameParser 解析的對象數據和 BackButtonDispatcher Delegate 回退事件都會轉發給上文中的 RouteDelegate,RouteDelegate 接受到這些事件後就會響應,執行響應的狀態改變,從而致使含有 pages 的 Navigator 組件重建,在應用層中呈現最新的路由狀態。

整個過程能夠用下圖表示:

須要知道的是,RouteNameProvider Delegate 和 BackButtonDispatcher Delegate 都有 Flutter 內置的默認實現,所以,大部分狀況下,咱們並不須要考慮其中的細節,此時類型 T 默認爲 RouteSetting(與 Navogator1.0 一致,包含路由信息)。

從以上部分能夠看出,一系列的操做只是將最終事件傳遞給 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) 方法,參數爲 ’/‘,此時路由棧就會存在一個首頁了。

完整代碼,參見:github.com/MeandNi/flu…

在子組件中,咱們也可使用 MyRouteDelegate,經過以下方式打開或者關閉一個頁面:

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

MyRouteDelegate.of(context).pop();
複製代碼

與可遺傳組件性質相同,這裏會觸發 MyRouteDelegate 中,咱們自定義的 push 和 pop 方法操做聲明的路由棧,最終通知更新路由狀態。

實現 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 '/book/: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,在 Navigator2.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 體系中一塊大主題。

上述說起的全部代碼包含三個案例,分別是:

源碼地址:github.com/MeandNi/flu…

新書預售 🔥

image-20201108214042932

終於能夠宣佈啦,個人新書 《Fluter 開發之旅從南到北》 終於在異步社區開始預售了!裏面涵蓋了 Flutter 各種進階知識點,包括三棵樹原理,佈局約束、自渲染組件、狀態管理等等,全書配套開源代碼:github.com/MeandNi/flu… ,正式發佈以後也會有專門的文章介紹,有須要的同窗能夠先關注起來 😊 但願能對 Flutter 社區有所貢獻。

預售地址:item.jd.com/10024203424…,以後會在各大書店同步發售,歡迎你們關注公衆號「MeandNi」,留意以後的送書活動以及最新高質量 Flutter 技術文章。

相關文章
相關標籤/搜索