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

相关文章

Spring Security 基于表达式的权限控制

前言 spring security 3.0已经可以使用spring el表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。 常见的表达式 Spring Security可用表达式对象的基类是SecurityExpressionRoot。 表达式描述hasRole([role])用户拥有制定的角色时返回true (Spring security默认会带有ROLE_前缀),去

Security OAuth2 单点登录流程

单点登录(英语:Single sign-on,缩写为 SSO),又译为单一签入,一种对于许多相互关连,但是又是各自独立的软件系统,提供访问控制的属性。当拥有这项属性时,当用户登录时,就可以获取所有系统的访问权限,不用对每个单一系统都逐一登录。这项功能通常是以轻型目录访问协议(LDAP)来实现,在服务器上会将用户信息存储到LDAP数据库中。相同的,单一注销(single sign-off)就是指

Spring Security基于数据库验证流程详解

Spring Security 校验流程图 相关解释说明(认真看哦) AbstractAuthenticationProcessingFilter 抽象类 /*** 调用 #requiresAuthentication(HttpServletRequest, HttpServletResponse) 决定是否需要进行验证操作。* 如果需要验证,则会调用 #attemptAuthentica

基于MySQL Binlog的Elasticsearch数据同步实践

一、为什么要做 随着马蜂窝的逐渐发展,我们的业务数据越来越多,单纯使用 MySQL 已经不能满足我们的数据查询需求,例如对于商品、订单等数据的多维度检索。 使用 Elasticsearch 存储业务数据可以很好的解决我们业务中的搜索需求。而数据进行异构存储后,随之而来的就是数据同步的问题。 二、现有方法及问题 对于数据同步,我们目前的解决方案是建立数据中间表。把需要检索的业务数据,统一放到一张M

康拓展开(hash算法中会用到)

康拓展开是一个全排列到一个自然数的双射(也就是某个全排列与某个自然数一一对应) 公式: X=a[n]*(n-1)!+a[n-1]*(n-2)!+...+a[i]*(i-1)!+...+a[1]*0! 其中,a[i]为整数,并且0<=a[i]<i,1<=i<=n。(a[i]在不同应用中的含义不同); 典型应用: 计算当前排列在所有由小到大全排列中的顺序,也就是说求当前排列是第

综合安防管理平台LntonAIServer视频监控汇聚抖动检测算法优势

LntonAIServer视频质量诊断功能中的抖动检测是一个专门针对视频稳定性进行分析的功能。抖动通常是指视频帧之间的不必要运动,这种运动可能是由于摄像机的移动、传输中的错误或编解码问题导致的。抖动检测对于确保视频内容的平滑性和观看体验至关重要。 优势 1. 提高图像质量 - 清晰度提升:减少抖动,提高图像的清晰度和细节表现力,使得监控画面更加真实可信。 - 细节增强:在低光条件下,抖

C++11第三弹:lambda表达式 | 新的类功能 | 模板的可变参数

🌈个人主页: 南桥几晴秋 🌈C++专栏: 南桥谈C++ 🌈C语言专栏: C语言学习系列 🌈Linux学习专栏: 南桥谈Linux 🌈数据结构学习专栏: 数据结构杂谈 🌈数据库学习专栏: 南桥谈MySQL 🌈Qt学习专栏: 南桥谈Qt 🌈菜鸡代码练习: 练习随想记录 🌈git学习: 南桥谈Git 🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈�

如何在页面调用utility bar并传递参数至lwc组件

1.在app的utility item中添加lwc组件: 2.调用utility bar api的方式有两种: 方法一,通过lwc调用: import {LightningElement,api ,wire } from 'lwc';import { publish, MessageContext } from 'lightning/messageService';import Ca

让树莓派智能语音助手实现定时提醒功能

最初的时候是想直接在rasa 的chatbot上实现,因为rasa本身是带有remindschedule模块的。不过经过一番折腾后,忽然发现,chatbot上实现的定时,语音助手不一定会有响应。因为,我目前语音助手的代码设置了长时间无应答会结束对话,这样一来,chatbot定时提醒的触发就不会被语音助手获悉。那怎么让语音助手也具有定时提醒功能呢? 我最后选择的方法是用threading.Time

软考系统规划与管理师考试证书含金量高吗?

2024年软考系统规划与管理师考试报名时间节点: 报名时间:2024年上半年软考将于3月中旬陆续开始报名 考试时间:上半年5月25日到28日,下半年11月9日到12日 分数线:所有科目成绩均须达到45分以上(包括45分)方可通过考试 成绩查询:可在“中国计算机技术职业资格网”上查询软考成绩 出成绩时间:预计在11月左右 证书领取时间:一般在考试成绩公布后3~4个月,各地领取时间有所不同