基于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