【Flutter】抄一个路由轮子到Flutter上能有多难(二)—— 路由表部分的原理与使用简介
前言
之前的一篇【Flutter】抄一个路由轮子到Flutter上能有多难? 中,我抱着三分记录思考,七分吐槽的目的写下了那篇文章;结果意外发现点赞还是有一些的……
那么吐槽归吐槽,既然有人关注,那么接下来该做的事就很明确了,在实现功能的基础上完成并开源这个路由框架;
现在计划将路由框架分两部分完成,一部分是自定义的路由跳转框架;另一部分就是这篇文章要介绍的核心基础——路由表部分,以证明我真的在写了;
另外提醒一下,很多功能乍看挺高大上的,其实原理特别low,实属武大郎放风筝——起手一点都不高,以至于现在回顾一下,好像路由这东西没啥好说的……
目前设计的路由表主要有这么三大部分功能:
- 页面跳转能力(
FRouterWareHouse
) - 跨模块依赖注入能力(
FRouterProvider
) - 全局任务和消息中心(
FRouterFlowTaskCenter
)
介绍
1.1 三大能力:
FRouterWareHouse:
- 使用类似 URI Scheme 作为路由Path格式,对Web、deepLink等天然友好;
- 类似于 Spring MVC(后端一看就笑了,注解参数名都一模一样) 的传参方式和解析模式,允许任意object的传递,在允许参数别名的基础上,不需要任何序列化等操作即可保证对象类型,(谁说路由传参只能用基本数据格式)
- 支持 json格式 导出路由表Bundle;
- 支持路由动态化,比如说可以依靠 远端 下发 动态json路由表Bundle,妈妈再也不用担心上线出重大bug不能降级为H5,不能换成别的页面临时过渡的问题了;
- 支持页面跳转拦截处理,也支持模块级别的拦截器;
- 路由表和路由模块隔离解耦,也就是说,可以本质上降为仅仅提供Widget的路由仓库,在这种情况下,没啥太大意外的话,兼容任意一种路由跳转框架;
- 尽可能的渐进式接入;
FRouterProvider:
- 支持跨模块依赖注入;
/// 感觉就写一条,很low啊
FRouterFlowTask:
- 支持单模块独立初始化
- 基于有向无环图自动维护依赖的加载次序;2023年了还在手写依赖的加载时机和顺序?
- 动态化任务
1.2 路由方案:
2. 如何接入
下面我就从零开始,一步步演示如何接入上frouter;
首先建立一个基础的多模块演示工程:
- 演示项目结构概览
演示项目结构概览,仅供参考
2.1 使用FRouter的路由表功能
2.1.1 项目导入
http依赖地址暂无,因为还没发布😛,Get路由的搬运抄袭缝合已经在计划中了;
在这里以library的引用方式演示(其实都差不多)
首先是在yaml中声明上frouter的包,这里就不再赘述,毕竟现在连发布地址都没有;
2.1.2 声明路由项
在需要路由跳转的页面上注解@RouterPath
;
在这里,我们给 module_b 的随便一个Widget此注解,比如说module_b 中的 PostInfoPage
;
参数释义
pathUri :路由path,必填项
pathUri的格式跟ARouter大差不差,同样是需要至少二级;只是格式上遵循URI的格式,说白了就是URI能解析即可;例如:
'user/user_info'
action :自定义事件,搭配全局任务用的;
description :描述,除了可以当注释外,也用在导出的路由表文件中当注释
示例图:
module_b:PostInfoPage
2.1.3 执行build_runner
如果不出意外的话,此时在声明注解的模块lib目录下,应该有一个模块名_router_export.dart
的引用文件;里面的内容就是声明了注解的文件,比如这样:
library module_b_route_export;
export 'page/post_info_page.dart';
这个文件的作用就是单纯的提供跨模块的引用文件,没啥好说的;
在主工程中,也会出现一个主工程_router.dart
的路由表文件;还是以上面的演示工程为例,其内容是这样的:
// Generated by lwlizhe frouter plugin, do not edit manually.
// run pub run build_runner build --delete-conflicting-outputs
// ignore_for_file: directives_ordering
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:frouter/bin/entity/frouter_router_map.dart' as _i1;
import 'package:module_a/module_a_route.dart' as _i2;
import 'package:frouter/bin/builder/frouter_widget_builder.dart' as _i3;
import 'package:module_b/module_b_route_export.dart' as _i4;
import 'package:frouter/bin/helper/safety_parameter_transform_utils.dart'
as _i5;
import 'package:flutter/material.dart'as _i6;
class FRouterMap extends _i1.FRouterRouterMap {
@override
String get hostRouterGroup {
return 'example';
}
@override
String get currentRouterGroup {
return 'example';
}
@override
List<_i1.FRouterRouterMap> get subModule {
return [
_i2.FModuleRouterMap(),
];
}
@override
Map<String, _i3.FRouterWidgetBuilder> get routerMap {
return <String, _i3.FRouterWidgetBuilder>{
'package:module_b/page/post_info_page.dart:PostInfoPage':
(Map<String, String>? parameters) {
return _i4.PostInfoPage(
_i5.transform<List<String>>(parameters?['postTitleList'])
as List<String>,
key: _i5.transform<_i6.Key?>(parameters?['key']),
);
},
};
}
@override
Map<String, String> get routerMapBundle {
return <String, String>{
'post/post_info':
'package:module_b/page/post_info_page.dart:PostInfoPage',
};
}
@override
Map<String, _i3.FRouterProviderBuilder> get providerMap {
return <String, _i3.FRouterProviderBuilder>{};
}
@override
Map<String, String> get providerBundle {
return <String, String>{};
}
}
这个就是最终生成的路由表部分了,其实也不复杂;
参数说明:
hostRouterGroup:主工程名称
currentRouterGroup:当前模块名称
subModule:当前模块的子模块是哪些(本来是打算基于消息的模式,搞个渐进式路由框架;这里就是路由表下沉到子模块的引用部分)
routerMap 就是核心路由表了;(在这里也担任本地路由缓存的作用,至于为什么需要一个本地路由缓存,后面会有说)
routerMapBundle:路由Bundle(关于这里的设计,也是后面动态路由部分细说)
providerMap: 跨组件通讯服务的缓存表
providerBundle :跨组件通讯服务缓存表Bundle
其中包含的 routerMapBundle
就是路由 bundle,也是可以用于动态替换,输出json的部分;举个例子,如果PostInfoPage出现 bug,可以通过替换 routerMapBundle 中
的 post/post_info
对应值, 将其指向其他page或者web页面;
其中的 routerMap
是路由本地缓存映射,用于通过 bundle 去寻找到真正对应的 Page
2.1.4 初始化路由表及基础路由跳转
-
接入到现有路由框架中
由于现在还没开发完路由部分;这里暂时由别的路由框架来实现(这就是上面吹的,完美兼容各种路由框架的部分……)
-
首先初始化路由表:
在main.dart 的 build 方法中加载一下路由表:
FRouter().init(FRouterMap());
-
接入路由
接入路由这块按照路由框架的要求接入即可,毕竟本质上只是个Map;
这里就以getX、go_router、navigaotr 1.0 为例;
class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); // This widget is the root of your application. @override Widget build(BuildContext context) { final wareHouse = FRouter().init(FRouterMap()); /// getX 的路由转换处理 final List<GetPage> getRouter = [ ...原来接入的GetPage路由部分, ...wareHouse.routerMapBundle.keys.map((e) => GetPage( name: '/$e', page: () { String uri = (Uri.parse(e).replace(queryParameters: { ...Get.parameters, ...Get.arguments ?? {} })).toString(); return FRouter().build(uri).navigation() as Widget; })) ]; /// go_router 的路由转换处理 /// 如果想支持go_router的多层级路由,可以用wareHouse.router这个Map; /// 其KEY值是路由路径的一级标签,可以做多层级路由的处理; /// 再多层级的话,建议直接根据路由路径的层级来处理; final GoRouter goRouter = GoRouter( routes: <RouteBase>[ ...原来接入的GoRouter路由部分, ...wareHouse.routerMapBundle.keys .map((e) => GoRoute( path: e, builder: (BuildContext context, GoRouterState state) { String uri = (Uri.parse(e).replace(queryParameters: { ...Get.parameters, ...Get.arguments ?? {} })).toString(); return FRouter().build(uri).navigation() as Widget; })) .toList(), ], ); /// 使用get return GetMaterialApp( title: 'Flutter Demo', home: const HomePage(), getPages: getRouter, ); /// 使用go_router return MaterialApp.router( routerConfig: goRouter, ); /// 使用 Navigator 1.0 return MaterialApp( title: 'Flutter Demo', theme: ThemeData( // This is the theme of your application. // // Try running your application with "flutter run". You'll see the // application has a blue toolbar. Then, without quitting the app, try // changing the primarySwatch below to Colors.green and then invoke // "hot reload" (press "r" in the console where you ran "flutter run", // or simply save your changes to "hot reload" in a Flutter IDE). // Notice that the counter didn't reset back to zero; the application // is not restarted. primarySwatch: Colors.blue, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<dynamic>( settings: settings, builder: (BuildContext _) { return FRouter().build(settings.name ?? '').navigation() as Widget; }, ); }, ); } }
-
-
启动导航
对于路由表来说,其实没啥启动路由的功能~~所以我感觉这里应该属于路由部分的内容,因此这里直接用路由框架的路由导航之类的就行;
比如说Get,直接调用Get.toNamed(xx路径,arguments:argument参数,parameters:parameters参数),对于路由表来说,关注的部分其实应该是路由传参和其解析部分;
frouter的参数格式跟http的带参格式一样,细心的同学应该能在上面的接入方式中发现,传给frouter的的路径都是这种经过URI解析并增加parameters的:
String fullPath = (Uri.parse(e).replace(queryParameters: { ...Get.parameters, ...Get.arguments ?? {} })).toString();
所以调用启动路由的时候,声明式路由就要自己处理一下入参转URI;如果以非声明式路由的方式的话,那就需要在整体路径上,用标准的URI格式增加上调用参数;
/// todo: 感觉这部分应该整合到路由功能那里?毕竟路由表不用关心路由调用
现在来看看效果:
基础路由功能
2.1.5 动态路由使用方式
动态路由别看名字很高上的样子,其原理其实非常简单;开个异步任务更新一下当前保存的路由表Bundle就完事了;
但是需要注意的是,我这里替换的是Bundle而非整个路由表;
至于原因也很简单,flutter不支持反射,因此无法直接获取到类;因此这里参考了Fair动态化自定义Widget的做法;先保存下来缓存Widget,后续通过更新Bundle的方式来实现动态化;因此没有原先Widget的缓存,是无法实现动态化的;
因此这里和原生的动态化功能上差距还是不小的,感觉基本只是用来做各种远端降级之类的处理?
说白了,这块的动态化只能用来在已有功能的基础上做兜底拦截等处理,不能新增功能;
说了这堆可能还不如一段demo,在这里演示一下如何将一个页面改为html页面:
1、首先加一个 CommonWebPage 来担任基础功能,在这里就以 app/webView
为例:
@RouterPath(pathUri: 'app/webView')
class CommonWebPage extends StatefulWidget {
final String tag;
const CommonWebPage(this.tag,{super.key});
@override
State<CommonWebPage> createState() => _CommonWebPageState();
}
。。。。
为了方便CommonWebPage获取参数,我在路由表接入部分,将uri路径放了进去:以上面的GETX接入方式为例,应该是这样的:
final List<GetPage> getRouter = [
...wareHouse.routerMapBundle.keys.map((e) => GetPage(
name: '/$e',
page: () {
String uri = (Uri.parse(e).replace(queryParameters: {
...Get.parameters,
...Get.arguments ?? {},
...{'tag': e.toString()} // 增加的部分
})).toString();
return FRouter().build(uri).navigation() as Widget;
}))
];
之后运行build_runner生成CommonWebPage的路由注解;
2、在首页增加一个按钮,来模拟从网络异步加载路由表更新的功能:
这里临时写个单条json,正常情况下应该是路由表导出的完整json;目前bundle仅仅是替换操作;
GestureDetector(
onTap: () {
FRouter().updateBundle('{"post/post_info":"package:base/common/common_web_page.dart:CommonWebPage"}');
},
child: Text('替换更新路由表'),
),
经过这步,可以看到这时候再打开PostInfoPage
页面,可以发现跳转的页面是WebPage;
动态替换路由
2.1.6 自定义参数的注解使用方式
这里就要提到frouter的一个小优势了;除了基础类型之外,frouter还支持自定义数据格式;
这里参考了后端的SpringMVC的设计;
同样还是以一个例子演示一下:
这里在moduleA
中新建一个UserInfoPage
,跟基础路由一样,需要先声明上路由path;
但是需要的参数,可以直接写在构造器中,比如说这样:
除了基本类型参数的入参之外,可以看到还有像UserInfo这种自定义实体类;
参数说明:
@requestBody @requestBody 这个注解用在需要自动序列化一个实体类的情况下,其实现方式也很见简单,就是将实体类构造器所需的参数,从传入的参数中挑选出来并放进去;
@RequestParam 这个注解用来处理诸如别名之类的,像图中那样,通过此注解,将userToken别名修改为userTokenA;
这里就是新生成的路由表,可以看到除了基础数据类型,自定义格式的数据也生成了出来;别名也覆盖掉了:
现在还是老样子,跑一遍build_runner看下路由表中具体生成的内容:
运行效果如下:
带参路由
2.1.7 多模块的单元测试搭配部分
看到这里,可能有人就要问到一个问题,如果我模块单独打包甚至做成依赖项,如何在单独的子模块上运行呢?
关于这点,frouter支持通过@RouterRegister
注解来声明生成路由文件的位置;在子模块中生成路由文件后,主模块中会注册上子模块的文件路径;子模块的路由这样既实现了自身路由跟主模块的路由解耦,也能将自身注入到主模块中;
按照惯例,上例子:
在moduleA中新建一个main.dart,用来当作单独运行测试项目的入口,给其中加上RouterRegister注解
之后运行build_runner可以看到子项目生成了路由表文件,主项目的路由表文件也注册了新的子路由表;
module_a中生成的路由表
example这个主模块中的路由表注册了子模块
那么这个module_a就可以自己单独启动,提交测试了,毕竟路由表下沉到自己模块中,不再依靠主模块;
现在,moduleA模块就跟主模块彻底断开了依赖,可以自己单独运行了;
2.2 使用FRouter的跨模块通讯
跨模块通讯能力可以说是我写这个路由表的最主要的目的;而这块的设计模式,无论是TheRouter还是ARouter,都用的是同样的方式;在这里,我也没做啥创新,基本都是大同小异的东西;
不过简单介绍还是要介绍一下滴,这里就直接把TheRouter的相关解释贴一下:
在这里,由 FRouterProvider
负责服务的提供;实现方式还是参考自ARouter的部分;
2.2.1 使用方式
对于跨模块间的通讯,自然分为两个部分,服务使用方和服务提供方,这里就分别以这两个身份分别概述:
首先假想一个场景:
现在你在做一个电商APP,你将购物车相关的部分放到一个单独的模块中,此模块我们命名为商品模块;同时还存在一个直播模块;直播模块允许直接下单主播推荐的商品添加到购物车中;
服务使用方:直播模块
对于服务使用方来说,不知道也不需要知道谁来提供一个购物车;只需要能够获取到购物车并往里面加商品即可,因此首先需要提供一个沟通桥梁,或者说,接口:
比如说在公共模块中加入一个base_cart_provider接口
然后将这个接口放到公共模块中,让其他依赖此公共模块的模块能获取到这个协议桥梁,这就是上面提到的接口下沉:
最后使用的时候,调用此接口即可,具体是谁来提供服务,则看是谁接入了此服务,并符合规定路径协议;
通过FRouter.navigation方法,用路由的方式寻找一个Provider
服务提供方:商品模块
注意看,这个男人叫小白,它现在正面对着来自某个未知甲方的接口协议;虽然这份协议看上去非常可疑,但是作为一个月薪300的打工仔,协议是否合规,并不在它的职责范围内;
于是乎,作为流水线的一环,小白顺理成章的开始了接口的实现接入,殊不知它的一切行动均在佛波勒的监控之下:
就在小白完成了接口接入的同时,佛波勒突然出现,并将通过注解接收了实现好接口的服务:
要想提供一个购物车服务,先继承base中的BaseCartProvider并实现具体接口,最后通过RouterPath注解声明路径,跟路由没啥区别
惊诧于丧彪突然出现的小白,这才注意到,原来自己所实现的服务,是将我们的女主角:名为小美的商品controller提供了出去,不难想象,小美接下来会面临什么结局,当然我知道各位兄弟们不爱看后面这段,这里就不赘述了…………
然鹅小白只是个月薪300的打工仔,这些事又与他何干呢,按照剧本,接下来应该是主人公小帅英雄救美闪亮登场了;
想到这里,小白开启了下一份工作;
番外篇:拦截器小帅的英雄救美
按照剧本,这时候应该轮到一炮子能掀翻两个卡拉米的主人公小帅登场了;
但是导演说拦截器理论上应该属于路由部分,再加上资金有限;
所以导演最终决定,在这里埋个彩蛋,做好出路由篇续集的准备;
———— 某位怨气颇深不愿透露姓名的主人公小帅这么说到
2.3 全局任务FRouterFlowTask
这个全局任务其实也没啥难点;能搞出路由表来,其实剩下的东西都不算啥;基础部分都是一个原理;
不过按照TheRouter中的设计,还是可以加一点花活的:
2.3.1 模块初始化任务
因为作为跨模块的项目,虽然主模块连接了各个子模块,可以获取各自的初始化任务;
但是正像TheRouter文中说的那样,如果都放到主模块中,那么任务如果有修改,那么就需要去修改主模块的初始化方法;
麻烦或者导致git冲突之类的还是小事;假如动了别人代码导致了bug,说不定就被毕业充当输出的优质人才了;
当然,作为初始化任务管理,基于有向无环图的依赖管理,应该成标配了;
使用方法
- 在提供初始化方法的类中加上@FlowTask注解
- 在初始化方法加上@FlowTaskInject
- 最后在需要启动初始化的地方(比如说闪屏页),调用
FRouterTask().startInitTask();
启动初始化任务加载;
参数说明
- taskIdentifier 任务的唯一标识
- deepenOn 此任务依赖任务的唯一标识,中间用逗号隔开
- isInitTask 是否是初始化任务,默认false
- isNeedAwait 是否是需要等待的异步任务,默认false
其实UserInfoPage中已经写好了几个全局任务~
其运行结果是这样的:
2.3.2 模块全局任务
全局任务的调用像这样即可:
FRouterTask()
.loadTaskMeta('moduleA_test_parameter')
?.apply(parameters: ['燕子,没有你我可怎么活啊']);
apply
方法有两个可选参数,一个是positionalArguments
,一个是namedArguments
,作用就是字面意思,一个传位置固定类型的入参,一个传命名类型的入参;
画饼专用的RoadMap
todo:
-
路由框架
就像之前说的,现在仅仅是路由库的部分,可以说现在是能用而已,就好比一个仅仅支持了命令行操作,但没有GUI界面的程序;离方便好用还缺点东西;
另外我也想故意不小心保留一点自己理想路由的一些功能;
-
模块mock和测试支持;
这块的东西该咋结合mock,还真没细细了解过;之后看看TheRouter怎么实现的;
-
启动优化加速(flutter3.7版本才可以)
其实看到有向无环图,再结合上flutter3.7的特性,老android们估计就笑了,毕竟这些都是镌刻在灵魂深处的八股文~~不过细想一下,好像任务量还不少,基础部分缺失的还不少…………好像实现完,都可以单出一个插件了?
不过不得不说,这回flutter3.7更新了一些底层开放能力,这才好玩嘛~~~
-
单模块自动初始化任务?
看了下TheRouter中的这部分,感觉其实就是提供了一个特殊的deepOn标志,然后监听路由变化,在特定页面调用这些特定标志标记的任务而已
感觉需要结合上路由的监听;所以打算放到路由部分中统一完成;
-
任务结果自动注入
后来想了想,可能还存在某些任务需要其他任务的返回结果什么的;举个例子,根据ABTest模块的初始化返回结果,判断是否启用某个悄咪咪窃取用户信息模块的初始化任务;
虽说这玩意现在也不是不能实现,实在不行我通过调用全局任务的做法获取返回值之类的,并将后续逻辑都统统写在一起放到一个大方法中,再以全局任务的形式提供出去;但感觉这块如果有个依赖注入的功能更好;毕竟,如果以第一段中的例子为例,也不是不存在ABTest模块和目标模块之间有着一大段其他模块初始化任务的可能性;强行写到一个任务中非常难管理;
总结
其实总结也没啥好写的,甚至那一套什么欢迎Issue欢迎PR之类的东西都因为项目还处于私有demo阶段也没法写%……;
不过要说的话,虽说路由这东西本身并不难,主要难点还是实现这块,毕竟资料少;实际上项目功能点实现思路和设计方面,其实主要还是靠下面这些优秀的开源项目:
特别感谢部分
-
要实现的功能列表:TheRouter(确实是最符合我心目中的跨模块解决方案,所以对标的功能直接按他们的来,好像这篇文章的模版也是洗稿他们的~)
-
路由部分的核心框架和思路来自于:ARouter(虽然前面提了TheRouter,但单单挑出路由设计上,我还是感觉ARouter的方式更符合模块化设计)
-
路由表的思路收到了 CC 的影响;(可惜Flutter不支持完美的那种基于事件消息而非路由的通讯方式,要实现这点的话,估计要接原生了,那就太重了)
-
参数解析功能直接照抄:SpringMVC(还是SpringMVC比较符合多端URI通讯的方案)
-
ast 分析受到了 ff_annotation_route 的启发(虽然并没有采用它的方案,但是通过阅读它的源码收到了很大的启发)
-
路由JSON处理和动态化能力思路来自 Fair(动态化的部分思路基本照搬Fair,也就核心实现不同,不过因此,像在没有映射缓存的情况下无法做到动态化之类的问题,也给继承过来了✌️)
-
启动优化部分来自 AppStartFaster (Android启动优化的老祖宗之一,虽然可能随着它的方案被集成进了官方而降低了维护频率,但确实是我启动优化的思路来源)
另外,特别感谢 Github Copilot!!!
学习api的过程中没有文档和demo怎么办?就给你一堆没写注释的test工程,连这个test作用是什么都不知道,两眼抓瞎不知道怎么写怎么办?
试试Colilot吧,Github Copilot 收录了全github的代码,你不懂没关系,总有人懂并通过copilot告诉你怎么写;AI的发展速度确实真的令人惊喜~
///todo: 问下ChatGpt如何用ChatGpt润色文章,再问下ChatGPT写啥说辞,才能让像我这种的白嫖精能给点改进建议啥的;
转载自:https://juejin.cn/post/7209825720385011771