用Android的方式无痛开发Flutter项目,真的是泰裤辣!

9,584 阅读29分钟

像Android一样开发Flutter项目

前言

省流:使用约束布局【flutter_constraintlayout】百分之80还原Android布局方式。再使用 GetX 框架 快速实现仿 MVP 的架构。

本文偏新手向,老手如果熟悉这两点其实可以跳过,下面是正文部分。

为什么用Flutter?

没得选,突发事件要新开发一个公司内部项目,时间紧任务重,20天的时间从筹备到上线。如果要从加班996 和 Flutter 之间做一个选择的话,那毫无疑问我选Flutter。

人员配置如何?

iOS 与 Android 共同开发的,大致三个人左右,都不太会Flutter ,(所以才会有这一篇文章嘛),讲的就是一个如何从零开始快速开发一个应用并上线。大家都是边学边写,所以后面如果有错漏的地方也希望大家指出哦。

Flutter行不行?

因为业务并不涉及到很复杂的业务,都是业务逻辑,第三方集成的东西比较少,现在 Flutter 的插件与一些生态也比较完善。我们甚至都不要以 module 的方式集成 Flutter 模块,直接以 app 的方式全部用 Flutter 代码完成,甚至都没有写通信桥 Channel 就能完成全部项目。

毕竟 Flutter 还是一个 UI 框架,我们展示页面并且调用接口写逻辑还是很快速的,而涉及到平台化的东西也有开源的一些插件可以使用,不是一些特殊需求的话 Flutter 完全没问题。

哪些难点?

除了 Dart 语言方法比较好理解,其他都挺难的,主要是 Flutter 的控件思路不习惯,不熟练,业务代码与页面Widget混杂在一起看不惯,所以基于这两点痛点,我们使用了 Flutter 的约束布局实现页面,使用了 GetX 框架快速实现 MVP 逻辑与页面分离。

那么基于这两点下面就做一些介绍,并且记录一下实际开发中遇到的一些坑与解决方案。

一、Flutter约束布局

想加一个 margin?套一层 Container 再说,想加 padding? 套一层 Container 再说!哎,不是的 Container 比较复杂,杀鸡不用牛刀,用 Padding 这个组件就行了...

t0187970d80acce8134.jpg

地狱嵌套没完没了了,反正我刚入门我是受不了的,并且控件太多了我记不住啊。

😒😒我太菜了!

说到这里,很多刚入坑 Flutter 的新手可能或多或少的被安利过,霉事的,大家都这么嵌套,只是比较难看一点,性能其实是一样的!

那你真是太天真了,怎么可能。

8deddf1d3e9ec30c2fec744202648ad7a8ff38337e48-f28ltO_fw236.webp

在 Flutter 中,嵌套层级更多的布局往往会导致性能上的下降。这是因为每个 Widget 都需要计算其自身的位置和大小,并且嵌套越深,计算量越大,因此增加了计算时间。同时,如果涉及到多个布局嵌套,还可能会导致重复计算。

反之,嵌套层级更少的布局往往具有更好的性能。因为不需要进行多次计算,也不会重复渲染,从而提高应用的响应速度和流畅度。

所以能不能像 Android 一样搞个约束布局,减少层级嵌套?从而提升性能呢?哎,你还别说还真有大佬做出来了。【传送门】 我相信他之前肯定是一个Android仔,太相似了。

接下来我们以下设计图为例:

image.png

1.1 常用的几种约束与Android的异同

首先作为一个 Android 开发者,约束布局肯定是小ks拉,就算属性字段和用的方法有点区别,但是思想还是一样的,当我们看完文档之后就能学个7788了。这也不是很难嘛,立马用起来。

集成插件,定义布局,加入约束。哟?怎么整个页面都变红了?哪报错了?也没有错误信息啊!

一学就会,一用就废,这TMD是个坑吧!

no,no,no。如果有错误的话大概率是没有理清楚下面这几点,快对号入座。

一定要依赖一个纵向点,一个横向点, 例如:

      MyTextView(
          controller.calateSalary(),
          textColor: ColorConstants.appBlue,
          isFontBold: true,
          fontSize: 16,
        ).applyConstraint(
          id: jobSalary,
          top: jobTitle.bottom,
          left: parent.left,
        )

如果你只依赖了一个方向,那么编译报错,你可能都找不出哪里报错,报什么错。

为了方便,作者甚至提供了快速依赖点:

      MyTextView(
           controller.jobData?.title ?? "-",
           textColor: ColorConstants.black323843,
           isFontMedium: true,
           fontSize: 17,
         ).applyConstraint(
           id: jobTitle,
           topLeftTo: parent,
         )

其他的一些快速依赖点如下:

image.png

可以左右约束,可以上下约束,但是不要相互约束

      MyImageView(
          Assets.jobManagePostSuccessCouponIcon,
          height: 70,
          width: 164,
        ).applyConstraint(
          id: imgBg,
          top: parent.top,
          horizontalBias: 0.14,
          bottom: parent.bottom,
          left: parent.left,
          right: parent.right,
        )

比如这样,上下依赖左右依赖之后它本身是固定宽高的,那么它就是居中展示的,这与Android的约束布局没区别。

但是两个控件之间互相约束,一起水平居中,就会报错,因为它不支持链,所以不知道怎么一起水平居中(这是Row线性布局天然支持的)

      MyTextView(
          "20000",
          textColor: Colors.white,
          fontSize: 24,
        ).applyConstraint(
          id: one,
          top: imgBg.top,
          bottom: imgBg.bottom,
          right: imgBg.right.margin(22),
          left: two.right
        ),
        MyTextView(
          "20000 Coins",
          textColor: ColorConstants.black33,
          fontSize: 19,
          isFontBold: true,
        ).applyConstraint(
          id: two,
          top: imgBg.top,
          left: imgBg.right.margin(13),
          right: one.left
        ),

例如我们两个 TextView 互相约束了,啊,你在我左边,我在你右边,我们一起水平居中,

宽高约束中matchConstraint,matchParent,wrapContent怎么用?

matchParent,wrapContent 这可太亲切了,字面意思,和Android一样的,充满父布局,包裹内容。这都很好理解,但是 matchConstraint 是什么?哎呀。就是Android中的 0 。

在Android中我们用约束布局限制比例,不也是要确定一个方向,让另一个方向设置为0,然后生成对应的宽高吗?

这里也是一样的,只是它识别 0 这个属性,所以我们需要手动的设置 matchConstraint ,其实意思就是我不知道这个控件的具体宽高,你帮我生成!

        Container(
            height: 0.5,
            color: ColorConstants.dividerItem,
          ).applyConstraint(
            width: matchConstraint,
            left: parent.left.margin(15),
            right: parent.right.margin(15),
            top: tvYyCoins.bottom,
          ),

例如这样的布局,不是和 Android 一样的吗?如果宽度有值,左右都约束了,那么就是居中了,如果宽度为 0 ,左右都约束了,那就是充满父布局嘛。

            MyAssetImage(
                  Assets.homeMainTipBg,
                ).applyConstraint(
                  id: imgBG,
                  width: matchParent,
                  height: matchConstraint,
                  widthHeightRatio: 375 / 263,
                  top: parent.top,
                  left: parent.left,
                )

比如我要宽度充满父布局,高度和宽度的比例为375:263,那么我把高度设置为 matchConstraint (相当于Android的0),那么这样才能生效成功。

可以占位隐藏,不占位隐藏,不破坏树形结构

同样的,很多刚入坑 Flutter 的新手可能或多或少的被安利过这一点,Flutter你不需要的布局就可以不定义,使用if else 当需要的时候加进来,不要的是不加进来。这样树形图会少一层或少一点控件,是优化点。

这个嘛,对也不对,当静态布局比如下图所示的按钮组,如果对方已经接收了,那么只显示按钮接收的按钮,如果对方已经拒绝了显示拒绝的按钮,如果都没有则显示两个按钮。

image.png

对于这样的静态页面,我们是可以用 if else 来控制选择使用哪一种控件,但是如果换一种角色,如果你是用户,那么你这个页面就是动态的,你需要点击按钮来触发当前按钮组的显示状态。比如用户点击同意,那么需要刷新当前的按钮组。此时如果用不占位隐藏的控件会更好。因为视图树如果发生了结构性的变化会重构更损耗性能。

所以也推荐大家按情况来操作,而Flutter的约束布局就提供了完美的占位隐藏与不占位隐藏的功能,也保留的 Android 的隐藏 margin 功能呢。

       MyTextView(
            'YY Coins'.tr,
            textColor: ColorConstants.white,
            fontSize: 16,
            isFontMedium: true,
          ).applyConstraint(
            id: cointext,
            top: parent.top.margin(16),
            left: parent.left.margin(26),
            visibility: CLVisibility.gone,
            goneMargin: EdgeInsets.only(left: 26)
        ),

这样是不是更方便呢?避免了还要嵌套 Offstage / Visibility / Padding 等控件的嵌套。所以本质上约束布局就是减少嵌套而已。

1.2 开发中更推荐的布局方式

上图首页的页面布局,如果是用传统 Flutter 的 Widget 来做的话,Stack + Column + Row 内部再加上无数的 Padding ,如果要居中还要加 Center ,如果要装饰还需要 DecoratedBox 或 Container ...

如果整体布局用 ConstraintLayout 来做那么可以减少很多层级,整体页面是Column ,子布局上部分是 ConstraintLayout 下部分用 Row 即可。

可以说除了简单的线性布局,其他的复杂容器或 Stack 之类的布局都可以用 ConstraintLayout 来替代,同时约束布局也特别适合一些比例布局百分比布局的方式。

为什么不把线性布局也换成约束布局?

没必要,因为这个约束布局并不支持链的概念,而原生的线性布局天然支持链和权重的概念,可以很方便的设置剩余控件大小和排队对齐方式,这一点实际开发中非常重要。

而 ConstraintLayout 更重要的替代 Stack ,百分比布局,等比例布局,减少层级嵌套用的。

那么我们需要掌握的一些常用的一些控件就是几个线性布局 Column ,Row 。一些列表ListView ,Sliver 。一些基本显示单元,例如文本与图片的展示之类的。

其实这样就已经足够能开发一款应用了,一些不太常见的控件,等用到去查文档就行了。

二、MVP架构GetX

只要是了解 Flutter 的应该没有人不知道 GetX 的大名吧,No.1的存在不需要我介绍了吧。

先等等,给先自己叠个甲。

我知道 GetX 框架其实在网上是有争议的,爱它的人xxx,恨它的人xxx。

萝卜青菜各有所爱,只是一个工具而已,无所谓了。但是我们想要快速开发的话还真得靠它,确实简化了很多开发场景,对小白比较友好,主要是状态管理与依赖注入太香了,反正我们都还蛮喜欢的😅😅

基本的使用,推荐大家可以自己看文档学习,已经很完善了。

这里也并不涉及到原理和基本的一些使用,只是讲一下实际开发中需要注意的地方与坑点。

2.1 页面的生命周期管理与路由管理

我们的 StatefulWidget 中 State 有 initState(){} 与 dispose(){ } 的生命周期,太少了,好就算够用,那 StatelessWidget 呢?

所以才引出 GetX 的 GetController ,有点类似 ViewModel 的意思,它和页面同生命周期,但是却没有 ViewModel 的持久化特性,可以称为青春版ViewModel。

Controller中常用的生命周期,页面创建,页面准备,页面销毁。

  @override
  void onInit() {
    super.onInit();
  }

  @override
  void onReady() {
    super.onReady();
  }

  @override
  void onClose() {
    super.onClose();
  }
 

那么当页面关闭的时候真的能正常销毁 Controller 吗?真的只能正常调用 onClose() 吗?

这... 还用想吗?当然是可以的啦。这不废话吗?但是我们需要注意的是,如果我们使用 GetX 框架中路由定义使用到 binding 的时候:

GetPage(
      name: RouterPath.AUTH_LOGIN,
      page: () => LoginPage(),
      binding: LoginBinding(),
    ),

class LoginBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => LoginController());
  }
}

但是 LoginPage 页面又不是继承与 GetView 或者内部并没有使用 Get.put(),或 Get.find() 或内部视图使用 GetBuilder 等初始化方法,那么 Controller 是不会初始化也不会走 onclose() 方法的哦。

所以最方便的就是搭配 GetView 使用,或者不使用 binding 的方式懒加载生成 Controller ,而是自己手动写 Get.put()。

就算我都按要求做了,生命周期就能自动调用了吗?能走到 onclose 方法了吗? No,No,还是有坑,当我们跨页面销毁页面的时候,例如Home->ConcatUs->Feedback。

当我们Feedback点击关闭的按钮,直接回到Home页面。使用Get路由跳转如下:

 Get.offNamedUntil(RouterPath.HOME, (route) => true);

没毛病,确实回到Home页面了,但是中间的ConcatUs页面的Controller能销毁吗?能走到onClose方法吗?我试过了,不能!大家有兴趣试试。

如果我一直不能销毁万一我再其中有资源的开销无法销毁不是一直占用内存吗?那怎么办?

使用原生的导航关闭

   Navigator.popUntil(context, ModalRoute.withName(RouterPath.HOME));

这样原生导航关闭多个页面,GetX框架能知道吗?能做出处理吗?

能的,只是需要做一下小小的监听:

main函数定义:

      child: GetMaterialApp(
            //顶部是否展示Debug图标
            debugShowCheckedModeBanner: true,
            //是否展示Log
            enableLog: true,
            //对原生导航的兼容
            navigatorObservers: [
                    GetXRouterObserver(),
                    FlutterSmartDialog.observer
                  ],

这样就能感知路由的关闭了。

/// 手动让getx感知原生路由
class GetXRouterObserver extends NavigatorObserver {
  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    RouterReportManager.reportCurrentRoute(route);
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) async {
    RouterReportManager.reportRouteDispose(route);
  }
}

这样中间的页面才能正常的走生命周期了。当然只有关闭多个页面的时候才会遇到这样的问题,也不知道是不是我使用的方式不对,如果有更好的方式希望指点。

今天又试了一次,使用GetX的方式关闭多个页面,不会触发onClose,也不会回收Controller:

IHC@IO7{TY0K0`GTL%WKBBK.png

同样的代码用原生路由关闭多个页面就会触发:

IHC@IO7{TY0K0`GTL%WKBBK.png

2.2 为什么不用MVP不用MVVM?

现在Android开发的主流都是 MVVM 了,你咋用 MVP 这种老方式?

因为 GetX 的 Controller 并不是 Android 中类似 ViewModel 的存在,它并不是与页面绑定的,也并不会保存相关的实例,是的,你想到了什么?

无法保存实例啊,当页面关闭之后重开,那么状态丢失只能重新走生命周期,重建页面啊。网上给出的解决方案是把数据保存到 GetStorage 或 SP 中...太傻了。

例子:

class MyStatefulPage extends StatefulWidget {
  @override
  _MyStatefulPageState createState() => _MyStatefulPageState();
}

class _MyStatefulPageState extends State<MyStatefulPage> {
  String _myText = '';

  @override
  void didChangeDependencies() {
    // 在此处进行初始化操作
    super.didChangeDependencies();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('My Stateful Page')),
      body: Center(child: Text(_myText)),
    );
  }

  @override
  void reassemble() {
    // 在开发阶段进行快速重建UI
    super.reassemble();
  }

  @override
  void didUpdateWidget(MyStatefulPage oldWidget) {
    // 在组件更新时保存需要持久化的状态变量
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    // 在组件被销毁时释放资源和取消订阅等操作
    super.dispose();
  }
}

需要自己实现保存 _myText 字段到本地存储或其他持久化中,这...

不知道有没有类似 onSaveInstanceState 和 onRestoreInstanceState ,或者一步到位能达到ViewModel效果的方式,也希望大佬指点。

2.3 同一个页面的多开导致的无法回收问题

还有另一个坑,如果重复进同一个页面怎么办?页面对应的 Controller 到底会不会回收?怎么保证 Controller 与多开的同一个页面一一对应,并且保证对应的一一回收?

不会回收,默认也不会开多个示例,需要我们手动的Get.Put各自的 Controller ,并且自带各自的Tag,回收的时候也自行销毁自己的 Controller,属实也是全手动了,不方便。

class FeedbackPage extends StatefulWidget {
  @override
  _FeedbackPageState createState() => _FeedbackPageState();
}

class _FeedbackPageState extends State<FeedbackPage> {
  final FeedbackController controller = Get.put(FeedbackController());

  @override
    Widget build(BuildContext context) {
      return Container();
    }

  @override
  void dispose() {
    Get.delete<FeedbackController>();
    super.dispose();
  }
}

是的,如果 Controller 不能手动销毁,我们在 StatefulWidget 中手动销毁不就行了吗?但是我们使用 GetX 框架不就是想尽量写 StatelessWidget 嘛,够简单。

所以我们就需要封装为一个 Widget 来使用

import 'package:flutter/material.dart';
import 'package:get/get.dart';

class GetBindWidget extends StatefulWidget {
  const GetBindWidget({
    Key? key,
    this.bind,
    this.tag,
    this.binds,
    this.tags,
    required this.child,
  })  : assert(
          binds == null || tags == null || binds.length == tags.length,
          'The binds and tags arrays length should be equal\n'
          'and the elements in the two arrays correspond one-to-one',
        ),
        super(key: key);

  final GetxController? bind;
  final String? tag;

  final List<GetxController>? binds;
  final List<String>? tags;

  final Widget child;

  @override
  _GetBindWidgetState createState() => _GetBindWidgetState();
}

class _GetBindWidgetState extends State<GetBindWidget> {
  @override
  Widget build(BuildContext context) {
    return widget.child;
  }

  @override
  void dispose() {
    _closeGetXController();
    _closeGetXControllers();

    super.dispose();
  }

  void _closeGetXController() {
    if (widget.bind == null) {
      return;
    }

    var key = widget.bind.runtimeType.toString() + (widget.tag ?? '');
    GetInstance().delete(key: key);
  }

  void _closeGetXControllers() {
    if (widget.binds == null) {
      return;
    }

    for (var i = 0; i < widget.binds!.length; i++) {
      var type = widget.binds![i].runtimeType.toString();

      if (widget.tags == null) {
        GetInstance().delete(key: type);
      } else {
        var key = type + (widget.tags?[i] ?? '');
        GetInstance().delete(key: key);
      }
    }
  }
}

使用的时候就可以指定bind的Controller对象,或者绑定的tags去销毁:

/// 回收多个
class TestPage extends StatelessWidget {
  final oneController = Get.put(OneController(), tag: 'one');
  final twoController = Get.put(TwoController());
  final threeController = Get.put(ThreeController(), tag: 'three');

  @override
  Widget build(BuildContext context) {
    return GetBindWidget(
      binds: [oneController, twoController, threeController],
      tags: ['one', '', 'three'],
      child: Container(),
    );
  }
}

跳转的时候我们直接跳转,允许多个页面实例:

Get.toNamed('xxx',  preventDuplicates: false);

2.4 响应式布局刷新的方式

GetX 的响应式刷新有两种方式,一种是Obx是配合Rx响应式变量使用,另一种是 GetBuilder。

前者Obx自动刷新,而后者需要使用 update 手动调用刷新。

哪种更好?

GetBuilder 加上 update 方法半自动的方式更好,GetBuilder内部实际上是对 StatefulWidget 的封装,所以占用资源极小,而Obx的话随着变量越来越多,会生成生成大量的 GetStream ,对内存不友好。

那一个页面需要多个GetBuilder吗?

这个看情况,可以根视图包裹,也可以需要更新的控件包裹,如果您的应用程序中有多个 GetBuilder 与同一 Controller 相关联,则调用 update 方法将更新所有这些 GetBuilder 。这是因为每个 GetBuilder 都会在其所属的子树中保持引用到相同的 Controller 实例,因此当您调用该控制器上的 update 方法时,它将通知所有订阅者并更新整个子树。

如果不想全部刷新,我们可以给包裹的 GetBuilder 打上 tag 属性,我们也能指定刷新某一个 GetBuilder 包裹的子树。

GetBuilder(
  tag: 'myTag',
  init: controller,
  builder: (_) => Text(controller.myValue),
)


controller.update(['myTag']);
   

通常来说GetBuilder的遍历开销通常不是一个问题,性能影响不大,所以全局使用一个还是多个 GetBuilder 区别不是很大。

2.5 保持单例的几种方式,为什么会失效?

Dart的单例定义与GetX的依赖注入单例

默认的单例写法

class EventBus {
  //私有构造函数  //注意单例怎么写,统一的三板斧:私有构造+static变量+工厂构造函数
  EventBus._internal();
  //保存单例
  static final EventBus _singleton = EventBus._internal();
  //工厂构造函数
  factory EventBus()=> _singleton;
   
}

//定义一个top-level(全局)变量,页面引入该文件后可以直接使用bus
var bus = EventBus();

另一种Dart的单例写法,也可以不用顶层函数,直接用静态的getInstance获取实例:

class StorageService extends GetxService {
      //保存单例
  static StorageService _instance = StorageService._();
      //工厂构造函数
  factory StorageService() => _instance;

  // //私有构造函数
  StorageService._() {
    init();
  }

   // 使用静态的方法获取实例
  static StorageService getInstance() {
    _instance = StorageService._();
    return _instance;
  }

GetX的依赖注入单例

默认的GetX对页面的Controller的注入是以懒加载的方式注入的。

class ChargeRecordsBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => ChargeRecordsController());
  }
}

为什么?因为当页面关闭的时候就会自动回收,如果想保持单例怎么做?

class FeedbackPage extends StatelessWidget {
  FeedbackPage({Key? key}) : super(key: key);

  final controller = Get.put(FeedbackController(),permanent: true);
}

手动的 put 对象并且设置持久化,就是单例,如何释放呢?由于Controller是比较特殊的存在,一般都是跟随页面的生命周期,所以也有了上面提到的手动指定释放Controller的解决方案。

那么其他的对象单例管理呢?比如 ApiRepositoty 这样的网络请求数据仓库?

class AppBinding extends Bindings {
  @override
  void dependencies() async {

    Get.put<ApiProvider>(ApiProvider(), permanent: true);

     Get.put(ApiHomeRepository(apiProvider: Get.find()), permanent: true);
  
  }
}

这样行不行?全局保持单例嘛,这样肯定行!但是我不想应用初始化的时候全部创建,毕竟只有在进入到这个模块才会调用这个模块的数据仓库。能懒加载是最好的?

class AppBinding extends Bindings {
  @override
  void dependencies() async {
    Get.put<ApiProvider>(ApiProvider(), permanent: true);
    Get.lazyPut<ApiHomeRepository>(() => ApiHomeRepository(apiProvider: Get.find<ApiProvider>()));
    Get.lazyPut<ApiCommonRepository>(() => ApiCommonRepository(apiProvider: Get.find<ApiProvider>()));
    Get.lazyPut<ApiMessageRepository>(() => ApiMessageRepository(apiProvider: Get.find<ApiProvider>()));
    Get.lazyPut<ApiJobRepository>(() => ApiJobRepository(apiProvider: Get.find<ApiProvider>()));
    Get.lazyPut<ApiCompanyRepository>(() => ApiCompanyRepository(apiProvider: Get.find<ApiProvider>()));
    ...

  }
}

那我们改成这样?合适吗?全部用懒加载的方式创建?不太行!

只有在第一次的时候生效,当我们在 Controller 中使用网络请求数据仓库的时候就只能用一次,再使用就会失效,为什么?

本身 使用Get.lazyPut注册一个对象时,这个对象只有在首次被使用时才会创建,并且之后会一直存在于内存中,直到应用程序关闭或该对象被手动删除。

但是 Controller 又有一点特殊,如果 Controller 被回收,它所依赖的所有lazy load 对象也将被回收,因为它们不再被引用。这通常发生在 Controller 所在的页面被关闭的时候。

那怎么办?

这里又引出了 Getx 依赖注入的作用域的概念:

  1. GetxController: 这个类是你自己定义的控制器类,用于在应用中管理某个页面或部件的状态。这个类可以通过依赖注入来创建和管理,以便在整个控制器的生命周期中重复使用。

  2. GetxView: 这个类是一个特殊的控制器类,它结合了 GetxController 和视图的概念。在大多数情况下,你可以使用 GetxView 来代替 GetxController ,因为它提供了更好的抽象和封装。

  3. GetxService: 这是一个全局单例服务,可以在整个应用程序中使用。只要应用程序运行,GetxService 就会存在,并且可以通过依赖注入来访问。

  4. GetxBindings: 这是一个用于绑定依赖项的类。你可以使用 GetxBindings 将依赖项绑定到控制器或视图中,以便在它们被创建时自动初始化。

  5. GetxInstance: 它是一个基本的依赖注入容器,用于管理和创建其他类的实例。你可以使用 GetxInstance 来手动创建和管理实例,但通常情况下不需要这样做,因为GetX提供了更高级的依赖注入功能。

我们想要保持全局的单例,我们只需要让我们的数据仓库继承自 GetxService 即可

class ApiHomeRepository extends GetxService {
  ApiProvider apiProvider;

  ...
}

这样就能实现懒加载的全局单例了。

2.6 GetX内置网络请求封装

说起 Flutter 的网络请求,除了原生的 Http 之外,大家最耳熟能详的就是 dio 了, dio 当然好用了,但是 GetX 内置的 GetConnect 也是蛮好用的,平常的网络请求与拦截之类操作都能满足需求了。

像 Android 封装协程一样封装 GetConnect 请求过程

思路也是和协程类似,底层是基类的网络请求封装,中间是数据仓库(Repository),然后在Controller(ViewModel)中使用数据仓库来发起请求。

同样的我们可以自定义一个返回对象封装自定义的错误与成功信息,并记录是否成功与失败,并且做好数据的转换流程。

与Android封装不同的是,Android可以直接通过泛型指定对象,然后直接把Json转换为对象,而 Dart 不支持我们这么做,我们需要在数据仓库(Repository)中手动的调用对象的 formJson 之类的序列化方法才能转为指定的 Entity 对象。

  1. 先封装好网络请求的结果对象
class HttpResult<T> {
  HttpResult(
      {required this.isSuccess,
      dynamic dataJson,
      List<dynamic>? listJson,
      this.errorCode,
      this.errorMsg}) {
    this._dataJson = dataJson;
    this._listJson = listJson;
  }

  //是否成功
  bool isSuccess = false;

  //成功的数据(Json数据)
  dynamic _dataJson;
  List<dynamic>? _listJson;

  //成功的数据(真正的数据)
  T? data;
  List<T>? list;

  //失败的数据
  int? errorCode;
  String? errorMsg;

  /// 以Json对象的方式获取
  Map<String, dynamic>? getDataJson() {
    if (_dataJson is Map<String, dynamic>) {
      return _dataJson as Map<String, dynamic>;
    }
    return null;
  }

  /// 以原始对象的方式获取,可以获取到String,Int,bool等基本类型
  dynamic getDataDynamic() {
    return _dataJson;
  }

  /// 以数组的方式获取
  List<dynamic>? getListJson() {
    return _listJson;
  }

  /// 设置真正的数据对象
  void setData(T data) {
    this.data = data;
  }

  void setList(List<T> list) {
    this.list = list;
  }

  /// 基本类型转换为指定的泛型类型
  HttpResult<T> convert<T>({T? data, List<T>? list}) {
    var result = HttpResult<T>(
        isSuccess: this.isSuccess,
        dataJson: this._dataJson,
        listJson: this._listJson,
        errorCode: this.errorCode,
        errorMsg: this.errorMsg);

    result.data = data;
    result.list = list;

    return result;
  }
}
  1. 基层的万能网络请求封装,以常用的 Post Get 请求为例
typedef NetSuccessCallback<T> = Function(T data);
typedef NetSuccessListCallback<T> = Function(T data);
typedef NetErrorCallback = Function(int? code, String? msg);

enum HttpMethod { GET, POST }

/// 网络请求相关封装
class ApiProvider extends GetConnect {
  /// 默认简单的请求封装,回调的方式
  Future<void> requestNetEasy(
    String url, {
    HttpMethod? method,
    Map<String, String>? headers,
    Map<String, dynamic>? query,
    Map<String, String>? paths,
    NetSuccessCallback<Map<String, dynamic>>? onSuccess,
    NetSuccessListCallback<List<dynamic>>? onSuccessList,
    NetErrorCallback? onError,
  }) async {
    //根据参数封装请求
    var req = generateRequest(method, query, paths, null, url, headers);

    //开始请求
    final startTime = DateTime.now().millisecond;
    var result = await req;
    final endTime = DateTime.now().millisecond;

    if (!AppConstant.inProduction) {
      final duration = endTime - startTime;
      Log.d('网络请求耗时 $duration 毫秒, 响应内容 ${result.body}}');
    }

    if (result.statusCode == 200) {
      //网络请求正确之后获取正常的Json-Map
      Map<String, dynamic> jsonMap = result.body;

      //查看apiCode是否正确
      int code = jsonMap['code'];
      if (code == 200) {
        if (jsonMap['data'] is List<dynamic>) {
          //返回数组
          List<dynamic> list = jsonMap['data'];
          if (onSuccessList != null) {
            onSuccessList(list);
          }
        } else {
          //返回对象
          if (onSuccess != null) {
            onSuccess(jsonMap['data']);
          }
        }
      } else {
        //Api错误
        if (onError != null) {
          onError(jsonMap['code'], jsonMap['message']);
        }
      }
    } else {
      //网络请求错误
      if (onError != null) {
        // result.bodyString   错误信息,这里没必要打印,拦截器中有打印的
        onError(result.statusCode, result.statusText);
      }
      //吐司网络请求错误
      SmartDialog.compatible.showToast("Request Network Error :${result.statusCode}  ${result.statusText}");
    }
  }

  /// 网络请求异步的Result封装
  Future<HttpResult> requestNetResult(
    String url, {
    HttpMethod? method,
    Map<String, String>? headers,
    Map<String, dynamic>? query,
    Map<String, String>? paths, //文件Flie
    Map<String, Uint8List>? pathStreams, //文件流
  }) async {
    //根据参数封装请求
    var req = generateRequest(method, query, paths, pathStreams, url, headers);

    //开始请求
    Response result;
    if (!AppConstant.inProduction) {
      final startTime = DateTime.now().millisecond;
      result = await req;
      final endTime = DateTime.now().millisecond;
      final duration = endTime - startTime;
      Log.d('网络请求耗时 $duration 毫秒, 响应内容 ${result.body}}');
    } else {
      result = await req;
    }

    if (result.statusCode == 200) {
      //网络请求正确之后获取正常的Json-Map
      Map<String, dynamic> jsonMap = result.body;

      //判断成功与失败
      int code = jsonMap['code'];
      if (code == 200) {
        if (jsonMap['data'] is List<dynamic>) {
          //成功->返回数组
          return HttpResult(
            isSuccess: true,
            listJson: jsonMap['data'],
          );
        } else {
          //成功->返回对象
          return HttpResult(isSuccess: true, dataJson: jsonMap['data']);
        }
      } else {
        //失败->返回Api错误
        return HttpResult(isSuccess: false, errorCode: jsonMap['code'], errorMsg: jsonMap['message']);
      }
    } else {
      //失败->返回网络请求错误
      return HttpResult(isSuccess: false, errorCode: result.statusCode, errorMsg: result.statusText);
    }
  }

  ///生成请求体
  Future<Response> generateRequest(
      HttpMethod? method,
      Map<String, dynamic>? query,
      Map<String, String>? paths, //文件
      Map<String, Uint8List>? pathStreams, //文件流
      String url,
      Map<String, String>? headers) async {
    Future<Response> req;

    if (method != null && method == HttpMethod.POST) {
      var map = <String, dynamic>{};

      if (query != null || paths != null || pathStreams != null) {
        //只要有一个不为空,就可以封装参数

        //默认的参数
        if (query != null) {
          map.addAll(query);
        }

        //Flie文件
        if (paths != null) {
          paths.forEach((key, value) {
            if (value != null && value.isNotEmpty) {
              final file = File(value);
              map[key] = MultipartFile(
                file.readAsBytesSync(),
                filename: "file",
              );
            }
          });
        }

        //File文件流
        if (pathStreams != null) {
          pathStreams.forEach((key, value) {
            if (value != null && value.isNotEmpty) {
              map[key] = MultipartFile(
                value,
                filename: "file_stream",
              );
            }
          });
        }
      }

      var form = FormData(map);

      Log.d("Post请求FromData参数,fields:${form.fields.toString()} files:${form.files.toString()}");
      //以Post-Body的方式上传
      req = post(url, form, headers: headers);
    } else {
      //默认以Get-Params的方式上传
      req = get(url, headers: headers, query: query);
    }

    return req;
  }

  @override
  void onInit() {
    httpClient.baseUrl = ApiConstants.baseUrl;
    httpClient.timeout = const Duration(seconds: 30);

    /// 统一添加身份验证请求头
    httpClient.addRequestModifier(authInterceptor);

    /// 打印Log(生产模式去除)
    if (!AppConstant.inProduction) {
      httpClient.addRequestModifier(logReqInterceptor);
    }

    httpClient.addResponseModifier(responseInterceptor);

    /// 打印Log(生产模式去除)
     if (!AppConstant.inProduction) {
       httpClient.addResponseModifier(logResInterceptor);
     }
  }

  /// 取消网络请求并重新设置
  ApiProvider cancelAndResetHttpClient() {
    httpClient.close();
    Get.replace(ApiProvider());
    return Get.find();
  }
}

我们定义两种不同的使用方式,使用类似OkHttp那样的回调方式,或者协程那样的异步都支持。

  1. 对应模块的数据仓库的创建

底层的万能基类封装好了之后,我们就能写对应模块的数据仓库了。

class ApiRepository extends GetxService{
  ApiProvider apiProvider;

  ApiRepository({required this.apiProvider});

  //获取服务器时间
  Future<HttpResult<ServerTime?>> getServerTime() async {
    Map<String, String> headers = {};
    headers["Accept"] = "application/x.yyjobs-api.v1+json";

    //网络请求获取原始数据
    final result = await apiProvider.requestNetResult(ApiConstants.apiServiceTime, headers: headers);

    //根据返回的结果,封装原始数据为Bean/Entity对象
    if (result.isSuccess) {
      final json = result.getDataJson();
      var data = ServerTime.fromJson(json!);
      //重新赋值data或list
      return result.convert<ServerTime?>(data: data);
    }
    return result.convert<ServerTime?>();
  }

  // 获取行业的List
  Future<HttpResult<IndustryData?>> getIndustryList() async {
    Map<String, String> headers = {};
    headers["Accept"] = "application/x.yyjobs-api.v1+json";

    final result = await apiProvider
        .requestNetResult(ApiConstants.apiIndustryList, headers: headers);

    if (result.isSuccess) {
      final jsonList = result.getListJson();

      //获取List数据 需要转换一次
      var list = jsonList?.map((value) {
        if (value is Map<String, dynamic>) {
          return IndustryData.fromJson(value);
        } else {
          return null;
        }
      }).toList();

      return result.convert<IndustryData?>(list: list);
    }

    return result.convert<IndustryData>();
  }
  
  ...


  //用户登陆(回调的方式使用)
  void userLoginEasy(
      {NetSuccessCallback<UserLogin>? success, NetErrorCallback? onError}) {
    Map<String, String> headers = {};
    headers["Accept"] = "application/x.yyjobs-api.v1+json";

    Map<String, String> params = {};
    params["nric_no"] = "+861123456789";
    params["password"] = "12345678";
    params["registration_id"] = "123456789";

    apiProvider.requestNetEasy(ApiConstants.apiUserLogin,
        method: HttpMethod.POST,
        headers: headers,
        query: params, onSuccess: (json) {
      var userLogin = UserLogin.fromJson(json);
      if (success != null) {
        success(userLogin);
      }
    }, onError: onError);
  }
}

数据仓库中的代码演示了回调的方式调用,以及异步方式的调用。

而数据仓库中重要的功能就是调用基类请求,封装参数,获取结果,判断是否成功,json转换为对象或集合,赋值转换对应真正的泛型对象便于上层使用。对错误信息只进行封装赋值不进行处理,因为我们不知道具体的业务逻辑,上层拿到错误信息需要怎么处理,是弹出吐司,弹窗?还是展示错误的Widget?

  1. 最终对应页面的上层调用

最后就到了我们页面对应的 Controller 上层调用了,举个栗子:

class DemoController extends GetxController with StateMixin<dynamic> {
  DemoController({required this.apiRepository});

  //先登录再获取信息
  Future<void> getUserInfo() async {
    change(null, status: RxStatus.loading());

    //用户登陆
    final result = await apiRepository.userLogin();

    if (result.isSuccess) {
      final token = result.data?.token;

      if (token != null) {
        // 获取信息
        final profile = await apiRepository.getUserProfile(token);

        if (profile.isSuccess == true) {
          final nickName = profile.data?.nickName;

          SmartDialog.compatible.showToast("当前登录的用户为:$nickName");

          change(null, status: RxStatus.success());
        }
      }
    } else {
      final errorMsg = result.errorMsg;
      change(null, status: RxStatus.error(errorMsg));
    }

}

我们也是更推荐使用异步的方式来进行,更优雅不说,也方便支持 Future 的并发。

  Future<void> complicatedFetch() async {

    // 等待所有的Future对象都完成
    List<dynamic> results = await Future.wait([apiRepository.getServerTime2(), apiRepository.getIndustryList2()]);

    int? timestamps;
    List<IndustryData?>? industries;

    for (var future in results) {
      if (future is HttpResult<ServerTime?>) {
        final serverTime = future;
        timestamps = serverTime.data?.timestamps;
      } else if (future is HttpResult<IndustryData?>) {
        final industryList = future;
        industries = industryList.list;
      }
    }

    SmartDialog.showToast("并发完成的数据 行业数量:${industries?.length} 当前时间:$timestamps");
  }

总的来说 GetX 的网络请求并没有什么大的槽点,用起来还算不错。

2.7 国际化与主题切换

其实 GetX 自身对这一块支持的还蛮好的。

比如定义国际化文档

const Map<String, String> zh_CN = {
  'loading': '加载中...',
  'load_error_try_again': '加载失败,请点击重试',
  'load_no_data': '暂无数据',
  'Click Again Exit App': '再次点击退出应用',
  'Select Video': '选择视频',
  'Select Image': '选择图片',
  'Camera': '相机',
  'Album': '相册',
  'Cancel': '取消',
  'Pull to refresh': '下拉刷新',
  'Release ready': '释放刷新',
  'Refreshing...': '刷新中...',
  'Succeeded': '成功',
  'No more': '没有更多数据',
  'Failed': '失败',
  'Last updated at %T': '最近更新于 %T',
  'Pull to load': '上拉加载更多',
  'Loading...': '加载中...',

  'Post Job': '发布工作',
  'Message Conversation': '消息对话',
  'Pending Interview': '待面试',

};

定义国际化服务类

class TranslationService extends Translations {
  static Locale? get locale => Get.deviceLocale;
  static const fallbackLocale = Locale('en', 'US');

  @override
  Map<String, Map<String, String>> get keys => {
        'en_US': en_US,
        'zh_CN': zh_CN,
      };
}

main函数定义

      child: GetMaterialApp(
            //顶部是否展示Debug图标
            debugShowCheckedModeBanner: true,
            //是否展示Log
            enableLog: true,

            //本地化相关
            locale: TranslationService.locale,
            fallbackLocale: TranslationService.fallbackLocale,
            translations: TranslationService(),

使用的时候:

image.png

直接在字符串后面加.tr 。 如果能找到对应的国际化文本就会执行逻辑,找不到就还是显示当前的字符串,很有iOS的感觉,我感觉这一点比 Android 原生的国际化要方便一些。

切换主题

主题的方式与国际化类似,定义自己的主题样式对应的ThemeData对象

  static ThemeData get lightTheme => createTheme(
        brightness: Brightness.light,
        background: ColorConstants.lightScaffoldBackgroundColor,
        cardBackground: ColorConstants.secondaryAppColor,
        primaryText: Colors.black,
        secondaryText: Colors.black,
        accentColor: ColorConstants.secondaryAppColor,
        divider: ColorConstants.secondaryAppColor,
        buttonBackground: Colors.black38,
        buttonText: ColorConstants.secondaryAppColor,
        disabled: ColorConstants.secondaryAppColor,
        error: Colors.red,
      );

  static ThemeData get darkTheme => createTheme(
        brightness: Brightness.dark,
        background: ColorConstants.darkScaffoldBackgroundColor,
        cardBackground: ColorConstants.secondaryDarkAppColor,
        primaryText: Colors.white,
        secondaryText: Colors.white,
        accentColor: ColorConstants.secondaryDarkAppColor,
        divider: Colors.black45,
        buttonBackground: Colors.white,
        buttonText: ColorConstants.secondaryDarkAppColor,
        disabled: ColorConstants.secondaryDarkAppColor,
        error: Colors.red,
      );

main函数定义

      child: GetMaterialApp(
            //顶部是否展示Debug图标
            debugShowCheckedModeBanner: true,
            //是否展示Log
            enableLog: true,
            //默认路由与路由表的加载
            //样式相关
            theme: ThemeConfig.lightTheme,
            darkTheme: ThemeConfig.darkTheme,
            themeMode: ThemeMode.system,

当我们手动的切换模式,比如黑暗模式与亮色模式的时候,就会自动加载对应的ThemeData样式了,

Get.changeTheme(Get.isDarkMode ? ThemeConfig.lightTheme : ThemeConfig.darkTheme);

也不需要像 Android 原生那么复杂,去遍历视图树啊或者需要重启应用啊什么的。相对而言也是比原生 Android 方便一点。

如果当我们想修改为指定的颜色的时候,比如暗黑模式下我不想为黑色,我想变为红色,我们可以通过工具类复写这效果:

//设置颜色兼容黑色模式
class DarkThemeUtil {
  /// 默认黑暗模式下的颜色为[ColorConstants.darkScaffoldBackgroundColor].
  /// 如果想自定义黑暗模式下的颜色
  static Color multiColors(Color lightColor, {Color? darkColor}) {
    Color color;
    if (Get.isDarkMode) {
      color = darkColor ?? ThemeConfig.darkTheme.cardTheme.color ?? ColorConstants.darkScaffoldBackgroundColor;
    } else {
      color = lightColor;
    }
    return color;
  }

  /// 默认黑暗模式下的颜色不变.
  /// 如果想自定义黑暗模式下的图标颜色填充颜色就行
  static Widget multiImageColorFit(String imgPath, double width, double height, {Color? darkColor, BoxFit? fit}) {
    return MyAssetImage(imgPath, width: width, height: height, color: Get.isDarkMode ? darkColor : null, fit: fit);
  }

  /// 默认黑暗模式下的图片资源不变
  /// 如果想自定义黑暗模式下的图片资源,可以直接替换图片
  static Widget multiImagePath(String imgPath, double width, double height, {String? darkImagePath}) {
    return MyAssetImage(Get.isDarkMode && darkImagePath != null ? darkImagePath : imgPath,
        width: width, height: height);
  }
}

比如修改黑暗模式下图标的颜色:

DarkThemeUtil.multiImageColor("assets/images/splash_center_blue_logo.webp", 144, 112,darkColor: Colors.white),

或者改变Button黑暗模式下的颜色:

        backgroundColor: MaterialStateProperty.resolveWith((states) {
            if (states.contains(MaterialState.disabled)) {
              return DarkThemeUtil.multiColors(disabledBackgroundColor ?? Colors.white,darkColor: Colors.lightBlue);
            }
            return DarkThemeUtil.multiColors(backgroundColor ?? Colors.white,darkColor: ColorConstants.appBlue);
          }),

这种方式的生效级别更低,只在当前设置的控件生效,方便更精准的控制,而全局的ThemeData方便大范围的控制,都有各自的用法。

后记

其实本文并不算是什么教程之类的文章。我本人也是新手来着谈不上教学,我只是记录项目开发到上线过程中遇到的一些问题,与自己得到一些解决方案,可能并不是最优解,甚至是有些错误的用法,本着和jym一起学习分享的心态发布出来,也是希望能得到一些指点,也能碰撞出一些更好的思路与灵感。😀😀

可能存在的一些问题:

确实很Android,那iOS同学习惯吗?

说实话还挺习惯的,甚至比 Android 同学更开心,wrapContent可是把iOS同学馋哭了,太好用了,约束布局也太方便了,减少嵌套也太舒服了。开发效率比原生 iOS 高出不少。

如果不想用约束布局有没有其他方式减少嵌套?

如果一个布局中嵌套太多确实是不好看,自己写的页面过两天自己都不认识了,如果不想用约束布局,也可以使用一些扩展方法的方式进行装饰与布局,或者把布局进来的抽取出来成方法,抽取出来成类。也会减少视觉上的嵌套,是的,只是视觉上的,本身还是嵌套的那么多,甚至用一些扩展方法公共类方法包装的话,反而嵌套层数更多了,性能更差。

Getx停止维护了怎么办?

本身框架的更新速度就放缓了,其实也没所谓,现在的功能已经够用了,没有什么明显的漏洞,也不需要加一些其他的功能导致臃肿,甚至我都觉得像网络请求等其他的非核心功能都不需要加进来,什么都想要最后搞得像之前的 XUtils 一样最后还不是那啥了。

后续的项目还用Flutter吗?

如果有新项目大概率还是要上Flutter,因为我们是比较新手嘛,第一次做项目踩了不少坑,速度其实和原生差不多,现在熟练了之后后期如果再做新项目会更加快一点。

有Demo的示例代码吗?

Demo还没来得及做,简单给大家哼哼几句...😅😅

哎不是,我们这项目刚上线,确实还没整理出来,再说了一些知识点本身也都是一些比较零碎的不成体系,东一块西一块的不好整理,后期可能出一个类似脚手架的东西开源出来。

后期可能会分享一些 Flutter 的其他文字,开发的小技巧,第三方库集成的等相关内容,两三篇的样子,Flutter 的内容不准备做太多,毕竟 Android 才是主业,Flutter 只是加分项(主要太菜)

好了,本期内容就到这里,如讲的不到位或错漏的地方,希望同学们可以指出交流。

如果感觉本文对你有一点点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦。

Ok,这一期就此完结。

最近懒癌发作,文章也是拖了一个多星期才开始写,忙着看浪姐与拉票了。

话说回来,大家没有给美依礼芽投票的赶紧投起来啊,虽然第一名了,但是咱们体现的就是一个遥遥领先,鹤立鸡群😅😅,不说了,今天的票还没投,溜了溜了。