likes
comments
collection
share

基于Getx的Flutter应用架构

作者站长头像
站长
· 阅读数 17

前言

架构

这里讲的架构并不是MVC, MVP, MVVM之类的. 其实像React Native, Flutter这种声明式UI, 架构上天生就是MVVM. 当然你还可以搞点花样, 加上Redux, 弄成个MVI也行, 只不过我三年的React Native经验告诉我, Redux不太好用. 所以我使用MVVM就行了.

这里讲的架构, 主要是充分利用Getx来架构整个App, 更倾向于整体架构上的一些细节, 而不是着重在MVP这样的分层.

Getx

Getx是一个很强大的库, 好像也是pub.dev上like数最多的一个库.

基于Getx的Flutter应用架构

既然有这么多人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有多个方法, 总结如下:

基于Getx的Flutter应用架构

图示: 1). 灰色背景的(前缀为M-)就是Middleware中的生命周期方法 2). 粉色背景的(前缀为W-)就是Widget自己的方法, 如build()方法.

说一些我个人的使用经验, 那就是我会依赖全局binding (后面第三大节会讲到DI中的binding, 你可以理解为Koin或Dagger中的module), 而不是页面级别的binding. 所以这些方法里的onBindingStartonPageBuildStart就比较少用.

用得较多时是在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就很明显了

基于Getx的Flutter应用架构

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)中的值的前后变化, 来决定是否要更新的.
  • 说它快, 是因为它不用代码生成. 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有啦. 它的initStatedispose方法就是生命周期的开始与结束), 那碰到这种要在页面打开时注册某一东西, 然后在页面退出时注销掉什么(以免内存泄露)时, 要怎么办?

: 不用担心, GetxController就有生命周期, 这样就能代替StatefulWidget的生命周期.

基于Getx的Flutter应用架构

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系统来方便我们的跳转