基于Get实现的多实例启动与启动模式
前言
我们知道在 Android 开发中我们默认启动页面都是多实例的,例如工作详情的推荐列表中点击工作,还是会跳转到工作详情,我们不需要做任何操作就能开启多个工作详情页面的实例。
而在 Flutter 中默认启动页面是单实例的,特别是 Getx 框架中,如果用 Get 的路由方式跳转,默认的 preventDuplicates = true 都给限制死了,当我们需要开启多实例的时候还需要自己处理页面的 key 和 对应 controller 的 tag 管理。
由于我们项目中一半的页面是单实例就行,还有一半的页面都可能遇到多实例的问题,每次都需要额外处理感觉太麻烦,由于我们的项目已经是基于 Get 框架搭建的,所以就在 Get 路由的基础上扩展出类似 Android 页面跳转一样的多实例,并且支持像 Android 一样的启动模式。
我感觉这一点也比较符合 App 页面跳转的逻辑,那么需要做到这一点我们需要做哪些改动呢?
- 页面创建的时候如果没有指定 Key 就用自动的 Key 管理。
- GetxController 的 tag 基于 key 自动管理,方便与页面一一对于。
- 启动模式的处理【传送门】。
最终的效果为三种入口:
scss
Get.start(RouterPath.AUTH_FORGET);
Get.start(RouterPath.AUTH_LOGIN, launchModel: LaunchModel.singleTop)
Get.startWithPop(RouterPath.AUTH_FORGET);
Get.pop();
下面就一起看看如何实现吧。
一、页面自动Key的管理与Controller的管理
首先要实现页面的多实例启动,我们就需要为每一个Page设置单独的key,给对应的每一个Controller设置单独的tag,那么我们就需要单独管理Page与Controller的对应关系。
如何管理 Page 与 Controller 呢?我们要实现类似的方法有三种,继承,扩展,混入。
由于我们的 Page 页面一般都默认继承于 StatefulWidget 或 StatelessWidget 的,如果用扩展那肯定不行,因为这些布局不止是作为页面跟视图,还能是默认的子视图 Widget ,我们不能每个 Widget 都加上逻辑。而使用混入需要每一个页面的入口文件都加上混入文件也比较麻烦,不如继承来的方便,因为本身就是需要每一个 Page 页面实现这个逻辑,我们只需要把之前继承自StatefulWidget 或 StatelessWidget 的类改为我们自己实现的基类即可。
既然我们确定了方案,我们首先就要确定一个全局的 GlobalKey ,用于自增 Key 的使用
javascript
/*
* 全局的Key管理,可以用于默认页面或Widgert标识
*/
class GlobalKeyFactory {
static BigInt _counter = BigInt.zero;
static GlobalKey createGlobalKey() {
final key = GlobalKey(debugLabel: 'global_key_${_counter.toString()}');
_counter += BigInt.one;
return key;
}
}
那么我们继承的两种 Base 基类中我们就能根据是否有key来设置对应 Page 的 key 值。
scala
/*
* 页面的基类,StatelessWidget 类型
*
* 如果没有传Key,则自动管理Key
*/
abstract class BaseStatelessPage<T> extends StatelessWidget {
BaseStatelessPage({Key? key}) : super(key: key ?? GlobalKeyFactory.createGlobalKey()) {
_createController();
}
T? _controller;
T? get controller => _controller;
void _createController() {
try {
_controller = GetInstance().put<T>(createRawController(), tag: key?.toString());
} catch (e) {
_controller = null;
}
}
T createRawController();
@override
Widget build(BuildContext context);
}
而 StatefulWidget 有点麻烦,我们需要实现两个基类:
scala
/*
* 页面的基类,StatefulWidget 类型
*
* 如果没有传Key,则自动管理Key
*/
abstract class BaseStatefulPage<T> extends StatefulWidget {
BaseStatefulPage({Key? key}) : super(key: key ?? GlobalKeyFactory.createGlobalKey()) {
_createController();
}
T? _controller;
T? get controller => _controller;
void _createController() {
try {
_controller = GetInstance().put<T>(createRawController(), tag: key?.toString());
} catch (e) {
_controller = null;
}
}
T createRawController();
}
State 类我们单独封装出来:
scala
/*
* 页面的基类,StatefulWidget 类型
*
* 如果没有传Key,则自动管理Key
*/
abstract class BaseState<T extends BaseStatefulPage<C>, C> extends State<T> with RouteAware {
C? _controller;
C? get controller => _controller;
@override
void initState() {
_createController();
super.initState();
}
void _createController() {
try {
_controller = GetInstance().find<C>(tag: widget.key?.toString());
} catch (e) {
_controller = null;
}
}
为什么要用可空字段标记 _controller ? 因为有可能一个页面 Page 没有逻辑也就没有对应的 Controller,所以需要做一下空的兼容。
需要注意的是我们的基类中已经处理了 Controller 的注入了,并且自行处理了 tag 。所以在路由表中我们就不需要写binding了。
less
//...
GetPage(
name: RouterPath.NOTIFICATION_ENABLE,
page: () => NotificationEnablePage(),
binding: NotificationEnableBinding(), //不需要Binding了,默认实现的Binding没有注入Tag会报错
),
GetPage(
name: RouterPath.DARK_MODEL,
page: () => DarkModelPage(), //直接对应name 与 page 即可
)
//...
使用的时候:
不带Controller的用法:
scala
class DarkModelPage extends BaseStatefulPage {
DarkModelPage({super.key});
//启动当前页面
static void startInstance() {
return Get.start(RouterPath.DARK_MODEL);
}
@override
State<DarkModelPage> createState() => _DarkModelPageState();
@override
createRawController() {}
}
class _DarkModelPageState extends BaseState<DarkModelPage, dynamic> {
int selectedMode = 0; // 0-跟随系统,1-亮色模式,2-暗色模式
...
}
带Controller的用法:
scala
class SettingPage extends BaseStatefulPage<SettingController> {
SettingPage({super.key});
@override
State<SettingPage> createState() => _SettingPageState();
@override
SettingController createRawController() {
return SettingController();
}
}
class _SettingPageState extends BaseState<SettingPage, SettingController> {
@override
Widget build(BuildContext context) {}
}
Stateless页面的用法:
scala
class SettingPage extends BaseStatelessPage<SettingController> {
SettingPage({super.key});
@override
SettingController createRawController() {
Log.d("当前的key:${key?.toString()}");
return SettingController();
}
@override
Widget build(BuildContext context) {}
}
二、启动模式
在前文中我们简易的实现过启动模式了【传送门】。之前是基于单页面实现的,实现的实例是自建页面路由栈表,然后通过目标页面在路由栈的位置决定使用哪一种 Api 去跳转。
我们在原文本质工具类不变的情况下使用扩展方法的方式给 Get 扩展我们自己的启动方式,Get 的路由启动方法名称比较迷惑,我个人还是比较喜欢比较语义化的启动名称如 start、startWithPop 、pop 等方法。
我们修改代码如下:
typescript
enum LaunchModel {
standard, //默认模式
singleTop, //检查栈顶
singleTask, //检查全栈
}
extension GetRouterNavigation on GetInterface {
/// 查询指定的RouterName是否存在自己的路由栈中
bool isRouteExist(String routerName) {
return MyRouterHistoryManager().isRouteExist(routerName);
}
/// 查询指定的RouterName是否存在自己的路由栈顶
bool isTopRouteExist(String routerName) {
return MyRouterHistoryManager().isTopRouteExist(routerName);
}
/// 跳转页面SingleTask模式
void _toNamedSingleTask(
String routerName, {
dynamic arguments,
bool preventDuplicates = true,
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) {
if (isRouteExist(routerName)) {
Get.until((route) => route.settings.name == routerName);
} else {
Get.offNamed(
routerName,
arguments: arguments,
parameters: parameters,
preventDuplicates: preventDuplicates,
)?.then((value) => {
if (cb != null) {cb(value)}
});
}
}
/// 跳转页面SingleTop模式
void _toNamedSingleTop(
String routerName, {
dynamic arguments,
bool preventDuplicates = true,
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) async {
if (isTopRouteExist(routerName)) {
Get.until((route) => route.settings.name == routerName);
} else {
Get.toNamed(
routerName,
arguments: arguments,
parameters: parameters,
preventDuplicates: preventDuplicates,
)?.then((value) => {
if (cb != null) {cb(value)}
});
}
}
/// 默认的跳转方式,带自己的回调
void _toNamedStandard(
String routerName, {
dynamic arguments,
bool preventDuplicates = true,
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) {
Get.toNamed(
routerName,
arguments: arguments,
parameters: parameters,
preventDuplicates: preventDuplicates,
)?.then((value) => {
if (cb != null) {cb(value)}
});
}
/*
* 启动新页面
*/
void start(
String routerName, {
LaunchModel launchModel = LaunchModel.standard,
dynamic arguments,
bool preventDuplicates = false, //默认多实例
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) {
//检查栈顶
if (launchModel == LaunchModel.singleTop) {
_toNamedSingleTop(
routerName,
arguments: arguments,
cb: cb,
parameters: parameters,
preventDuplicates: preventDuplicates,
);
//检查全栈
} else if (launchModel == LaunchModel.singleTask) {
_toNamedSingleTask(
routerName,
arguments: arguments,
cb: cb,
parameters: parameters,
preventDuplicates: preventDuplicates,
);
//默认启动
} else {
_toNamedStandard(
routerName,
arguments: arguments,
cb: cb,
parameters: parameters,
preventDuplicates: preventDuplicates,
);
}
}
/*
* 关闭当前启动新页面
*/
void startWithPop(
String routerName, {
dynamic arguments,
bool preventDuplicates = false, //默认多实例
void Function(dynamic value)? cb,
Map<String, String>? parameters,
}) {
Get.offNamed(
routerName,
arguments: arguments,
parameters: parameters,
preventDuplicates: preventDuplicates,
)?.then((value) => {
if (cb != null) {cb(value)}
});
}
/*
* 关闭页面,调用Get的back方法
*/
void pop<T>({
T? result,
bool closeOverlays = false,
bool canPop = true,
int? id,
}) {
Get.back(result: result, closeOverlays: closeOverlays, canPop: canPop, id: id);
}
}
三、测试与Log
我们以三个页面 SettingPage -> DartModelPage -> LoginPage 来测试跳转逻辑,打印的Log如下:
启动 SettingPage
启动 DartModelPage
启动 LoginPage
返回
再返回到 SettingPage了,由于DartModelPage 没有Controller,所以没有销毁Controller的步骤。
由 Loginpage 直接返回 SettingPage。
Get.start(RouterPath.SETTING,launchModel: LaunchModel.singleTask);
基本符合我们的预期。
四、 优缺点与注意要点
优点:
- 代码简洁: 通过自动化的方式减少了重复代码,这有助于简化Controller的创建和维护过程。
- 多实例启动: 使用Key作为tag确保了同一页面的不同实例有着不同的唯一的Controller实例。方便多实例启动,配合启动模式更符合客户端开发体验。
- 方便扩展:由于我们使用的继承的方式,每一个页面都用了基类,那么一旦有页面的全局修改,我们可以直接基于基类去扩展,例如Android对Activity进行扩展。因为我们的默认页面都是基于 StatefulWidget 或 StatelessWidget 实现的,一旦有全局的页面修改,我们不可能对StatefulWidget、StatelessWidget进行扩展。
缺点:
- 复杂性上升: 对于新开发者来说,理解自动化的tag管理可能需要一定的学习曲线,并且还需要对Get框架比较了解。
- 潜在的内存泄露: 如果不适当管理tag,那么被Dispose的页面负责的Controller可能不会从Getx的内部存储中删除,有潜在的内存泄漏风险。
注意要点:
由于我们自动管理了Tag,那么在使用或者获取我们的 Controller 的时候就需要注意了。
例如我们在使用 GetBuilder 的时候,我们需要传入tag属性,为了方便我封装了方法,可以替换默认的 GetBuilder 当然不是必须的,你可以自己维护。
dart
//创建自动绑定Controller的GetBuilder
GetBuilder<C> autoCtlGetBuilder({
required Widget Function(C controller) builder,
final Object? id, //Controller根据id来刷新指定的GetBuilder区域
final bool autoRemove = true,
final bool global = true,
final bool assignId = false,
final void Function(GetBuilderState<C> state)? initState,
final void Function(GetBuilderState<C> state)? dispose,
final void Function(GetBuilderState<C> state)? didChangeDependencies,
}) {
assert(_controller != null, "controller不能为空,请在 BaseState 与 BaseStatefulPage 中指定正确的泛型自动初始化");
return GetBuilder<C>(
init: _controller,
tag: key?.toString(),
builder: builder,
);
}
另外我们获取其他页面的 Controller 的时候注意添加Tag,比如我在A页面获取B页面的Controller,从而直接操作B的逻辑,虽然不推荐这么用,但是有人这么用,此时我么在A页面find的时候注意加上Tag,推荐使用固定的Tag。
因为B页面肯定是单实例才能这么玩,那么就可以指定Key,再根据Key获取对应的实例。
例如B的路由:
less
GetPage(
name: RouterPath.MAIN,
page: () => MainPage(
key: const ValueKey(RouterPath.MAIN),
),
),
在A页面中获取B的控制器:
vbscript
MainController mainController = Get.find<MainController>(tag: const ValueKey(RouterPath.MAIN).toString());
这是用的比较多的两个地方,如果还有其他的Controller场景注意需要处理tag即可。
总的来说对客户端开发比较友好,但是有一点学习成本。
后记
我们都知道 Flutter 的路由跳转方式有很多种,一些第三方的路由方式例如 auto_route、go_router 之类的太多了,但是大多数都是基于原生 Flutter 的思路是单页面启动思路,本文就是基于原生开发多实例开发的思路扩展一下,也是给大家多一种选择。
一定需要注意的是这不是最好的方案,没有最好只有最合适,我当然知道每一种路由方案其实都能直接或间接的实现类似多实例的效果,只是由于我们的项目太多这种多实例启动页面,又是基于 Get 框架搭建的所以才有这种特殊的处理方式,比较适合我们自己的项目。
当然如果你也想要多实例的启动方式,又不是 Get 框架搭建的,其实不管是其他的路由方式也好,原生的路由方式也罢,你都能间接的实现,具体的细节不难。
那为什么我的页面有生命周期?这又是另一个故事了也是后期要分享的,基于继承与混入实现路由页面的生命周期监听。
那么本期内容就到这里,如讲的不到位或错漏的地方,希望同学们可以评论区指出,如果有更多更好更方便的方式也欢迎大家评论区交流。
本文的代码已经全部贴出,部分没贴出的代码可以在前文中找到,也可以到我的 Flutter Demo 查看源码【传送门】 。
如果感觉本文对你有一点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力啦!
Ok,这一期就此完结。