Google 官方原文链接:Flutter Router
概述
引入一种声明式 API 来设置导航器的历史堆栈,以及一个新的路由器小部件,以便根据应用状态和系统事件来配置导航器。
作者: Michael Goderbauer (@goderbauer)
链接: flutter.dev/go/navigato…
创建时间: 2019.09 / 上次更新时间: 2019.10
目标
这份文档描述了一个用于导航器的新 API,它允许通过提供一系列不可变的页面以声明式的方式设置导航器的历史堆栈。这些页面将被转换为存在于导航器堆栈上的实际路由,这与 Flutter 框架将不可变的小部件扩展为元素以在屏幕上呈现它们的方式类似。文档还描述了现有的命令式导航器 API(push()、pop()、replace() 等)的实现如何进行重构,以便与新的声明式 API 协同工作。最后但同样重要的是,文档引入了一个新的路由器小部件,它可以包裹在导航器周围。路由器小部件根据来自操作系统的事件和应用状态配置导航器要显示的页面列表:它可以从操作系统获取最初请求的初始路由,并相应地通过新引入的声明式 API 配置导航器的历史堆栈。或者,当应用正在运行时接收到新的意图时,路由器可以重新配置导航器以转到由该意图指定的新路由。路由器还通过从导航器堆栈中删除最顶层的路由来响应系统后退按钮的点击。同时,路由器也可以在应用内部用于配置导航器,例如响应用户输入。
目标
- 导航器的历史栈可以由开发人员以声明方式进行设置和修改。
- 导航器的命令式 API(push()、pop()、replace() 及其他相关方法)继续有效,并且两种 API 可以在同一个应用程序中一起使用。
- 导航器将声明式提供的历史堆栈转换为新提供的历史堆栈的方式可以进行自定义。
- 有一个路由小部件可用,它包裹导航器并根据系统事件和应用状态重新配置其历史记录栈,支持以下内容:
- 当应用启动时,路由可以配置导航器以显示操作系统请求的初始路由。
- 当新的意图(例如来自操作系统)到达时,路由可以重新配置导航器以显示该意图指定的路由。
- 当用户点击系统返回按钮以弹出最顶层的路由时,路由可以适当地重新配置导航器。
- 开发人员可以指示路由根据自己的喜好重新配置导航器的历史记录栈,例如,为了响应用户交互显示新的路由。
- 开发人员可以自定义上述路由器行为(包括操作系统提供的路由字符串如何被解析为路由栈)。
- 路由器/导航器可以嵌套,按下系统返回按钮会从最合适的导航器中弹出路由(例如,在大多数情况下,路由应首先从最内层的导航器中弹出)。
背景 & 动机
这一部分讨论了当前导航器 API 的功能及其不足之处。目前,Flutter 提供了两种配置和修改导航器历史记录栈的方法:初始路由属性和命令式 API(push()
、pop()
、pushNamed()
、pushAndRemoveUntil()
等)。
Initial Route
initialRoute
参数仅在首次构建导航器(Navigator
)以设置导航器应显示的首个路由时才会生效。它通常被设置为Window.defaultRouteName
,该属性包含应用程序启动时操作系统所请求的路由名称。在导航器首次构建之后再更改 initialRoute
参数是没有效果的,这使得开发人员在应用程序运行时没有好的办法来妥善响应传入的意图:他们无法轻松替换导航器的历史记录栈以显示意图中所请求的路由。
命令式 API
导航器(Navigator)的命令式API是作为导航器上的静态方法实现的,这些方法会转发到导航器状态(NavigatorState)的实例方法。该 API 允许开发人员将新路由推送到导航器上,并移除现有的路由。这个 API 通常用于响应应用程序内的用户输入:例如,当用户在应用栏(AppBar
)中点击返回按钮时,会调用pop()
方法来移除最顶层的路由。当用户点击一个元素以打开详细视图时,会调用push()
方法来显示一个包含所点击元素详细信息的路由。就目前的实现方式而言,命令式 API 允许对历史记录栈进行非常有针对性的修改,但灵活性不足:它不允许开发人员按照自己的喜好自由地修改和重新排列历史记录栈1。
这导致了许多 feature requests,开发人员要求对命令式API进行扩展。从根本上说,这些功能请求大多本质上是要求对导航器的历史记录栈拥有完全的控制权。 正如 Flutter 的一项用户研究中的一位参与者所提到的,该 API 也感觉过时,不太符合 Flutter 的风格。在 Flutter 中,如果你想让一个部件(widget)有一组不同的子部件,你只需用一组新的子部件重新构建该部件即可。如果你想让导航器有一组不同的路由作为子项,嗯,你不能仅仅重新构建导航器。目前,你必须使用有点笨拙的命令式 API 来实现你的目标。
嵌套导航
当前情况下,开发人员的另一个痛点是嵌套导航器。嵌套导航器在选项卡式用户界面中很常见,每个选项卡都有自己的导航器嵌套在一个根导航器下。嵌套导航器跟踪一个选项卡内的路由历史记录。目前,Flutter 仅将系统返回按钮连接到根导航器,如果用户在选项卡内导航到特定路由,这可能会给用户带来困惑:点击系统返回按钮不会将他们返回到该选项卡内的上一个路由。相反,它将弹出全局导航器,并从全局历史记录栈中删除带有整个选项卡式界面的路由。
概述
该部分从未来使用这些 API 的开发人员的角度概述了 Navigator
的声明式 API 的拟议新设计以及新引入的 Router
小部件。请参考下面的“详细设计”部分以了解这些 API 背后的实现细节。
Navigator
该部分向导航器引入了页面(Page)的概念,并将导航器管理的路线分为两组:一些路线由页面支持,而其他路线则没有。后者被称为无页面路线。
Pages
为了声明式地设置导航器的历史栈,开发人员将在导航器小部件的构造函数中提供一个页面对象列表。页面对象是不可变的,并描述了应放入导航器历史栈中的路由。导航器将页面对象扩展为路由对象。这样,页面和路由之间的关系类似于小部件和元素之间的关系:小部件或页面分别描述了实际元素或路由的配置。
页面对象始终可以生成一个相应的路由并将其放入历史记录栈中。然而,并非每个路由都对应一个页面(请参见下面的“无页面路由”部分)。
开发人员可以自由实现他们自己的页面对象,或者他们可以使用框架提供的页面构建器之一。该框架将有页面构建器,这些构建器可以接收路由(Route
)或小部件(Widget
)。对于后者的情况,特定的页面构建器将用适当的路由(例如 MaterialPageRoute
等)包装小部件。
历史堆栈中与页面相对应的路由顺序与提供给导航器的列表中其相应页面的顺序相同。当提供给导航器的页面列表更新时,新列表会与旧列表进行比较,并且路由历史会相应地更新。
- 属于在新列表中不再存在的页面的路由将从历史记录中删除。
- 存在于新列表中但尚未有相应路由的页面将被展开,生成的路由将在适当位置添加到历史记录中。
- 历史记录栈中的路由顺序将更新,以与新列表中它们相应页面的顺序相匹配。
transition delegate决定了那些页面被添加到列表中或从列表中移除的路由如何在屏幕上出现或消失(请参见下面的“transition delegate”部分)。
除了页面列表之外,导航器还接受一个新的 onPopPage
回调。导航器调用此方法——通常是响应 Navigator.pop()
调用——以请求弹出与页面对应的给定路由。如果此回调的接收者同意,它会在路由上调用 didPop
。如果成功,它必须使用不再包含与弹出路由对应的页面的页面列表更新导航器,并从 onPopPage
回调中返回 true。如果弹出的页面未从提供给导航器的列表中删除,它将被视为一个新页面,该页面会膨胀为一个新路由。如果 onPopPage
回调的接收者不希望弹出路由,则只需返回 false(无需在路由上调用 didPop
)。onPopPage
回调仅针对最顶层的页面调用。
Pageless Routes
导航器现有的命令式 API 将路由添加到历史记录栈中(通过 push()
及相关方法),这些路由并不对应一个页面。为了尽量减少对现有应用程序的破坏,这条代码路径将继续有效。在新的世界中,无页面路由与历史记录栈中它们下方的、确实对应一个页面的路由相关联。如果一个对应页面的路由在路由历史记录中被移动到不同的位置,所有与之相关联的无页面路由也会移动到新位置。在移动过程中,与给定路由相关联的无页面路由内部的顺序保持不变。如果一个对应页面的路由从路由历史记录中被移除,所有与之相关联的无页面路由也会被移除(它们的退出过渡可以通过过渡委托来控制)。
导航器上现有的 initialRoute
属性在导航器首次插入到树中时也会生成一个无页面路由列表。从那个 initialRoute
字符串生成的路由将被放置在从提供给导航器的页面列表膨胀而来的所有路由之上。然而,在实际中,不鼓励同时提供 initialRoute
字符串和初始页面列表。相反,初始路由应该只定义为页面列表,而 initialRoute
字符串应该留空。
Transition Delegate
过渡委托对象必须决定当相应页面被添加到导航器提供的列表中或从该列表中移除时,路由应如何进入或退出屏幕。为此,过渡委托对象必须做出两个决定:
- 当添加或移除路由时,路由应该以动画形式出现/消失吗?还是应该直接出现/消失?
- 当在历史堆栈中的同一位置添加和移除路由时,在过渡期间应该如何对它们进行排序?
让我们看一个例子来更好地理解第二个问题:假设提供给导航器的页面列表从 [A, B] 变为 [A, C]。这种转变有两种可能的方式:
- C(可能带有动画效果)被添加在 B 之上,同时 B(可能带有动画效果)从下面被移除(这个移除可能会延迟到 C 的动画完成)。
- C(可能带有动画效果)被添加在 B 之下,并且 B(可能带有动画效果)在它上面过渡出去以显示 C。
第一种选择的视觉效果会让它看起来像是 C 被推到了 B 的上面。第二种选择看起来像是 B 被弹出以显示 C。
要获得选项 #1 所描述的视觉效果,在过渡期间导航器堆栈中的顺序必须是 [A、B、C]。对于选项 #2,顺序必须是 [A、C、B]。仅通过查看提供给导航器的旧页面和新页面列表无法确定所需的效果。因此,确定页面应如何排序以实现所需效果是过渡委托的责任。
当页面列表发生变化时,会向过渡委托提供一系列历史差异对象,历史中每一个有页面添加或删除的位置对应一个历史差异对象。历史差异包含添加到该位置的有序路由列表和从该位置移除的有序路由列表。作为上下文,它还可以看到在差异位置之前的历史栈中有哪些路由,以及在该位置之后的历史栈中有哪些路由。现在,过渡委托负责确定添加和删除列表中的每个路由是否应该进行动画效果。此外,委托必须返回一个新列表,该列表合并添加和删除列表以确定所需的顺序。在添加和删除列表中路由的相对顺序必须在合并列表中保留(该顺序只能通过向导航器提供新的页面列表来更改)。
过渡委托也可以决定与已移除的路由绑定的无页面路由应如何离开屏幕。为此,历史差异包含一个从“有页面的路由”到“该路由拥有的无页面路由列表”的映射。只有来自已移除列表的路由可能在此处有一个条目,因为新添加的路由在添加时不能拥有任何无页面路由。不过,过渡委托不能修改无页面路由的顺序。它们将始终位于其拥有的基于页面的路由的顶部,并且它们的生命周期与其生命周期绑定。
开发者可以在导航器上配置过渡委托。开发者可以为页面列表的每次更新选择提供不同的委托,以实现不同的过渡风格。
如果未提供自定义委托,则将使用默认委托。默认委托实现如上述选项 #1 中所描述的类似推送的效果。对于每个历史差异,它将所有添加的路由置于任何已删除的路由之上,并且仅在历史堆栈的最顶层发生变化时才会为路由转换设置动画。
- 如果要添加最顶层的路由,它将把该路由以动画形式加入。当该过渡完成时,所有其他路由将在无动画的情况下被添加/移除(这假设新推入的路由隐藏这些过渡以避免视觉故障)。
- 如果要移除路由,它将把该路由以动画形式移除。在过渡开始之前,所有其他路由将在无动画的情况下被添加/移除(这假设即将弹出的路由隐藏这些过渡以避免视觉故障)。
概要
总之,这份文档对公共导航器 API 提出了以下更改:
- 引入一个新的类 Page,它作为创建路由的蓝图。
- 向导航器 API 添加以下属性:
List<Page>
pages,用于声明式地设置路由历史记录。OnPopPageCallback
onPopPage,它允许导航器弹出一个 Page。TransitionDelegate
transitionDelegate,用于自定义添加/删除 Page 的过渡效果。
Router
Router
是一个新的小部件,是一个用于打开和关闭应用程序页面的调度器。它包裹着一个Navigator
,并根据当前应用程序状态配置其当前页面列表。此外,Router
还监听来自操作系统的事件,并可以响应这些事件来更改Navigator
的配置。
使用新的Router
小部件的应用程序可以在其应用状态中管理当前在屏幕上显示的内容。与其使用命令式 API 在用户点击按钮时显示新的路由,不如让按钮的点击处理程序修改该状态。Router
被注册以监听应用状态的变化,并将使用新配置的Navigator
进行重建以反映这些变化。当Navigator
使用新页面重新配置时,这可能会导致新的路由出现在屏幕上。“Router”的这种使用示例过程在以下图表中进行了说明。
Router
了解应用状态变化的具体行为方式以及它如何响应这些变化是由 routerDelegate 配置的。Router
的用户必须自定义实现这个委托以使其适应他们的应用需求。他们可以选择让委托监听应用状态的变化,如上文所示,但这不是使用Router
的必要条件。
Router
还可以帮助开发人员监听来自操作系统的路由相关事件。Router
旨在支持以下系统事件:
- 获取应用首次启动时操作系统请求的初始路由。
- 监听来自操作系统的新意图,这些新意图可能请求显示新的路由。
- 监听来自操作系统的弹出历史堆栈中最后一个路由的请求。
Router
如何监听这些事件是由 routeNameProvider
委托和 backButtonDispatcher
委托配置的。表示操作系统请求的路由的字符串,无论是作为初始路由还是来自意图内部,都由 routeNameParser
委托进行解析。这个委托将 routeNameProvider
提供的字符串转换为类型为 T 的解析路由数据,T 是“路由器”的泛型类型参数。该框架为这些委托提供了默认实现,这对于大多数用例应该是足够的。当使用默认委托时,T 将是RouteSettings的列表。
来自路由名称解析器的已解析路由数据和来自后退按钮分发器的关于后退按钮按下的通知被传递给路由委托,路由委托可以根据这些信息使用新的页面列表重建Navigator
。在上面的图表给出的示例中,路由委托将使用这些通知根据通知中的信息重新配置应用状态,然后重建Navigator
以反映应用状态的这些变化。
以下图表说明了信息在委托中的流动:
路由器委托与应用程序状态进行通信的部分是可选的,并且取决于用户提供的路由器委托的具体实现。返回按钮分发器和路由名称提供程序在哪里以及如何监听事件(不一定是操作系统)可以通过提供这些委托的自定义实现来进行定制。
Route Name Provider
routeNameProvider
委托决定了 Router
如何了解操作系统想要显示的路由。它是一个字符串的 ValueListenable
,当 Router
首次构建时,其当前值用于确定显示的初始路由。每当可监听对象的值发生变化时(这可能在从操作系统接收到显示不同路由的新意图时发生),Router
会得到通知,并可以通过提供新的页面列表来更改 Navigator
的配置以显示该路由。
从可监听值(ValueListenable
)中获取的字符串由路由名称解析器委托(routeNameParser delegate)解析为类型 T 的数据。解析后的数据被传递给路由委托(routerDelegate),路由委托可能会根据此信息决定使用新的页面列表重建导航器。
提供的默认 routeNameProvider
是一个字符串类型的 ValueNotifier
,它只是将Window.defaultRouteName包装起来作为初始值,并监听WidgetsBindingObserver.didPushRoute。当后者触发时,routeNameProvider
(即 Router
)的监听器会被告知操作系统有一个新的路由需要处理。
默认的 routeNameProvider
对于大多数用户应该是足够的,只有极少数用户可能会选择提供自定义实现。
Route Name Parser
The routeNameParser delegate gets the current route string from the routeNameProvider and turns it into data of type T. That data is used by the routerDelegate to configure the Navigator to show the appropriate route.
The default routeNameParser will parse the provided string into a list of RouteSettings, where each element of the List represents a Page that should be pushed onto the navigator. The default delegate assumes that route strings have the following shape: /foo/bar?id=20&name=mike. This string will be parsed into three RouteSettings for the routes /, /foo, and /foo/bar and each RouteSetting will have the arguments {'id': '20', 'name': 'mike'} associated with it.
It's expected that the default routeNameParser is sufficient for most cases and that this delegate will be rarely customized.
Router Delegate
路由委托(Router Delegate)是 Router
的核心,负责根据其可用的信息构建一个适当配置的 Navigator
。路由委托本身是一个 Listenable,Router
小部件会订阅它。当路由委托需要更改 Navigator
的配置(例如,改变提供给 Navigator
的页面列表)时,它会通知其监听器,从而触发 Router
小部件重新构建。
在重新构建过程中,Router
会请求路由委托返回一个正确配置的 Navigator
实例,并将该实例集成到组件树中。
框架并未为路由委托提供默认实现,因为其行为高度依赖于具体应用程序的需求。开发者可以选择让路由委托监听应用状态的变化,并根据当前的应用状态重新配置 Navigator
,以展示适合该状态的路由。
routerDelegate
还会接收与路由相关的系统事件通知:
popRoute
:当backButtonDispatcher
通知 Router 用户按下了系统返回按钮时调用该方法。setInitialRoutePath
:在 Router 第一次构建后不久,根据从routeNameProvider
获取的初始路由信息调用此方法。路由名称会先由routeNameParser
解析后传递给该方法。默认情况下,此方法直接转发到setNewRoutePath
。setNewRoutePath
:当routeNameProvider
指示需要显示新路由时调用该方法。路由名称会先由routeNameParser
解析后传递给该方法。
Router
不对 routerDelegate
如何处理这些通知作任何假设。可能的选项包括:
- 忽略这些事件,不做任何操作。
- 调整应用状态以反映这些通知所请求的更改,并请求 Router 使用重新配置的
Navigator
重新构建。 - 直接请求 Router 使用新配置的
Navigator
重新构建。
Back Button Dispatcher
backButtonDispatcher
委托会通知 Router
,用户已点击了系统的返回按钮,因此想要返回到上一个路由。这仅在具有系统返回按钮的平台上使用(例如 Android)。
backButtonDispatcher
被实现为一个 可监听对象,Router
订阅了它。每当 dispatcher 认为被路由器包裹的导航器应该弹出当前页面时,因为用户按下了系统返回按钮,它就会通知其监听者。
调度程序可以选择不通知自己的路由器监听器,而是通知子后退按钮调度程序,并让子路由器监听器处理后退按钮按下操作。此功能允许在例如选项卡式界面中嵌套路由器/导航器。该设计不限制嵌套级别,并且子后退按钮调度程序又可以有另一个子级。
该框架提供了两个具体的backButtonDispatcher
实现:一个默认实现通常用于根路由器,一个子backButtonDispatcher
实现通常用于嵌套的路由器。
根路由器的默认实现仅监听WidgetsBindingObserver.didPopRoute以确定是否按下了返回按钮。如果有子backButtonDispatcher
请求优先于根分发器,它要么通知其监听器,要么将通知转发给子backButtonDispatcher
。当多个子对象声明优先级时,最后声明的子对象将获得通知。当该子对象决定不再需要优先级时,在此之前声明优先级的子对象将获得通知。如果没有其他子对象声明优先级,则通知将像往常一样分发给父对象的监听器。
ChildBackButtonDispatcher
本身不监听任何系统事件。一旦它在父分发器上声明了优先级,它就只会获得父分发器的通知。为了声明优先级,它需要对其父对象的引用。为了获得该引用,路由器被实现为一个InheritedWidget,带有一个静态方法来获取上下文中最近的Router
小部件。该路由器小部件有一个对其backButtonDispatcher
的引用,该引用被用作ChildBackButtonDispatcher
的父对象。
Example Usage
要使用带有新导航器 API 的Router
,开发人员基本上只需要实现他们自己的自定义路由委托。对于所有其他委托,默认值应该就足够了。本节展示了如何将Router
和新的Navigator
API 用于股票应用程序。
对于这个示例,我们假设股票应用有三个屏幕:
- 一个主页,显示收藏的股票代码列表(点击代码可进入其详细信息页面)和一个搜索图标(点击搜索图标可进入搜索页面)。
- 一个详细信息页面,展示特定股票的详细信息,并带有一个返回按钮可返回到上一个屏幕。
- 一个搜索页面,带有搜索栏、返回按钮和结果列表。点击结果可进入其详细信息页面。点击返回按钮可返回到上一个屏幕。
这个应用的应用状态(或模型)看起来如下所示。正如这篇文章中所解释的,它在应用中通过继承自 InheritedWidget
的方式提供。应用状态是一个变更通知器,以便 RouterDelegate
监听变化。
class StockAppState extends ChangeNotifier {
// If non-null: show the search page with this initial query.
String get searchQuery;
String _searchQuery;
set searchQuery(String value) {
if (value == _searchQuery) {
return;
}
_searchQuery = value;
notifyListeners();
}
// If non-null: Show the details page for this symbol.
String get stockSymbol;
String _stockSymbol;
set stockSymbol(String value) {
if (value == _stockSymbol) {
return;
}
_stockSymbol = value;
notifyListeners();
}
// Show these symbols on the home screen.
final List<String> favoriteStockSymbols; // Loaded from e.g. a database.
}
这个应用程序状态现在可以被开发者必须实现的自定义RouterDelegate
使用。RouterDelegate
被传递给路由器。Router
的所有其他委托可以保持其默认实现。
class StockAppRouteDelegate extends RouterDelegate<List<RouteSettings>>
with PopNavigatorRouterDelegateMixin {
StockAppRouteDelegate(this.state) {
state.addListener(notifyListeners);
}
void dispose() {
state.removeListener(notifyListeners);
}
final StockAppState state;
@override // From PopNavigatorRouterDelegateMixin.
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@override
void setNewRoutePath(List<RouteSettings> configuration) {
if (configuration.length != 1 || configuration.single.name != '/') {
// Don't do anything if the route is invalid.
return;
}
// Update state; if this modifies the state it will call our listener,
// which will cause a rebuild.
state.searchQuery = configuration.single.arguments['searchQuery'];
state.stockSymbol = configuration.single.arguments['stockSymbol'];
}
@override
Widget build(BuildContext context) {
// Return a Navigator with a list of Pages representing the current app
// state.
return Navigator(
key: navigatorKey,
onPopPage: _handlePopPage,
pages: [
MaterialPageBuilder(
key: ValueKey<String>('home'),
builder: (context) => HomePageWidget(),
),
if (state.searchQuery != null)
MaterialPageBuilder(
key: ValueKey<String>('search'),
builder: (context) => SearchPageWidget(),
),
if (state.stockSymbol != null)
MaterialPageBuilder(
key: ValueKey<String>('details'),
builder: (context) => DetailsPageWidget(),
),
],
);
}
bool _handlePopPage(Route<dynamic> route, dynamic result) {
Page page = route.settings;
if (page.key == ValueKey<String>('home')) {
assert(!route.willHandlePopInternally);
// Do not pop the home page.
return false;
}
final bool result = route.didPop(result);
assert(result);
// Update state to remove the page in question; if this modifies the state
// it will call our listener, which will cause a rebuild.
if (page.key == ValueKey<String>('search')) {
state.searchQuery = null;
return true;
}
if (page.key == ValueKey<String>('details')) {
state.stockSymbol = null;
return true;
}
assert(false); // We should never be asked to pop anything else.
return true;
}
}
命令式 API 和声明式 API 的共存
如上文所述,并在以下部分进行解释,导航器的现有命令式 API 和新的声明式 API(可能与新的路由器结合使用)可以在同一个应用程序中并行使用。然而,这可能会导致一些代码重复,因为一些路由字符串将不得不在导航器和路由器中进行解析。建议应用程序决定使用一种风格。希望中型到大型应用程序将选择通过路由器配置导航器的历史堆栈,并且仅使用命令式 API 来显示非常短暂的路由,如对话框和警报。灵活的路由器方法更具前瞻性,因为它将使在框架中集成和支持新功能(如可链接性以及保存/恢复当前实例状态(例如,当操作系统由于内存不足在后台终止应用程序时))变得更加容易。
Detailed Design
本节包含关于概述章节中所概述的设计的特定方面的更多细节。
Navigator
本节讨论Pages
是如何实现的,Navigator
如何跟踪其路线的当前状态,以及当Navigator
的页面列表更新时它会做什么。
Pages
Route 已经有一个用于 RouteSettings
的属性,并且 Pages
基本上是加强版的 RouteSettings
,因为一个 Page
本质上描述了一个 Route
的配置。因此,将 Pages
实现为现有 RouteSettings
类的子类是有意义的。
在实现方面,每个页面可能有一个可选的Key,类似于小部件可能有一个键。当提供给导航器的页面列表更新时(参见“更新页面”),该键用于确定给定页面是否代表已展开页面的当前配置。由于页面仅在一维列表中移动,因此LocalKey在这里就足够了。
除此之外,页面还必须实现一个 createRoute 方法。它接收一个 BuildContext 作为参数,并且必须返回与该页面相对应的已展开的路由。当页面首次被添加到导航器的历史堆栈中时,会调用此方法。此方法返回的路由必须将其settings属性设置为相应的页面。
最后但同样重要的是,页面还实现了一个 canUpdate 方法,当向导航器提供新的页面列表时,会查询此方法以确定旧列表中存在的与页面相对应的路由是否可以由新列表中的页面更新(请参阅“更新页面”)。这与Widget.canUpdate的使用方式类似。默认实现当旧页面和新页面具有相同的运行时类型和相同的键时返回 true。
综合考虑所有这些因素,页面的界面如下所示:
abstract class Page<T> extends RouteSettings {
const Page({
this.key,
String name,
Object arguments,
}) : super(name: name, arguments: arguments);
final LocalKey key;
bool canUpdate(Page<dynamic> other) {
return other.runtimeType == runtimeType && other.key == key;
}
Route<T> createRoute(BuildContext context);
}
Route State Machine
命令式 API 和声明式 API 都能将路由从一个生命周期状态转换到下一个状态,同时触发这些状态转换在路由上带来的副作用。例如,当请求导航器弹出一个路由时,它将通过调用 Route.didPop() 触发路由退出转换,并在处理路由之前等待动画完成。对于导航器来说,弹出路由的请求将路由的生命周期状态从“空闲”经过“等待弹出完成”转换为“已处理”。目前,这些生命周期状态变化在命令式 API 中是隐式编码的。
声明式 API 将不得不使路由通过与命令式 API 相同的生命周期状态进行转换。为了避免重复,生命周期转换被提取到一个共享方法中,并且两个 API 在调用中心方法之前只会用它们需要在其上执行的转换来标记路由。那个名为 flushHistoryUpdates 的方法将执行实际转换,并触发路由上随转换而来的所有副作用。
以下图表描述了在这个新世界中路由可以经历的生命周期转换。用星号标记的瞬态状态被命令式和声明式 API 用作标记状态,以告诉 flushHistoryUpdates 接下来需要在路由上执行什么转换。一旦 flushHistoryUpdates 运行,路由将离开该状态进入下一个状态。一个“#”表示路由保持在该状态,直到指定事件触发。
以下事件决定了路由的当前生命周期状态:
- 通过解析初始路由添加到历史堆栈的路由,从
add*
状态开始。 - 通过 push() 和 pushAndRemoveUntil() 添加的路由,从
push*
状态开始。通过pushAndRemoveUntil()
移除的路由会进入remove*
状态。 - 通过 pushReplacement() 添加的路由,从
pushReplace*
状态开始,被替换的路由会进入remove*
状态。 - 通过 replace() 添加的路由,从
replace*
状态开始,被替换的路由会进入remove*
状态。 - 调用 pop() 时,如果路由无法内部处理弹出操作(例如 LocalHistoryRoute),最顶部的路由可能会进入
pop*
状态。 - 添加到提供给 Navigator 的 Pages 列表中的页面,根据过渡委托(transition delegate)的决定,可能从
push*
、replace*
或add*
状态开始。 - 从提供给 Navigator 的 Pages 列表中移除的页面,根据过渡委托的决定,可能进入
pop*
、complete*
或remove*
状态。 - 路由从
pushing#
状态前进,当push
动画完成后(即 Route.didPush() 返回的 Future 完成)。 - 路由从
popping#
状态前进,当 Navigator 调用 finalizeRoute() 表明弹出动画完成时。 - 路由从
removing#
和adding#
状态前进,当所有位于其上方的push
动画完成后,或者如果其顶部至少有一个空闲路由,则会立即完成,以避免可见的视觉问题。
在所有由星号标记的瞬态状态变更处理完成之前,路由不会被通知其新的前后路由(通过 didChangeNext() 或 didChangePrevious())。这样可以避免通知即将消失的瞬态邻居路由。路由也只会被告知下一个邻居中的 active 路由。
未在图表中展示的一种情况是,当一个路由仍处于 pushing#
状态时被移除。这种情况是完全支持的:处于 pushing#
状态的路由可以跳过 idle
状态,直接进入 idle
之后的某个状态。
路由对象及其当前生命周期状态被封装在 RouteEntry
对象中。Navigator
的路由历史堆栈只是这些 RouteEntry
的列表,其中最后一项属于最顶部的路由。
Updating Pages
当提供给导航器的页面列表发生变化时,历史堆栈必须按照“页面”部分中的描述进行更新。为此,导航器通过将新列表中的页面与旧历史堆栈中的路由进行匹配来构建新的历史堆栈。如果对与该路由相关联的旧页面调用 canUpdate(pageFromTheNewList) 返回 true,则页面与路由匹配。默认情况下,当旧页面和新页面具有相同的运行时类型并且如果提供了键且它们的键相等时,这为 true。如果页面与路由匹配,则将路由的设置更新为新页面(如果新页面与旧页面不同,这将导致路由重建)。然后,该路由的 RouteEntry 以及它所拥有的所有无页面路由都将被复制到新的历史记录中。
如果在旧历史记录中没有与页面匹配的内容,则通过调用 createRoute 来扩展页面。生成的路由被包装在一个新的 RouteEntry
中并放入新的历史记录中。
对新列表中的所有页面重复此过程。最后,未与页面匹配的路由被标记为已删除,并相对于其他已匹配的路由在其旧位置重新插入到历史堆栈中。这些路由必须在历史记录中保留更长一点时间,因为导航器可能需要触发并等待退出过渡,然后才能处理这些路由。
已经过了空闲生命周期状态的路由不能再与页面匹配,因为它们基本上正在退出。
过渡委托决定删除和添加的路由以何种顺序添加到新的历史记录中。它还决定这些路由如何进出过渡。
Transition Delegate
从技术角度来说,过渡委托方必须决定新展开的路由在哪个生命周期状态下被添加到历史堆栈中。状态机图中的可能选项有:push*、replace* 和add*。类似地,它必须决定从历史堆栈中移除的过渡路由在空闲状态下应采取哪种方式。这里的选择有:pop*、remove* 和complete*。
上一节中描述的合并历史堆栈被拆分为多个历史差异,这些差异逐个提供给过渡委托方。下面的例子说明了这些差异是如何创建的(小写字母用于表示无页面的路由,大写字母表示由页面支持的路由,PA 表示与路由 A 对应的页面)。
Old Route History: | A, B, x, y, z, C, D |
---|---|
New Page List: | PA, PE, PF, PD, PG |
Merged Route History: | A, |
这将产生两个历史差异。第一个包含以下信息:
Page-based Routes added: | E, F |
---|---|
Page-based Routes removed: | B, C |
Mapping to pageless Routes: | { B => [x, y, z] } |
Routes before diff: | A |
Routes after diff: | D, G |
Diff number: | 1 of 2 |
接收此差异的过渡委托现在将调用 E 和 F 上的方法,将它们标记为 push*、replace* 或 add*。同样,它将调用 B、C 以及附加到它们的无页面路由 x、y、z 上的方法,将它们标记为 pop*、remove* 和 complete*。最后但同样重要的是,它将返回一个合并了添加和删除的路由的列表,以确定它们的顺序。假设它返回[E、B、F、C]。然后第二个 HistoryDiff 构造如下:
Page-based Routes added: | G |
---|---|
Page-based Routes removed: | n/a |
Mapping to pageless Routes: | { } |
Routes before diff: | A, E, B, x, y, z, F, C, D |
Routes after diff: | n/a |
Diff number: | 2 of 2 |
过渡委托处理此差异的方式与处理第一个差异类似。由于“差异后的路由”为空,过渡委托可以推断出这个历史差异位于历史堆栈的顶部。
在历史差异中作为添加和删除列表以及映射中的路由所包含的内容基本上是导航器在内部用于管理历史堆栈的路由条目(RouteEntries)的一个大部分为只读的视图。这个视图使过渡委托能够访问路由的当前生命周期状态及其路由设置(可能是一个页面)。它还公开了一些方法,可将这些路由条目移动到 push*、replace*、add*、pop*、remove* 或 complete* 生命周期状态。差异中包含的其他路由是路由条目的只读视图,无法访问这些额外的可变方法。
Routes & RouteSettings
目前,在不播放入口动画的情况下将路由添加到历史堆栈的唯一方法是在其路由设置中将其标记为初始路由。声明式 API 要求可以在任何时候添加路由而不进行动画。当路由被另一个路由覆盖并且播放动画根本没有意义时,这很有用。为了支持这一点,在路由接口中添加了一个 didAdd 方法。当路由应该在没有常规入口动画的情况下被添加时,导航器会调用这个方法而不是 didPush
。为了简化事情,这个新方法也将用于在屏幕上显示初始路由。这使得 RouteSettings.initialRoute
参数变得无用,它将从 RouteSettings
中删除。这是一个较小的破坏性更改。
Router
路由器与其各个委托之间的 API 完全是异步的。当有新的路由可用时,路由名称提供程序会异步通知路由器。路由名称解析器会返回一个 Future,在解析完成时完成。当有新的导航器配置可用时,路由器委托会异步通知路由器。等等。
异步性质在实现这些委托时为开发人员提供了更大的灵活性:路由名称解析器可能需要与 OEM 线程通信以获取有关路由的更多数据。这种通信只能异步发生,因此解析 API 也需要是异步的。类似的说法适用于大多数其他委托 API 方法。
当代理可以完全同步地执行其工作时,那么在其实现中应使用 SynchronousFuture。这将使路由器以完全同步的方式进行,这可能会稍微加快速度。然而,如果有必要,路由器完全支持异步工作。
为了实现正确的异步处理,在实现路由器时必须特别小心:当委托之一返回的未来完成时,需要检查创建该未来的请求是否仍然是当前请求。如果自那时以来已经发出了另一个更新的请求,则应忽略该未来的完成值。
Router Delegate
开发人员使用路由器时必须实现的 routerDelegate 具有以下接口:
abstract class RouterDelegate<T> implements Listenable {
void setInitialRoutePath(T configuration);
void setNewRoutePath(T configuration);
Future<bool> popRoute();
Widget build(BuildContext context);
}
RouterDelegate 的主要任务是在 Router 请求时,在其 build 方法中返回一个正确配置的 Navigator。Navigator 应配置有 RouterDelegate 想要在屏幕上显示的一系列页面。每当 RouterDelegate 想要更改其 build 方法返回的 Navigator 的配置时,它需要调用从超类继承的 notifyListeners()。这是 Router 小部件重建并通过再次调用委托的 build 方法从 RouteDelegate 请求新 Navigator 的信号。
RouterDelegate 的其他方法由 Router 在响应系统事件时调用:当从 routeNameProvider 检索到初始路由或新路由并且 routeNameParser 已解析路由字符串时,会调用 setInitialRoutePath 和 setNewRoutePath。作为对这些调用的响应,委托可以通过 notifyListeners 通知 Router,以使用传递给这些方法的参数所隐含的新配置请求重新构建 Navigator。
当 backButtonDispatcher 报告操作系统请求弹出当前路由时,Router 会调用 popRoute()方法。这很可能会导致路由委托将弹出操作转发给其 build 方法先前返回的 Navigator。如果路由委托能够处理弹出操作,它应该返回 true。否则,它应该返回 false。返回 false 可能会弹出周围 Navigator 的路由(可能是SystemNavigator),具体取决于 backButtonDispatcher 的具体实现。
Back Button Dispatcher
如概述部分所述,该框架将附带两个具体的后退按钮分发器实现(根后退按钮分发器和子后退按钮分发器),它们都实现以下接口:
abstract class BackButtonDispatcher implements Listenable {
void takePriority();
void deferTo(ChildBackButtonDispatcher child);
void forget(ChildBackButtonDispatcher child);
}
当在子返回按钮分发器上调用 takePriority() 方法时,它将在其父级上调用 deferTo() 方法。父级会在一个有序列表中记住所有调用了该方法的子级。当父级(或者在根返回按钮分发器的情况下是由操作系统)通知其返回按钮已被按下时,它会通过方法调用将此通知转发给该列表中的最后一个子级。如果列表为空,它将通过从其父类调用 notifyListeners() 方法来通知其路由器。如果子级不再希望接收返回按钮通知,它也可以在其父级上调用 forget() 方法。在这种情况下,父级会将该子级从其内部列表中移除。
当在任何返回按钮分发器上调用 takePriority() 方法时,分发器也将清空其内部子列表,不再将返回按钮通知转发给任何子级。
Integration
目前,Navigator
已集成到WidgetsApp中(并通过它也集成到MaterialApp中)。我们也希望将路由器集成到这些小部件中,以便其用户能够从中受益。最终,在 Flutter 应用中使用路由器有望成为与Navigator
交互的最佳方式。
虽然路由器(以及新的声明式导航器 API)被设计为与Navigator
现有的命令式 API 并行使用,但在常规的 MaterialApp/WidgetApp 构造函数中同时公开它们可能会令人困惑。为了减少这种困惑,我们建议创建一个新的构造函数 WidgetApp.withRouter 和 MaterialApp.withRouter。这些构造函数将不再公开与旧的命令式 API 相关联的Navigator
API。相反,这些构造函数将允许用户为路由器传入自定义委托。旧的构造函数保持不变,使用这些构造函数的人将无法获得路由器的好处。
也就是说,新的构造函数将不再允许用户传入以下参数。它们的功能应该为在自定义 routerDelegate 中实现。
- navigatorKey
- initialRoute
- onGenerateRoute
- onUnknownRoute
- navigatorObservers
- pageRouteBuilder
- routes
相反,他们可以传入本文档前面描述的路由器的各种委托。
Credits
此文档在某些部分是对 ianh@ 在 Hixie:router 中所做工作的扩展。
Document History
Date | Author | Description |
---|---|---|
2019-09-30 | goderbauer | Initial draft. |
2019-10-07 | goderbauer | Copied draft into a shareable document. |
2019-10-10 | goderbauer | Added section: "Example Usage" |
2019-10-11 | goderbauer | Replaces Navigator.removePage with Navigator.onPopPage API |
Footnotes
-
一些人认为 Navigator 的命令式 API 是世界上对列表变更 API 的最差实现(需要引用来源)。 ↩