前言

架构

这儿讲的架构并不是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列表时, 功能超级差.

题外话: 由于这个setState的原因, 相对于React, 我其实更喜爱Solid JS, 这个SolidJS会知道你要改写哪个组件, 而去准确改写某一组件, 而不是改写整个页面, 但效率天然更快了

基于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体系来便利咱们的跳转