基于Getx的Flutter应用架构
前言
架构
这里讲的架构并不是MVC, MVP, MVVM之类的. 其实像React Native, Flutter这种声明式UI, 架构上天生就是MVVM. 当然你还可以搞点花样, 加上Redux, 弄成个MVI也行, 只不过我三年的React Native经验告诉我, Redux不太好用. 所以我使用MVVM就行了.
这里讲的架构, 主要是充分利用Getx来架构整个App, 更倾向于整体架构上的一些细节, 而不是着重在MVP这样的分层.
Getx
Getx是一个很强大的库, 好像也是pub.dev上like数最多的一个库.
既然有这么多人Like它, 那我就也来分享一下我的使用经验吧.
Getx自己主要是分成四部分的:
- Router (路由, 跳到某页去)
- DI (依赖注入, 类似Dagger, Koin)
- State管理 (管理widget的state, 类似RN中的Redux, 或Android中的Presenter/ViewModel)
- Utilty (各种工具类, 工具方法) 下面的讲解也是围绕这几部分来的
二. 路由
1. 为何不用Flutter自己的Router系统
Flutter自己是有router系统的, 但缺点也明显 1). 这个自带的router的named router有局限性, flutter并不推荐用. 但是named router明显能快速对接deep links, 所以这种局限性很不利于我们开发
2). 功能有限. 像我们需要的off, offAll这些功能就没有 (getx里有)
3). 使用时还需要有一个context实例. 但我们并不是随时随地都持有一个context的, 这也局限了我们的使用场景.
4). 使用起来麻烦
// 这是Flutter自带的router系统
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SongScreen(song: song)
),
);
// 这是Getx
onPressed: () => Get.to( SongScreen() );
所以我还是推荐使用Getx的Router系统
2. 路由 (Router)
因为这毕竟不是一篇介绍Getx的入门文, 所以这里就不多做Getx的讲解, 只做一些关键的说明, 或是架构上的说明.
2.1 定义路由
我们一般要先定义一个Getx的Router. 因为我们要对接deep link, 即后台给我们一个yourcompany://page1?id=23
的string, 你能跳到某一页去 (并带上参数id=23). 所以我们的基本要求就是: 要能通过一个plain string就能知道要跳到哪个页面去, 并支持传参
GetMaterialApp(
initialRoute: "/home",
unknownRoute: GetPage(name: "/404", page: () => const NotFoundPage()),
routingCallback: (routing) { ... } //相当于跳转的监听器
getPages: [
GetPage(name: "/home", page: ()=> const HomePage()),
GetPage(name: "/detail", page: ()=> OneDetailPage()),
initialRoute
是首页是哪个unknownRoute
是没找到对应的页面时, 就去这个页面getPages
定义一个Map<String, function>的路由表. 注意page都是函数, 这样我们就能做到lazy initialization. 要是用GetPage(name: "/home", page: HomePage())
这样的路由表, 那一打开app, 所有页面都初始化好了, 太浪费资源, 也太慢了.routingCallback
是跳转的监听器, 当你跳转到下一页, 或是按back等会调用它. 详情可见这里.
坑1
很多从web转过来的人, 都喜欢让initRouter定义为"/". 但在Getx中, 这样做会让unkonwRoute失效. 这是我经过反复研究才找到的一个隐藏bug吧.
也就是说, 为了unknowRoute能成功, 你的initRoute不能是"/".
2.2 跳转
这里的跳转功能就很丰富了, 下面一一讲解
Get.to(NextScreen());
Get.toName("/detail"); //比起to(), 一般使用toNamed()
// 跳转时带参数
Get.toNamed("/router/back/p3?id=100&name=flutter")
// 本页finish, 再跳detail页
Get.offNamed("/detail"); //其实就是说用detail页来replace掉本页
// 使用场景: 当点了"logout"按钮时, 清除所有页, 再跳到login页去
Get.offAllNamed("login"); //类似Android中的clear_task | new_task
// 后退
Get.back();
注意到Getx的路由跳转是不需要context的, 这样你在任何地方的代码(如ViewModel, Repository, ...)都可以写跳转的代码.
跳转时的传参
当然, 若你的参数是非String, 是个普通类, 那就得用argument
, 而不是parameter
// A1). 带Map<String, String>参数
final params = <String, String> {"source": "p1", "value": "230"};
Get.toNamed("/router/back/p2", parameters: params);
// 或用这种方式也行
Get.toNamed("/router/back/p3?id=100&name=flutter")
// A2). 下一页中取出Map<String, String>参数
String source = Get.parameters["source"] ?? "<default>";
String name = Get.parameters["name"] ?? "<default>";
// - - - - - - - - - - - - - - - - - - - - - - - -
// 若参数不是Map<String, String>类型, 就用不了parameter
// 这时就要用 Get.toNamed("..", argument)
// B1). 存入值
final params = Offset(12, 13);
Get.toNamed("/router/back/p3", arguments: params);
// B2). 取出值
final args = Get.arguments;
2.3 路由中间件
Router Middleware可以理解为, 你要跳转时, 就得先去中间件里报个到, 中间件们或是打日志, 或是发现没登录就重定向到登录页去, 或是埋点, ..., 总之所有中间件过一遍, 没问题了, 才能真正到达跳转的终点
2.3.1 中间件的callback版本
这个严格来说是个路由的listener, 而不是中间件. 不过每次跳转都会经过它(包括你按back, 或是dismiss掉一个dialog), 所以你可以用它来记录一些比如Page栈的工作, 或是埋点的工作
@override
Widget build(BuildContext context) {
return GetMaterialApp(
initialRoute: "/home",
unknownRoute: GetPage(name: "/404", page: () => const NotFoundPage()),
routingCallback: (routing) {
MyRoutingLifecycle.onRoutingChange(routing);
},
这样我就可以知道跳转的一些细节(都在routing参数里). routing参数的类型是Routing
, 关键源码就是:
class Routing {
String current, previous, removed
dynamic args;
Route<dynamic>? route;
bool? isBack, isBottomSheet, isDialog;
通过使用这些成员你就能知道跳转的上一级, 当前页, 是否是按了back之类的.
备注1: 当你dismiss dialog时, isBack为true哦
备注2: 当isBack
= true时, previous
参数不太准, 所以不要过分相信这个previous
参数. 这个应该是Getx的一个bug.
2.3.2 带生命周期的中间件
你只要让一个或多个类继承自GetxMiddleware
, 重载它里面的某些生命周期方法就能做到拦截/监听路由跳转了.
GetxMiddleware有多个方法, 总结如下:
图示:
1). 灰色背景的(前缀为M-
)就是Middleware中的生命周期方法
2). 粉色背景的(前缀为W-
)就是Widget自己的方法, 如build()
方法.
说一些我个人的使用经验, 那就是我会依赖全局binding (后面第三大节会讲到DI中的binding, 你可以理解为Koin或Dagger中的module), 而不是页面级别的binding. 所以这些方法里的onBindingStart
与onPageBuildStart
就比较少用.
用得较多时是在Widget创建之前做些处理, 即类似这样的:
if(isVip) goTo(vipPage)
else if(isGuest) goTo(loginPage)
...
那这时我们就可以用 1. 注册中间件
GetMaterialApp(
GetPage(
name: "biz/cart",
page: () => CartPage(),
middlewares: [
AuthenticatedGate(), LogGate(),
],
2. 中间件中拦截跳转
class AuthenticatedGate extends GetMiddleware {
@override RouteSettings redirect(String route) {
final authService = Get.find<AuthService>();
return authService.authed.value ? null : RouteSettings(name: '/login')
}
}
三. 依赖注入 (DI)
Getx的DI主要就是 Get.put(obj)
, 然后取出来用obj = Get.find()
. 这样就能存对象, 取对象了.
初看好像很简单, 但其实是有坑的, 特别是在使用Binding时.
3.1 Binding
现在假设我们有两个页面, 一个是Home页展示各种商品, 另一个Detail页展示一种商品的详情. 在Getx中我们可以使用Binding, 这个Binding类似Dagger中的module, 或是Koin中的module, 即提供对象的.
class HomeBinding implements Bindings {
@override
void dependencies() {
Get.lazyPut<HomeController>(() => HomeController());
Get.put<Service>(()=> Api());
}
}
class DetailsBinding implements Bindings {
@override
void dependencies() {
Get.lazyPut<DetailsController>(() => DetailsController());
}
}
这样你可以在路由系统中加入binding, 这样跳入到home页与detail页时就能自带上面的HomeController, Service, DetailsController这些对象了.
Get.to(Home(), binding: HomeBinding());
//或是:
Get.to(DetailsView(), binding: DetailsBinding())
3.2 Binding的缺点
上面的写法, 其实有两个很大的缺点.
第一个缺点
Get.to(widgetObj, bindings)
是可以注入binding.
但是Get.toNamed()
并不支持binding参数啊. 我的跳转一般都是用toNamed的, 所以注定了这种方式我用不了.
第二个缺点
这个缺点很隐藏, 很容易出问题. 以上面的binding为例
HomeBinding
中提供了 HomeController, Service 两个对象DetailsBinding
中提供了 DetailsController 对象 但其实我们的Details页中也会用到Service对象.
之所以不出现"details页中说找不到Service"的crash, 是因为用户先打开的home页, Home已经往Get中写入了Service对象了, 所以等之后打开detail页时, serivce对象已经有了, 能够Get.find()
得到, 所以不会有NPE错误.
但要是deep link的场景呢?
: 你直接跳到了Detail页, 结果就因为没有经过home页, 所以Service service = Get.find()
找不到service对象, 应用会crash.
所以现在就明白了, 第二个缺点就是: 上面两个Binding有隐藏的依赖性 DetailsBinding其实依赖于HomeBinding. HomeBinding不先放好service, 那DetailsBinding提供不了Serivce, 就可能会让Detail页crash.
3.3 全局Binding
也就是像Dagger或Koin一样, 一开始就定义好一个全局的"对象提供表", 即Dagger与Koin中讲的Module
啦.
这样好处是:
1). 因为是全局的, 所以我们使用Get.toNamed("...")
也能使用到全局提供的对象
2). 因为是全局的, 所以没有什么Binding1依赖于Binding2的问题. 总共就一个Binding嘛, 自然没什么依赖的前后关系问题.
定义全局Binding
GetMaterialApp(
initialBinding: AppDiModule(), // 就是定义这个
...
);
class AppDiModule implements Bindings {
@override
void dependencies() {
Get.lazyPut(() => AppleRepository(), fenix: true);
Get.lazyPut(() => BoxService(), fenix: true);
Get.lazyPut(() {
BoxService service = Get.find();
return BoxRepository(service);
}, fenix: true);
}
}
具体业务页面中取出值
这个就容易了, 直接使用Get.find()
就行了. 如:
class DiPage2 extends StatelessWidget {
@override Widget build(BuildContext context) {
HoeService service = Get.find();
class DiPage3 extends StatelessWidget {
@override Widget build(BuildContext context) {
HoeRepository repo = Get.find();
final service = repo.service;
3.4 坑3: put(Clazz())多次是什么结果
若是我们调用 Get.put(MyController())
三次, 之后再final ctrl = Get.find()
, 那这个ctrl是最新的ctrl(即后put的覆盖了前面的值), 还是最老的ctrl(即后put的被忽略了) ?
通过查看源码, 发现put时, 其实是放到了Map<String, dynamic>的变量池里了.
而这个map的key就相当于 obj.class + tag
.
同时注意, 若key一样, 那就自动忽略, 不走map.put(key, value).
所以上面的问题的答案, 就是: find出来的是最早放入的ctrl. 后续put的值会被忽略.
3.5 坑4: 共享controller
你只要前面Get.put(ctrl)后, 在其它页面中都可以用Get.find()来得到ctrl变量. 而且这个ctrl变量是类似于单例的, 即多个页面使用的ctrl是同一个对象. 这就类似Android中多个Fragment共用一个ViewModel
备注: Java中打印对象会有内存地址打印出来, 这样我们就知道是不是同一个对象
而Dart中不会公开内存地址, 要想知道是不是同一个对象, 就得用obj.hashcode
. 只要hashcode一样, 那就是同一个对象
不过实践经验告诉我, 多个页面的GetxController还是不要共享的好. 因为页1与页2共用了同一个ctrl时, 这样就相当于页2依赖了页1中的一些状态, 也就有了隐藏的依赖关系. 这是很容易出问题的.
这时要是想不共享, 那就得用tag
final ctrlOfPage1 = Get.put(MyController(), tag: "home")
final ctrlOfPage2 = Get.put(MyController(), tag: "detail")
//取出值
MyController ctrlOfPage1 = Get.find(tag: "home")
MyController ctrlOfPage1 = Get.find(tag: "home")
print("ctr1 = ${ctrolOfPage1.hashcode}, ctrl2 = ${ctrlOfPage2.hashcode}") //可以看出两个hashcode不一样, 即表示这是两个对象.
四. state管理
好了, 这个是我喜欢Getx的一点. 不过在先说之前, 先得说我最讨厌React Native的一点
4.1 声明式UI框架的一个普遍问题
像Flutter, React Native这些声明式UI框架, 我碰到过的性能问题, 主要是两点
1). 这些声明式UI全是UI框架, 一涉及到非UI的东西, 就要走Android, iOS端. 这时的来回传数据, 可能会有性能问题.
-- 当然, 这个不绝对. 因为我在做一个React Native项目时, 我用crypto-js去解密一个作品时, 很慢. 但当我下沉到Android, iOS端去解密, 反而性能大大提升了. 所以还是要看使用场景.
2). setState()
式的刷新
也就是说你一个TextView要刷新了, 结果我们调用setState却是刷新整个页面. 这一点在React Native里更加明显, 特别是超长的list列表时, 性能超级差.
题外话: 当然, 还有一些声明式框架也不是刷新全部的, 只刷新要更新的View, 如SwiftUI, 以及Groovy的ui框架Griffon, 以及web界的SolidJS, 这样对性能自然也就好. 以Solid JS为例, 它因为能精确刷新某一组件, 而不是刷新整个页面, 但效率提升得有多好呢? 来看下面的图, 比较下因为setState而刷新整个widget的React就很明显了
4.2 Getx对性能的提升
Getx就像Solid JS一样, 能精确刷新某一个需要刷新的组件, 而不是刷新整个页面, 所以你的Flutter app效率自然就更好了.
备注: 因为不需要刷新整个页面, 所以在使用Getx时, 完全可以不用StatefulWidget. 仅仅使用StatelessWidget基本上就够了.
基本使用方式
// 两种声明方式
final name = "".obs; //声明方式1, 使用obs
Rxn<ui.Image> imgSrc = Rxn<ui.Image>(); //声明方式2, 使用Rx或Rxn.
// 使用:
Obx( ()=> MyWidget(imgSrc.value) )
// 更新
name.trigger(newValue)
备注: Rxn
可以不提供初始值, 这在一些异步场景中就比Rx
更好用了.
4.3 Getx的state管理的先进性
讲过最大的好处之后, 我们现在来全面地看一下Getx的statet管理的各种好处.
现在的flutter一些state管理库, 要么像Bloc一样使用很复杂, 要么像Mobx一样要用代码生成(这很慢), 所以Getx想要快一点, 使用更简单的state管理.
-
说它简单, 是因为你不要使用什么StreamController, StreamBuilder, 或为一个状态专门创建一个类. 直接用
"name".obs
中的obs就能创建一个可监听的值- 而且你也不用像React一样, 自己定义
memo( oldProps, newProps => ...)
来自己定义要如何比较. Getx会自己比较Obx(()=>widget)
中的值的前后变化, 来决定是否要更新的.
- 而且你也不用像React一样, 自己定义
-
说它快, 是因为它不用代码生成. flutter中的codegen真的很慢
-
另外, 最大的一个特点就是, 用了Getx的state管理之后, 你再也用不着StatefulWidget了. 仅仅StatelessWidget就够你用了! 性能自然也提升很多!
4.4 GetxController
这个就有点类似ViewModel, Presenter或Controller类. 你的那些数据以及操作数据的方法都在这里, 如:
class MyResearchCtrl extends GetxController {
Rx<Color> color = Rx(Colors.indigo);
void setColor(Color c) => color.trigger(c);
}
这样你就能在多个页面中使用, 甚至是共享这个GetxController:
// 共享
class ControllerAndPage3 extends StatelessWidget {
@override Widget build(BuildContext context) {
final ctrl = Get.put(MyResearchCtrl());
class ControllerAndPage2 extends StatelessWidget {
@override Widget build(BuildContext context) {
final ctrl = Get.find<MyResearchCtrl>();
return Column(
children: [
Obx( () => Text(ctrl.color.toString)),
TextButton( onPressed: () {
ctrl.setColor(Colors.orange);
}, child),
]
)
4.5 生命周期
上面讲过Getx多是使用StatelessWidget. 但麻烦就来了, 即StatelessWidget没有生命周期方法
而StatefulWidget是有生命周期方法的, 其实是它的State有啦. 它的initState
与dispose
方法就是生命周期的开始与结束), 那碰到这种要在页面打开时注册某一东西, 然后在页面退出时注销掉什么(以免内存泄露)时, 要怎么办?
: 不用担心, GetxController就有生命周期, 这样就能代替StatefulWidget的生命周期.
4.6 题外话: 动画
上面讲了Getx基本上使用StatelessWidget就够了. 但有一种场景, 就是做动画. 我们做动画是需要ticker的, 而一般用的ticker都是和State相匹配的Ticker.
class _MyState extends State<MynPage> with TickerProviderStateMixin {
这时我仅用StatelessWidget也能做动画吗?
: 答案是: 可以的, 只不过要借助GetxController的帮忙.
Getx为了让我们在StatelessWidget上也做动画, 提供了一个ticker, 叫GetSingleTickerProviderStateMixin
. 它是要求在GetxController上使用的, 所以我们一般可以这样:
class MyAnimationPresenter extends GetxController with GetSingleTickerProviderStateMixin {
final int durationInMs;
late AnimationController animCtrl;
MyAnimationPresenter({required this.durationInMs}) {
animCtrl = AnimationController(vsync: this, duration: Duration(milliseconds: durationInMs));
}
结语
Getx提供了
- 更强大的Router系统
- 更加提升性能的State管理
- 更多Utils方法 (如求屏幕宽高, 如不用context就能显示dialog, ...) 所以真的推荐使用Getx来架构你的app.
当然Getx还有DI系统, 只不过感觉还行, 只不过不是这么惊艳.
备注: 依照我个人喜欢, 其实我更喜欢用flutter-koin. 因为它简单, 好用, 还和我以前在Android中使用的koin一脉相承. 不过Getx的DI也还行, 只不过没有Koin中的factory
, single
这样好用而已.
架构
具体到系统架构上, 总结下就是:
- 把页面分解为 StatelessWidget 与 GetxController. 前者放UI, 后者放数据与业务逻辑
- 这一点类似于Android中的MVP, MVVM分层
- 在UI层面, 尽量使用StatelessWidget 与 Obx, 这样你的某一个数据变化时就只要去刷新一个widget, 而不是setState式的刷新整个页面. 这对我们app的性能有帮助
- 整体app的公用依赖对象, 全都放到GetMaterialApp里的initialBinding里去. 这样所有的widget, controller等在需要时都能取到这些对象.
- 利用Router系统来方便我们的跳转
转载自:https://juejin.cn/post/7230823928759582757