Bolt 的 Flutter 路由管理实践(页面解耦,流程控制、功能拓展等)

本文同步发表于我的微信公众号「MeandNi」,点击扫码或在微信搜索「MeandNi」关注,阅读前沿技术,不致于落后时代

在各大移动开发框架(Android、iOS、Flutter、React Native…)中,路由管理始终是 UI 架构最具热议的话题之一。

一大原因就是应用程序的页面会 不可避免的多,我们可以使用 BLOC,MVP,MVI 等等模式将 UI 和业务逻辑合理分离实现良好的架构,但是如何将一个新页面合理地集成到现有的结构中还是一个比较大的难题。

Android 中,除了传统的 Intent / Fragment 事务方式,Google 也在 Jetpack 中专门提供了用于管理复杂页面逻辑的 Navigation 组件,Flutter 也推出了 Navigator2.0 来帮助我们适应各种不同路由场景。

本文源自国外 Bolt 团队的文章(原文 https://medium.com/flutter-community/navigation-done-right-a-case-for-hierarchical-routing-with-flutter-ca0aac1275ad ),总结了他们团队在构建 Flutter 应用时,管理路由页面的一些想法和方案,我觉得非常值得借鉴思考,经作者同意,结合我自己的一点理解翻译发表。

注:本文并非基于 Flutter Navigator2.0。

打破单页面间的耦合

假如我们需要开发一款应用,需要用户先填写一系列个人信息才能继续操作,那么也就需要开发一系列不同的页面让用户填写不同的信息,如兴趣爱好、地理位置、个性签名等等,用户填写完这些信息点击提交后也需要调用接口将数据传给后端。

实现这种功能最简单的方式就是在每个页面上放一个 “继续” 按钮,用户点击后触发路由操作,如下:

onPressed: () {
finalResult.setInput(_getCollectedInput());
Navigator.push(
context,
MaterialPageRoute(builder: (context) => NextPage(finalResult)),
);
}

按此操作,到了最后一个表单页面时,就可以提交最终结果了。这里,用户输入的数据如何在路由间传递倒是其次,我们可以暂存在内存某处,更重要的是如何在某个页面履行其职责后执行下面的操作。

有一个比较有意思的场景,如果之后我们想要继续开发可以让用户编辑这些信息的入口页面,我们是要重新再开发一系列编辑页面,还是复用之前的逻辑,按步骤一步一步编辑信息?

很显然,如果用户只想要修改一个用户名,而还要必须走完这一整个系列流程的话,就会非常影响体验,因此,我们可以像下图这样复用之前的页面 UI 并且能够单独修改每一项信息,当处于编辑模式时,可以点击 “保存” 直接更新相关信息:

此时,可以修改点击按钮的回调函数,如下所示,判断当前是否处于编辑模式下:

onPressed: () {
final input = _getCollectedInput();
if (_isInEditMode) {
_updateProfile(input);
} else {
_navigateToNextScreen();
}
}

虽然功能实现了,但在这个简单例子中,代码就已经显得有点臃肿了,在实际的项目中,我们可能还会处理更多路由相关的操作,这种响应用户操作的方式着实不太讲究。

这里,每个页面都和后一个页面都偶合在一起,并且每个页面都强制依赖各自需要提交的数据类型,在大型项目中,我们通常需要尽可能降低这种耦合、依赖,并且可以将同一类行为单独提取出来放在一起。

另外,如果以后我们还想要调整步骤顺序,引入新的步骤,势必还要大量修改原有的代码;再如果要是存在类型相似的信息(如填写用户名、个性签名的页面都只含有一个标题 Text 一个输入框 TextField,只能另写一个不同 UI 组件,这样,从代码复用性和可拓展性上来讲,这种方案都说不过去。

因此,我们本文我们就来着重探讨如何使路由相关操作与其他业务逻辑尽量解耦,减少依赖。

抽象出口点

一个很简单的解决方案即可打破这种单个屏幕的紧密耦合。这种方式需要建立在,我们已经确定当前页面履行其职责后下一步该干嘛,然后将该出口点抽象出来。

首先,我们可以写一个抽象类 LocationInputScreenListener,该类专门用来监听 填写地位位置页面 中相关的路由操作,即将它履行其职责后下一步该干嘛的操作抽象出来:

abstract class LocationInputScreenListener {

void onLocationEntered(LocationModel input);

void onBackPressed();
}

之后,我们可以用接口的方式处理页面跳转的事件,如下所示:

onPressed: () {
final input = _getCollectedInput();
_getListener().onLocationEntered(input);
}

这样,该页面除了依赖 LocationInputScreenListener 外,就相当于是完全独立的个体了。

那么,组件如何拿到这个 Listener,我们当然可以通过构造函数逐层传递,更好的方法是利用 Flutter 中状态可遗传的性质,因为 打开页面/改变页面状态 的操作完全是由上层组件 决定/触发 的,因此,我们可以将某个 Listener 放在一个祖先节点中,然后,在子组件中使用 context.findAncestorStateOfType 在得到它。

这样,我们可以用如下方式为 LocationInputScreenListener 赋能,把它作为组件状态:

abstract class LocationInputScreenListener<T extends StatefulWidget> implements State<T> {

void onLocationEntered(LocationModel input);

void onBackPressed();
}

如下代码所示,AncestorState 实现了 LocationInputScreenListener 后,我们就可以在子组件中使用 context.findAncestorStateOfType<LocationInputScreenListener> 直接找到该状态对象,并使用其中的方法:

class AncestorState extends State<AncestorWidget>
implements
LocationInputScreenListener<AncestorWidget>

这样,该接口就成了页面路由的既定规则,要想执行某些路由操作就要实现相关接口。通常,实现该接口的可以是组件的直接父组件,也可以实现多个接口响应不同父级的事件,示例应用中包含一个例子:LoggedInFlowControllerhttps://github.com/yarolegovich/flutter_navigation/blob/master/lib/root/loggedin/logged_in_flow_controller.dart#L12)中实现的信息编辑事件(OnEditProfileClickedListener)和 RootStatehttps://github.com/yarolegovich/flutter_navigation/blob/master/lib/root/root.dart#L18)处理的 Logout 事件(OnLoggedOutListener),这两个事件都由 profile 页面响应。

class _ProfilePageState extends LifecycleAwareState<ProfilePage> {

Widget _buildEditProfileButton() {
return DesignClickableText(
text: 'Edit profile',
onPressed: () => _editClickListener().onEditProfileClicked(),
textStyle: Design.textCaption(color: Design.colorPrimary.shade900, bold: true),
padding: EdgeInsets.all(8)
);
}

Widget _buildLogOutButton() {
return DesignHorizontalMargin(DesignDangerButton(
text: 'Log out',
onPressed: () => _logOutListener().onLoggedOut()
));
}

OnLoggedOutListener _logOutListener() {
return context.findAncestorStateOfType<OnLoggedOutListener>();
}

OnEditProfileClickedListener _editClickListener() {
return context.findAncestorStateOfType<OnEditProfileClickedListener>();
}
}

除此之外,这种抽象出口点的方式也极大地简化了项目的协作,因为开发功能的开发者不必再等待将要展示该功能的上下文,只需要定义接口并自行构建功能,然后最终放置在合适的位置上即可。

流程控制器

将部分逻辑提取到了统一的祖先组件中后,UI 展示和路由逻辑依然可能会重合在一起,这时,我们可以通过 流控制器(flow controller) 这种设计模式解决这个问题。

我们可以将应用程序想象成一棵树,其中叶节点表示单个页面,而其他节点则代表抽象流。回到上面的示例,这个应用程序的 “路由树” 可以用如下这张图表示:

严谨地说,这是一个有根的无环有向图,从根到任一叶子结点至少有一个可达路径,并且节点可以重用,在多个上下文中展示。

通过这种方式建模,我们就能够清晰地看到和 单一流程相关的各个页面,对于每一个流程,我们可以创建一个 “空” 祖先,其唯一职责是协调流程,例如确定在某个时刻应显示哪个页面。

这种模式最大的益处就是可以 将路由操作的逻辑在范围内统一,对于每个流程都有一个统一的地方管理,包括页面展示的顺序、条件、数据、过渡动画等等。不仅给了我们一个清晰的视角去管理路由状态,而且使代码更加易于拓展和维护,此时,我们可以根据需求重新改变路由顺序,在流程中引入或者插入新的路由页面等等。

另一个很大的益处是,管理各个控制流中的路由栈比管理整个应用的路由栈要简单得多,此时,在堆栈中只有与该流程相关的组件,当执行一些不那么琐碎的堆栈操作(如 popUntil)时,这会极大地减少出现错误的可能性及其成本。

流程控制器也是保持多个屏页面可以共享某些通用逻辑或 UI 组件。在 Flutter 项目中,我开发的工作还包括在基本流控制器类中保留用于显示对话框和底部导航栏的逻辑,以便快速,轻松地访问这些组件。

实现基础流程控制器 BaseFlowController

下面,我想向大家展示一个比较通用的示例,读者们可以以它为基础在项目中拓展使用。

如上所述,流程控制器专门负责协调页面之间的协作流程,BaseFlowController 使用一个最基础的空栈作为例子,在更复杂的应用中,如包含多个栈的 flow,此时应用也可以做到同时展示多个不同的组件。

在你自己的 FlowController 中,应该只包含多个路由容器(特指 Navigator)和一些可以直接操作容器路由堆栈的方法(通过 key),

另外,流程控制器中也可能会包含多个路由间共享的元素,如底部导航栏、弹出通知的横幅等,这类情况本文暂不做考虑,留给读者们自己实现练习。

FlowControllerState 部分代码如下,你可以到 GitHub(https://github.com/yarolegovich/flutter_navigation) 查看完整代码:

abstract class FlowControllerState<T extends StatefulWidget> extends State<T> {

GlobalKey<NavigatorState> _navKey;
RouteObserver _routeObserver;
List<String> _navStack;

@override
void initState() {
super.initState();
_navStack = [];
_navKey = GlobalObjectKey<NavigatorState>(this);
_routeObserver = RouteObserver();
}

AppPage createInitialPage();

@override
Widget build(BuildContext context) {
return Navigator(
key: _navKey,
observers: [_routeObserver],
onGenerateRoute: (s) {
AppPage page = createInitialPage();
_navStack.add(page.name);
return _buildRoute((s) => page.widget, page.name);
});
}

Route<R> _buildRoute<R>(WidgetBuilder builder, String name) {
return CupertinoPageRoute(
builder: builder,
settings: RouteSettings(name: name)
);
}
}

以上代码就展示了流程控制器的基本功能,下面我们继续探究具体的实现过程。

扩展 Navigator 的功能

_navStack,可选,保存当前路由状态,利用它我们可以对针对当前路由状态做很多原生 Navigator 没有提供特定的功能,如提供以下方法:

bool containsChild(String routeName) => _navStack.any((element) => element == routeName);

bool isDisplayed(String routeName) => _navStack.last == routeName;
隐藏实现

_navKey,用来访问和操控导航器(Navigator),应该避免将其直接暴露给子组件,而是提供一些可以更新状态的方法,如下代码中的 pop、push 等:

void pushSimple(Widget Function() builder, String name) {
push(_buildRoute((c) => builder(), name));
}

void pop<T>({T result}) {
_navStack.removeLast();
_navigator().pop(result);
}

Future<R> push<R>(Route<R> route) {
assert(route.settings.name != null);
_navStack.add(route.settings.name);
return _navigator().push(route);
}

void popUntilFound(String name) {
_navigator().popUntil((route) {
final willPop = route.settings.name != name;
if (willPop) _navStack.removeLast();
return !willPop;
});
}

这样,我们可以轻松地在路由操作中添加 Log 等更多额外的通用功能,并在抽象层中保证 _navStack 状态的正确性。

生命周期的感知

_routeObserver 在 FlowController 中未使用,但是很适合实现对生命周期状态比较敏感的状态,我们可以根据这些状态执行某些可见性操作,如在应用程序进入后台返回时打开和关闭轮询:

abstract class LifecycleAwareState<T extends StatefulWidget> extends State<T> with WidgetsBindingObserver, RouteAware {

RouteObserver _routeObserver;
void onResumed();

void onPaused();

@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_isResumed = true;
_isAppInFg = true;
_isCovered = false;
onResumed();
}

@override
void didChangeDependencies() {
super.didChangeDependencies();

_unsubscribeFromStates();
_routeObserver = _flowController()?.routeObserver();
_routeObserver?.subscribe(this, ModalRoute.of(context));
}

@override
void dispose() {
super.dispose();

_unsubscribeFromStates();
WidgetsBinding.instance.removeObserver(this);
}

void _unsubscribeFromStates() {
_routeObserver?.unsubscribe(this);
_routeObserver = null;
}

FlowControllerState _flowController() => context.findAncestorStateOfType<FlowControllerState>();
}

在本文的示例应用中,我就使用它来更新个人资料页面的状态https://github.com/yarolegovich/flutter_navigation/blob/master/lib/root/loggedin/home/profile/profile_page.dart#L93),此时,当用户更改个人信息并从编辑页面返回后,用户就可以随即看到最新的数据。

@override
void onResumed() {
setState(() {});
}
处理返回按钮

最后,也是最棘手的部分就是处理返回按钮。如果我们仅将 Navigator 包装到 WillPopScope 组件中,那么最顶部的 widget 将会接收所有的返回事件,而无视底下的各个流程控制器。

另外,findAncestorStateOfType 非常高效,因为在最坏的情况下,它访问的节点数也就等于组件树的高度,然而,如果我们将一个返回按钮的事件从上层节点传入下层,找到合适的消费者,最坏的情况下就需要遍历整棵树的节点。

因此,为了避免这种情况,我们可以只使用一个 WillPopScope 以及一个监听返回按钮事件的状态列表。流程控制器自身可以注册和注销,WillPopScope 容器负责将事件调度分发到各个已注册的组件中,如下这段代码:

abstract class PopScopeHost<T extends StatefulWidget> implements State<T> {

List<BackPressHandler> _backPressHandlers = [];

Future<bool> onWillPop() async {
for (int i = _backPressHandlers.length - 1; i >= 0; i--) {
if (!_backPressHandlers[i].mounted) continue;
if (_backPressHandlers[i].handleBackPressed()) {
return false;
}
}
return true;
}

static PopScopeHostSubscription subscribe(BuildContext ctx, BackPressHandler handler) {
final host = ctx.findAncestorStateOfType<PopScopeHost>();
host.addBackPressHandler(handler);
return PopScopeHostSubscription(host, handler);
}
}

class PopScopeHostSubscription {
PopScopeHost _host;
BackPressHandler _handler;

PopScopeHostSubscription(this._host, this._handler);

void dispose() {
_host?.removeBackPressHandler(_handler);
_host = null;
}
}

下层,我们可以在流程控制器中消费该事件:

@override
void didChangeDependencies() {
super.didChangeDependencies();
_popScopeHostSubscription?.dispose();
_popScopeHostSubscription = PopScopeHost.subscribe(context, this);
}

@override
void dispose() {
_popScopeHostSubscription?.dispose();
super.dispose();
}

这样,根组件的状态对象混入 PopScopeHost ,并将 onWillPop 方法传给 WillPopScope 后,便可以完整的实现事件分发的功能:

class RootState extends State<RootPage> with PopScopeHost<RootPage> {
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: onWillPop,
child: _isLoggedIn ?
LoggedInFlowController() :
LoggedOutFlowController()
);
}
}
实现流程控制器

最后,我们以 ProfileSetupController 为例,看一下如何使用上述抽象类创建一个自己的流程控制器,如下:

class _ProfileSetupControllerState extends FlowControllerState<ProfileSetupController> 
implements
HobbyCategoryPageListener<ProfileSetupController>,
HobbyPageListener<ProfileSetupController>,
LanguagesPageListener<ProfileSetupController>,
LocationPageListener<ProfileSetupController> {

List<Hobby> _selectedHobbies;
LocationModel _enteredLocation;

@override
AppPage createInitialPage() => AppPage(_PAGE_HOBBY_CATEGORY, _createHobbyCategoryPage());

@override
void onHobbyCategorySelected(HobbyCategory category) {
pushSimple(() => _createHobbyPage(category.hobbies), _PAGE_HOBBY);
}

@override
void onHobbiesSelected(List<Hobby> hobbies) {
_selectedHobbies = hobbies;
pushSimple(() => _createLocationPage(), _PAGE_LOCATION);
}

@override
void onLocationEntered(LocationModel location) {
_enteredLocation = location;
pushSimple(() => _createLanguagesPage(), _PAGE_LANGUAGES);
}

@override
void onLanguagesSelected(List<LanguageModel> languages) {
final repo = UserRepository.get();
final user = repo.createNewUser(_selectedHobbies, _enteredLocation, languages);
_listener().onProfileSetupComplete(user);
}

ProfileSetupFlowListener _listener() {
return context.findAncestorStateOfType<ProfileSetupFlowListener>();
}
}

此时,就像很多架构书中学习的,这种方式充分体现了代码的 高内聚低耦合,将路由操作的相关逻辑从 UI 组件中分离了出来。

EditProfileFlowController 是一个更复杂的案例(信息编辑页面),此时的程序需要处理诸如更新数据,清空页面等等操作(完整代码参见:https://github.com/yarolegovich/flutter_navigation/blob/master/lib/root/loggedin/editprofile/profile_edit_flow_controller.dart#L22)。

完整的示例项目代码参见:https://github.com/yarolegovich/flutter_navigation

总结

Bolt 团队的这篇文章发表在 Navigator2.0 出现之前,其中的思想与其也有很多相似之处,经过实践,这种方案也确实证明了可以帮助他们增强应用的可拓展性,适应不断发展的新需求。

该方案不能开箱即用,但完全可以在该想法的基础上拓展修改适应自己的需求,希望你能从本文受到启发。

关注公众号「Meandni」,及时阅读最新前沿技术动态,不至于落后时代。

扫一扫,关注「Meandni」

文章作者: Joker
文章链接: https://meandni.com/2020/12/07/navigation-done-right-a-case-for-hierarchical-routing-with-flutter/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Joker's Blog
支付宝打赏
微信打赏