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

2023-12-18 14:18

本文主要是介绍Bolt 的 Flutter 路由管理实践(页面解耦,流程控制、功能拓展等),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在各大移动开发框架(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。

打破单页面间的耦合

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

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

dart

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

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

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

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

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

dart

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

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

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

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

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

抽象出口点

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

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

dart

abstract class LocationInputScreenListener {void onLocationEntered(LocationModel input);void onBackPressed();
}

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

dart

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

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

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

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

dart

abstract class LocationInputScreenListener<T extends StatefulWidget> implements State<T> {void onLocationEntered(LocationModel input);void onBackPressed();
}

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

dart

class AncestorState extends State<AncestorWidget>implementsLocationInputScreenListener<AncestorWidget>

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

dart

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) 查看完整代码:

dart

abstract class FlowControllerState<T extends StatefulWidget> extends State<T> {GlobalKey<NavigatorState> _navKey;RouteObserver _routeObserver;List<String> _navStack;@overridevoid initState() {super.initState();_navStack = [];_navKey = GlobalObjectKey<NavigatorState>(this);_routeObserver = RouteObserver();}AppPage createInitialPage();@overrideWidget 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 没有提供特定的功能,如提供以下方法:

dart

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

隐藏实现

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

dart

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

dart

abstract class LifecycleAwareState<T extends StatefulWidget> extends State<T> with WidgetsBindingObserver, RouteAware {RouteObserver _routeObserver;void onResumed();void onPaused();@overridevoid initState() {super.initState();WidgetsBinding.instance.addObserver(this);_isResumed = true;_isAppInFg = true;_isCovered = false;onResumed();}@overridevoid didChangeDependencies() {super.didChangeDependencies();_unsubscribeFromStates();_routeObserver = _flowController()?.routeObserver();_routeObserver?.subscribe(this, ModalRoute.of(context));}@overridevoid 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),此时,当用户更改个人信息并从编辑页面返回后,用户就可以随即看到最新的数据。

dart

@override
void onResumed() {setState(() {});
}

处理返回按钮

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

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

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

dart

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;}
}

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

dart

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

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

dart

class RootState extends State<RootPage> with PopScopeHost<RootPage> {@overrideWidget build(BuildContext context) {return WillPopScope(onWillPop: onWillPop,child: _isLoggedIn ? LoggedInFlowController() : LoggedOutFlowController());}
}

实现流程控制器

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

dart

class _ProfileSetupControllerState extends FlowControllerState<ProfileSetupController> implements HobbyCategoryPageListener<ProfileSetupController>, HobbyPageListener<ProfileSetupController>, LanguagesPageListener<ProfileSetupController>, LocationPageListener<ProfileSetupController> {List<Hobby> _selectedHobbies;LocationModel _enteredLocation;@overrideAppPage createInitialPage() => AppPage(_PAGE_HOBBY_CATEGORY, _createHobbyCategoryPage());@overridevoid onHobbyCategorySelected(HobbyCategory category) {pushSimple(() => _createHobbyPage(category.hobbies), _PAGE_HOBBY);}@overridevoid onHobbiesSelected(List<Hobby> hobbies) {_selectedHobbies = hobbies;pushSimple(() => _createLocationPage(), _PAGE_LOCATION);}@overridevoid onLocationEntered(LocationModel location) {_enteredLocation = location;pushSimple(() => _createLanguagesPage(), _PAGE_LANGUAGES);}@overridevoid 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 出现之前,其中的思想与其也有很多相似之处,经过实践,这种方案也确实证明了可以帮助他们增强应用的可拓展性,适应不断发展的新需求。

这篇关于Bolt 的 Flutter 路由管理实践(页面解耦,流程控制、功能拓展等)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/508711

相关文章

Ubuntu 24.04启用root图形登录的操作流程

《Ubuntu24.04启用root图形登录的操作流程》Ubuntu默认禁用root账户的图形与SSH登录,这是为了安全,但在某些场景你可能需要直接用root登录GNOME桌面,本文以Ubuntu2... 目录一、前言二、准备工作三、设置 root 密码四、启用图形界面 root 登录1. 修改 GDM 配

MySQL 迁移至 Doris 最佳实践方案(最新整理)

《MySQL迁移至Doris最佳实践方案(最新整理)》本文将深入剖析三种经过实践验证的MySQL迁移至Doris的最佳方案,涵盖全量迁移、增量同步、混合迁移以及基于CDC(ChangeData... 目录一、China编程JDBC Catalog 联邦查询方案(适合跨库实时查询)1. 方案概述2. 环境要求3.

Linux进程CPU绑定优化与实践过程

《Linux进程CPU绑定优化与实践过程》Linux支持进程绑定至特定CPU核心,通过sched_setaffinity系统调用和taskset工具实现,优化缓存效率与上下文切换,提升多核计算性能,适... 目录1. 多核处理器及并行计算概念1.1 多核处理器架构概述1.2 并行计算的含义及重要性1.3 并

全面掌握 SQL 中的 DATEDIFF函数及用法最佳实践

《全面掌握SQL中的DATEDIFF函数及用法最佳实践》本文解析DATEDIFF在不同数据库中的差异,强调其边界计算原理,探讨应用场景及陷阱,推荐根据需求选择TIMESTAMPDIFF或inte... 目录1. 核心概念:DATEDIFF 究竟在计算什么?2. 主流数据库中的 DATEDIFF 实现2.1

Spring Boot集成Druid实现数据源管理与监控的详细步骤

《SpringBoot集成Druid实现数据源管理与监控的详细步骤》本文介绍如何在SpringBoot项目中集成Druid数据库连接池,包括环境搭建、Maven依赖配置、SpringBoot配置文件... 目录1. 引言1.1 环境准备1.2 Druid介绍2. 配置Druid连接池3. 查看Druid监控

浅析Spring如何控制Bean的加载顺序

《浅析Spring如何控制Bean的加载顺序》在大多数情况下,我们不需要手动控制Bean的加载顺序,因为Spring的IoC容器足够智能,但在某些特殊场景下,这种隐式的依赖关系可能不存在,下面我们就来... 目录核心原则:依赖驱动加载手动控制 Bean 加载顺序的方法方法 1:使用@DependsOn(最直

Qt使用QSqlDatabase连接MySQL实现增删改查功能

《Qt使用QSqlDatabase连接MySQL实现增删改查功能》这篇文章主要为大家详细介绍了Qt如何使用QSqlDatabase连接MySQL实现增删改查功能,文中的示例代码讲解详细,感兴趣的小伙伴... 目录一、创建数据表二、连接mysql数据库三、封装成一个完整的轻量级 ORM 风格类3.1 表结构

Knife4j+Axios+Redis前后端分离架构下的 API 管理与会话方案(最新推荐)

《Knife4j+Axios+Redis前后端分离架构下的API管理与会话方案(最新推荐)》本文主要介绍了Swagger与Knife4j的配置要点、前后端对接方法以及分布式Session实现原理,... 目录一、Swagger 与 Knife4j 的深度理解及配置要点Knife4j 配置关键要点1.Spri

Spring WebFlux 与 WebClient 使用指南及最佳实践

《SpringWebFlux与WebClient使用指南及最佳实践》WebClient是SpringWebFlux模块提供的非阻塞、响应式HTTP客户端,基于ProjectReactor实现,... 目录Spring WebFlux 与 WebClient 使用指南1. WebClient 概述2. 核心依

MyBatis-Plus 中 nested() 与 and() 方法详解(最佳实践场景)

《MyBatis-Plus中nested()与and()方法详解(最佳实践场景)》在MyBatis-Plus的条件构造器中,nested()和and()都是用于构建复杂查询条件的关键方法,但... 目录MyBATis-Plus 中nested()与and()方法详解一、核心区别对比二、方法详解1.and()