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

相关文章

Oracle查询优化之高效实现仅查询前10条记录的方法与实践

《Oracle查询优化之高效实现仅查询前10条记录的方法与实践》:本文主要介绍Oracle查询优化之高效实现仅查询前10条记录的相关资料,包括使用ROWNUM、ROW_NUMBER()函数、FET... 目录1. 使用 ROWNUM 查询2. 使用 ROW_NUMBER() 函数3. 使用 FETCH FI

Java中Springboot集成Kafka实现消息发送和接收功能

《Java中Springboot集成Kafka实现消息发送和接收功能》Kafka是一个高吞吐量的分布式发布-订阅消息系统,主要用于处理大规模数据流,它由生产者、消费者、主题、分区和代理等组件构成,Ka... 目录一、Kafka 简介二、Kafka 功能三、POM依赖四、配置文件五、生产者六、消费者一、Kaf

使用MongoDB进行数据存储的操作流程

《使用MongoDB进行数据存储的操作流程》在现代应用开发中,数据存储是一个至关重要的部分,随着数据量的增大和复杂性的增加,传统的关系型数据库有时难以应对高并发和大数据量的处理需求,MongoDB作为... 目录什么是MongoDB?MongoDB的优势使用MongoDB进行数据存储1. 安装MongoDB

在C#中获取端口号与系统信息的高效实践

《在C#中获取端口号与系统信息的高效实践》在现代软件开发中,尤其是系统管理、运维、监控和性能优化等场景中,了解计算机硬件和网络的状态至关重要,C#作为一种广泛应用的编程语言,提供了丰富的API来帮助开... 目录引言1. 获取端口号信息1.1 获取活动的 TCP 和 UDP 连接说明:应用场景:2. 获取硬

Java内存泄漏问题的排查、优化与最佳实践

《Java内存泄漏问题的排查、优化与最佳实践》在Java开发中,内存泄漏是一个常见且令人头疼的问题,内存泄漏指的是程序在运行过程中,已经不再使用的对象没有被及时释放,从而导致内存占用不断增加,最终... 目录引言1. 什么是内存泄漏?常见的内存泄漏情况2. 如何排查 Java 中的内存泄漏?2.1 使用 J

Go语言实现将中文转化为拼音功能

《Go语言实现将中文转化为拼音功能》这篇文章主要为大家详细介绍了Go语言中如何实现将中文转化为拼音功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 有这么一个需求:新用户入职 创建一系列账号比较麻烦,打算通过接口传入姓名进行初始化。想把姓名转化成拼音。因为有些账号即需要中文也需要英

高效管理你的Linux系统: Debian操作系统常用命令指南

《高效管理你的Linux系统:Debian操作系统常用命令指南》在Debian操作系统中,了解和掌握常用命令对于提高工作效率和系统管理至关重要,本文将详细介绍Debian的常用命令,帮助读者更好地使... Debian是一个流行的linux发行版,它以其稳定性、强大的软件包管理和丰富的社区资源而闻名。在使用

基于WinForm+Halcon实现图像缩放与交互功能

《基于WinForm+Halcon实现图像缩放与交互功能》本文主要讲述在WinForm中结合Halcon实现图像缩放、平移及实时显示灰度值等交互功能,包括初始化窗口的不同方式,以及通过特定事件添加相应... 目录前言初始化窗口添加图像缩放功能添加图像平移功能添加实时显示灰度值功能示例代码总结最后前言本文将

Python实现NLP的完整流程介绍

《Python实现NLP的完整流程介绍》这篇文章主要为大家详细介绍了Python实现NLP的完整流程,文中的示例代码讲解详细,具有一定的借鉴价值,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1. 编程安装和导入必要的库2. 文本数据准备3. 文本预处理3.1 小写化3.2 分词(Tokenizatio

使用Python实现批量访问URL并解析XML响应功能

《使用Python实现批量访问URL并解析XML响应功能》在现代Web开发和数据抓取中,批量访问URL并解析响应内容是一个常见的需求,本文将详细介绍如何使用Python实现批量访问URL并解析XML响... 目录引言1. 背景与需求2. 工具方法实现2.1 单URL访问与解析代码实现代码说明2.2 示例调用