敢問路在何方——Flutter 路由淺析

Sandfone發表於2021-03-22

在開始之前,我們先介紹一個貌似毫不相關的概念,至於原因,一是因為後面的某個概念和它具有相關性,二是因為這個概念太簡單,不足以以一整篇篇幅來介紹它,所以不如就在這裡順帶著介紹一下。

InheritedWidget

一句話總結 InheritedWidget 就是「在檢視樹上更有效的向下傳遞資訊的 widget」。

abstract class InheritedWidget extends ProxyWidget {
  const InheritedWidget({ Key key, Widget child })
    : super(key: key, child: child);

  @override
  InheritedElement createElement() => InheritedElement(this);

  @protected
  bool updateShouldNotify(covariant InheritedWidget oldWidget);
}
複製程式碼

updateShouldNotify() 方法用來控制對其實現的子類是否在 rebuild 過程中同樣進行 rebuild,例如,當此 widget 的資料並未改變時,可能並不需要對其進行更新。

所以,相比於一般的 widget,它主要多了個在檢視樹上實現「資訊傳遞」的功能,那它的資訊傳遞的功能又是如何實現的呢——藉助 BuildContext 類,我們線看一個例子。

class FrogColor extends InheritedWidget {
  const FrogColor({
    Key key,
    @required this.color,
    @required Widget child,
  }) : assert(color != null),
       assert(child != null),
       super(key: key, child: child);

  final Color color;

  static FrogColor of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<FrogColor>();
  }

  @override
  bool updateShouldNotify(FrogColor old) => color != old.color;
}
複製程式碼

of() 方法接收 BuildContext 引數,並返回引數的 dependOnInheritedWidgetOfExactType() 方法呼叫結果,而該方法的實現在 BuildContext 類的子類 Element 類中。

inherited_widget.jpg

這個從檢視樹上按名稱摘果子的過程並不難理解。好了,關於 InheritedWidget 的部分我們就瞭解這麼多,下面迴歸本篇的核心主旨——路由部分。

從 Navigator 1.0 開始

Navigator 以棧的方式管理著它的家族控制元件們。正如在 Android 中通過一個棧來管理 Activity,每個 Activity 作為一個單獨的頁面的原則, Flutter 中也以棧的方式管理著我們需要的頁面,不過每個頁面不再是 Activity,而變成了 route。

說到 Navigator,我們可以在腦海中形成這樣一種畫像,在桌子上摞著一疊圖紙,我們能看到最上面的那一張畫了些什麼,但是無法其他在下面的圖紙的內容。如果現在把最上面的那張圖紙拿開,原先自上而下的第二張此刻就變成了最上面的那張圖紙,此時我們看到畫像就還是新的最上面的那幅。那再放置一張新的畫像在這一摞圖畫之上,可見的圖畫就又被更新了。

olia-gozha-prhiWWrS-SE-unsplash.jpg

當我們需要新增新的「圖畫」時,只需要使用 Navigatorpush 系列方法就可以了,push 系列方法有好些個,忽略其他的附加操作,它們可以分為兩類—— pushpushNamed

image-20210303001249950.png

1.push

Navigator.of(context).push(MaterialPageRoute(builder: (context) => SignUpPage()));
複製程式碼

這句程式碼並不特別,是跳轉一個新介面的一般做法。MaterialPageRouteRoute 的子類,builder 引數返回新介面的 Widget 例項。

NavigatorStatefulWidget 的子類,對應的 StateNavigatorStateNavigator.of(context) 方法返回 NavigatorState 例項,和上面 InheritedWidget 類似,藉助 BuildContextfindRootAncestorStateOfType() 方法在 Element 樹上尋找對應的 StatefulElement,返回可以和泛型指定的 State 型別匹配的 State 物件。

在深入 push() 方法之前,我們先借助 devtools 瞭解一下 Flutter 頁面的層級結構,能夠幫助我們更好地理解下面的流程。

由於圖片太長,請點選檢視 敢問路在何方——Flutter 路由淺析

接下來就是呼叫 Navigatorpush() 方法了,這部分的邏輯比較複雜,我嘗試著按我的理解繪製了一張流程圖,對照著來理解整個過程。

graph TB
	A["push()"] --> B["_history.add()"]
	A --> C["_flushHistoryUpdates"]
	A --> D["_afterNavigation()"]
	C --> E["entry.handlePush()"]
	C --> F["overlay.rearrange(_allRouteOverlayEntries)"]
	E --> G["entry.handlePush()"]
	G --> H["_overlayEntries.addAll(createOverlayEntries())"]
	H --> I["_buildModalBarrier()"]
	H --> J["_buildModalScope()"]
	J --> K["_ModalScope<_ModalScopeState>"]
	K --> L["build()"]
	L --> M["ModalRoute.buildPage()"]
	M --> N["MaterialPageRoute.builder"]
	D --> O["_cacelActivePointers()"]
	O --> P["setState()"]
	P --> Q["build()"]
	Q --> R["Overlay.initialEntries = _allRouteOverlayEntries"]
	S{{"for (entry in _history) yield entry.route.overlayEntries"}}
	F -->|"_allRouteOverlayEntries"| S
	H -->|"_overlayEntries"| S
	F -.-|"_allRouteOverlayEntries"| R

push() 方法接受 Route 型的引數,並在方法內將其封裝為 _RouteEntry 型。Navigator 類有一個成員 _history,是一個 OverlayEntry 物件的集合,push() 方法將封裝好的 _RouteEntry 物件新增到 _history 列表中。之後 push() 方法呼叫 _RouteEntryhandlePush() 方法,建立 「_ModalBarrir」 和 「_ModalScope」,它們都是 Widget 物件,前者是用來隔絕不同介面之間的互動操作(例如手勢操作),後者是對我們目標跳轉頁面的封裝。最後 push() 方法呼叫 _afterNavigation() 方法重新整理 Navigator,致使 build() 方法被呼叫,在此方法中,Navigator 通過 GlobalKey 獲取到全域性的 Overlay 物件,並將被 _OverlayEntryWidget 物件包裹的 「_ModalScope」頁面更新到 Overlay 中,這樣我們的介面就可以顯示在頁面層級中了。

2. pushNamed

MaterialApp 中支援通過 onGenerateRoute 引數來構建路由表。它是一個方法,形式為 Route<dynamic> Function(RouteSettings settings),根據傳入的 RouteSettings 物件引數,返回對應的 Route 例項。RouteSettings 類擁有兩個成員變數分別為 final String namefinal Object arguments。而 NavigatorNavigatorStatepushNamed() 方法引數接收的正是這兩個物件。

Future<T> pushNamed<T extends Object>(
    String routeName, {
        Object arguments,
    }) {
    return push<T>(_routeNamed<T>(routeName, arguments: arguments));
}
複製程式碼

可以看到 pushNamed() 方法最終呼叫還是上面介紹的 push() 方法,但是引數則通過 _routeName() 方法來構建。

Route<T> _routeNamed<T>(String name, { @required Object arguments, bool allowNull = false }) {
    
    // ...
    
    final RouteSettings settings = RouteSettings(
        name: name,
        arguments: arguments,
    );
    Route<T> route = widget.onGenerateRoute(settings) as Route<T>;
    if (route == null && !allowNull) {
        route = widget.onUnknownRoute(settings) as Route<T>;
    }
    return route;
}
複製程式碼

使用 flutter 命令列執行 flutter run --route=/signup 檢視 demo。

3. pop

當呼叫 pop() 方法時,會將頁面棧早上層的頁面檢視彈出,顯示出下面一張的檢視。

在這個過程中,_flushHistoryUpdates() 方法依然發揮著重要的作用,通過 _RouteEntry.currentState 變數控制彈出的過程,分別為poppopingremoveremovingdisposedisposed,並在這些過程中移除 NavigatorState._history 中的對應的 _RouteEntry 例項,在重新整理檢視時,Overlay 得到更新,被移除的例項會將包裹的頁面移除 Overlay 層。

那麼在這個過程中,前一個頁面的資料是如何傳遞到後一個頁面的呢?

graph LR
	A["NavigatorState.pop(result)"] --> B["_RouteEntry.pop(result)"] -->
    C["Route.didPop(result)"] --> D["Route.didComplete(result)"]

在經過上面的呼叫後,pop() 方法的引數 result 被傳遞到 Route.didComplete() 方法。

void didComplete(T result) {
    _popCompleter.complete(result ?? currentResult);
}
複製程式碼

_popCompleter 物件是 Completer 類的例項,而 _popCompleterfuture 屬性在 NavigatorState.push() 方法呼叫時被返回。

/// Route
Future<T> get popped => _popCompleter.future;

/// NavigatorState
Future<T> push<T extends Object>(Route<T> route) {
    // ...
    return route.popped;
}
複製程式碼

所以後一個頁面呼叫 pop() 方法返回的結果能被前一個頁面在呼叫 push() 方法後以 Future 的形式接收到,諸如下面的形式:

Navigator.of(context).push(MaterialPageRoute(builder: (context) => SignUpPage()))
    .then((value) => print("the result from next page: $value"));
複製程式碼

再到 Navigator 2.0

一、用法介紹

Navigator 到目前為止,一切都執行良好,但是它的侷限也很明顯。首先,它無法一次性壓入多個頁面;其次,它只能彈出最上層的頁面,對於某些場景下彈出下層頁面的需求則無法滿足。所以 Navigator 2.0 就應運而生了。

在 Flutter 迭代到 1.22 版本後,關於 Navigator 的部分新增了一些新的 api:

  • Page —— 抽象的「頁面」的概念,對 Route 配置選項的一種描述;
  • Router —— 管家角色,應用中頁面開啟或關閉的排程員,監聽來自於系統的路由資訊(如啟動路由、新路由加入或者系統返回按鈕的訊息等);
  • RouteInformationProvider —— 更改路由獲取到的頁面的名字;
  • RouteInfomationParser —— 接收來自 RouteInfomationProviderRouteInfomation 並將其轉化為泛型約束的資料型別;
  • RouterDelegate —— 輸入來自 RouteInformationParser 的資料,負責將提供的 navigator 頁面插入檢視樹,同時接受監聽更新檢視;
  • BackButtonDispatcher —— 監聽返回按鈕事件。

Navigator 2.0 的概念和之前介紹過的 Flutter 檢視樹比較相似——Widget 儲存著檢視的配置,通過 Widget 物件建立對應的 ElementRenderObject——Page 物件是關於路由的配置的抽象的概念,而通過它的 createRoute() 方法建立 Route 物件。

1. Page

Page 是一個頁面的抽象,繼承自 RouteSettings 類,通過 name 屬性來標識頁面。正如 WidgetElement 通過 createElement() 方法,Page 中也有一個方法 createRoute() 用來建立 Route 例項。

通過上面 Navigator 1.0 的分析,我們知道 Route 是 Flutter 路由進行頁面切換的載體,包裹著真正的頁面在棧中「騰挪閃轉」,從而實現頁面切換的功能。

2. RouterInformationProvider

這個類通過它的 value 屬性傳遞值給 RouteInformationParser 類的 parseRouteInformation 方法,該值即 RouteInformation 物件,儲存路由的地址,通過該地址可以控制頁面跳轉。

例如當我們在瀏覽器的位址列輸入 「/index」字尾作為新的跳轉地址後,RouteInformationParser 類的 parseRouteInformation 方法即可接收到 location 屬性儲存有 「/index」值的 RouteInformation 物件。

3. RouteInformationParser

該類提供了兩個方法,分別是 parseRouteInformationrestoreRouteInformation

parseRouteInformation 方法接收地址資訊—— RouteInformation ,然後返回 Future<T> 型別物件,「T」是一個約定的任意型別,返回的 Future<T> 型別將在 RouterDelegate 類的 setNewRoutePath 方法被接收,可以在該方法中真正實現頁面新增跳轉的邏輯。

通常該方法的呼叫來自瀏覽器位址列輸入地址後跳轉,而我們通過 navigator 實現的介面跳轉不會導致該方法被呼叫。

restoreRouteInformation 方法用來恢復瀏覽歷史頁面,比如我們需要做「前進」或「後退」的功能而保持瀏覽器位址列中的地址不變,則可以通過 Router 類的 navigate() 方法強制上報路由資訊從而觸發該方法。該方法返回的 RouteInformation 物件被 parseRouteInformation 方法接收和處理。

4. RouterDelegate

該類是處理路由地址的主要類,頁面的壓入與彈出都在這個類中進行。

首先,這個類通過 setNewRoutePath 方法接收新的路由地址,然後對新的地址進行查詢(一般在使用者自己維護的路由表中),將對應的頁面壓入棧。其次,該類提供了 build 方法,Router 物件會呼叫該方法獲取檢視樹物件,所以該方法中應當返回能代表當前檢視樹的 Widget 物件,以供系統對顯示檢視進行更新。

5. Router

管理頁面的管家。它不僅負責頁面的構建,還負責業務邏輯的處理與分發。

上面介紹到 Navigator 2.0 的思想在於把一部分的頁面棧的操作許可權下放給使用者,在 App 中,如果我們需要對頁面棧進行排序、插入、多頁面插入、刪除、多頁面刪除,或者對瀏覽器更新與載入方式等進行操作時,需要用到上面介紹的一些物件,這些物件都在 Router 中持有引用,所以我們就可以使用 Router 物件獲取到這些物件的引用,而 Router 物件可以通過其靜態方法 of() 獲取。

大致的介紹就這麼多,用法可以看這個 demo。下面簡單串一下系統的執行流程。

二、原理分析

首先 MaterialApp.router() 構造方法會傳入 routeInformationParserrouterDelegate 等物件,_MateiralAppState 物件在 build() 方法中呼叫 _buildWidgetApp() 方法構造 WidgetsApp 物件,因為 routerDelegate 物件是必填欄位,所以 bool get _usesRouter => widget.routerDelegate != null; 欄位為 true,會通過 WidgetsApp.router 建構函式構造,然後在 _WidgetsAppState 類的 build() 方法中構造 Router 物件,所以它的層級結構如下(當然,它們之間還穿插著其他的包裝類):

router.png

Router 類繼承自 StatefulWidget,那麼老規矩,還是看 _RouterStatebuild() 方法:

Widget build(BuildContext context) {
  return _RouterScope(
    routeInformationProvider: widget.routeInformationProvider,
    backButtonDispatcher: widget.backButtonDispatcher,
    routeInformationParser: widget.routeInformationParser,
    routerDelegate: widget.routerDelegate,
    routerState: this,
    child: Builder(
      // We use a Builder so that the build method below
      // will have a BuildContext that contains the _RouterScope.
      builder: widget.routerDelegate.build,
    ),
  );
}
複製程式碼

可見,最終還是會呼叫 RouterDelegatebuild() 方法來建立頁面,該方法由開發者實現。

我們對該方法的實現如下:

@override
Widget build(BuildContext context) {
    return Navigator(
        key: navigatorKey,
        pages: List.of(_pages),
        onPopPage: (route, result) {
            if (_pages.length > 1 && route.settings is MyPage) {
                final MyPage<dynamic>? removed = _pages.lastWhere(
                    (element) => element.name == route.settings.name,
                );
                if (removed != null) {
                    _pages.remove(removed);
                    notifyListeners();
                }
            }

            return route.didPop(result);
        },
    );
}
複製程式碼

Navigator 都很熟悉了,但是這裡的用法又和上面介紹的兩種用法都不一樣。

這裡通過 Navigatorpages 屬性,將頁面列表 List<Page> 傳遞進去,當檢視配置有變更時,觸發檢視更新,此方法被呼叫,然後通過比較 pages 是否已產生變化,來決定是否更新頁面,最終會呼叫 Navigator_updatePages 方法。這個方法的內容有點多,我們就不做具體說明了,只大概說一下它的工作流程。

這個方法比較新的 pages 列表和舊的 _history 列表(元素為 _RouteEntry 型別),然後產生新的 _history 列表。這個方法大致和 RenderObjectElement.updateChildren() 方法流程相同。

需要注意的是,這個方法全程在圍繞著兩個列表進行——舊的路由列表 _history 以及新的頁面列表 widget.pages,我們把前者稱為「oldEntries」,把後者稱為 「newPages」,通過兩個列表共同比對,剔除 oldEntries 中非 Page 型的節點,而用 newPages 中的節點更新對應的 oldEntries 的節點。

  1. 首先從 List 頭開始同步節點,並記錄非 Page 的路由,直到匹配完所有的節點。

  2. 從 List 尾部開始遍歷,但不同步節點,直到不再有匹配的節點,然後最後同步所有的節點,之所以這麼做,是因為我們想以從頭到尾的順序來同步這些節點。此時,我們將舊 List 和新 List 縮小到節點不再匹配的位置。

  3. 遍歷舊列表被收縮的部分,獲得一個儲存 Key 值的 List。

  4. 正向遍歷新 List 被收縮的部分(即去除已遍歷兩端的中間部分):

    • 對無 Key 元素建立 _RouteEntry 物件並將其記錄為 transitionDelegate(轉場頁面);
    • 同步有 Key 的元素列表(如果存在的話)。
  5. 再次遍歷舊 List 被收縮的部分,並記錄 _RouteEntry 和非 Page 路由(需要從 transitionDelegate 中被移除)。

  6. 從列表尾部再次遍歷,同步節點狀態,並記錄非 Page 頁面。

  7. 根據 transitionDelegate 配置轉場效果。

  8. 將非 Page 路由重新填充回新的 _history

更新過 _history 之後,剩下的流程就和 Navigator 1.0 中介紹的相同了——通過 Overlay 物件更新頁面棧,完成頁面顯示和切換的需求。

Navigator 2.0 的思路就是將頁面的排列和更替通過一個 Page 列表—— pages 完全交給開發者,開發者只需要維護好 pages,轉化為真正可顯示的介面的過程就交給 Flutter engine 即可。


  1. 本文中關於 Navigator 2.0 的部分理解學習了這篇文章,demo 也是根據文章中的 demo 參考而得。

相關文章