Flutter的工具与开发小技巧

我正在参加「启航方案」

前言

之前的文章【像Android相同开发Flutter项目】 一文中,也介绍了一些开发技巧。

可是还不行,关于工具的运用,插件的推荐,以及语法的技巧,也遇到过一些坑。

下面咱们就能够带着一些疑问来看:

资源怎样办理,Json怎样转目标,怎样主动生成,怎样便利的办理,怎样快速的更新?

假如一个页面结构过于杂乱,进入页面仍是会卡顿,那咱们该怎样优化?

常用的一些显现控件有没有必要封装?

扩展函数与高阶函数怎样运用?和 Kotlin 的用法有差异吗?

异步与并发有差异吗?Flutter中的异步与并发和 Android 中的用法有差异吗?

音讯总线能不能用?怎样界说?

图片挑选?图片裁剪?视频录制?权限处理?文本途径办理?多媒体?图片加载?软键盘兼容?… 都有哪些好用的插件,怎样选?

话不多说,Let’s go

用Android开发的方式开发Flutter - 那些开发小技巧

一、资源的办理插件

比方咱们写一个图片,指定为本地资源。

const MyAssetImage('other/page_no_data.webp', width: 180, height: 180, fit: BoxFit.contain)

那么咱们依照 Android 中 ctrl+左键的用法?不能跳转?这太不智能了。

既无法预览也无法办理,也看不到图片尺寸,以及对应的资源。

怎样处理?

AS插件市场中下载FlutterAssetsGenerator插件:

用Android开发的方式开发Flutter - 那些开发小技巧

下载结束之后,一键配置途径:

用Android开发的方式开发Flutter - 那些开发小技巧

顶部的 Build 栏中一键生成配置文件:

用Android开发的方式开发Flutter - 那些开发小技巧

然后默许生成的配置文件叫assets,你乃至能够改名叫 R 文件 … 这也太明火执仗了,咋们用默许的就好。

用Android开发的方式开发Flutter - 那些开发小技巧

这不就能预览图片了吗?

用Android开发的方式开发Flutter - 那些开发小技巧

哪里用到这个图片,ctr+左键 不就能都看到了吗?

用法:

 const MyAssetImage(Assets.homeMainEmployerIcon, width: 16, height: 14),

实在太便利了。

二、Getx页面与控制器生成插件

开发的过程中,每一个页面都需求创立 GetX 对应的页面与 Controller ,而且不同的开发人员创立的页面称号还不相同,有的用下划线有的用驼峰,前后缀也不一致,影响了开发体验啊。

这不就轮到小呆呆大佬开发的插件登场了吗?

用Android开发的方式开发Flutter - 那些开发小技巧

例如咱们内部开发者就束缚好了前后缀与取名规则之后,就能一键主动生成模板代码。

咱们比较习气叫页面 Page , 控制器叫 Controller ,所以咱们是自界说的模板:

用Android开发的方式开发Flutter - 那些开发小技巧

那么在指定的文件上点击右键 new 一些文件,运用插件生成就能创立指定的文件夹以及模板的代码。

用Android开发的方式开发Flutter - 那些开发小技巧

创立代码如下:

用Android开发的方式开发Flutter - 那些开发小技巧

例如咱们这运用100多个页面,的确省去了许多创立页面的费事操作,也节省了不少开发时间。

三、Entity主动化插件

Json 转 Dart 网上许多的工具类或插件,我就收藏了不少相似的网站

相似这种:

javiercbk.github.io/json_to_dar…

插件相似这样的:

用Android开发的方式开发Flutter - 那些开发小技巧

不是说他们不能用,能用!可是不便利,因为 Dart 的 Entity 和Java/ktolin的不相同,生成起来许多代码,假如要加减字段,改起来不便利。

咱们在开发的过程中,不免就会遇到后台人员少了些字段,做着做着就要加字段的状况,莫非此刻咱们还需求从头拿到新的 Json 去生成 Dart 吗?

那我去 Dart 中直接改? 这还真改不动,几层嵌套下来,改一个当地到处报错,为了一个简略的 Entity 目标何必整这么费事。

运用 json_annotation 注解库,加上 FlutterJsonBeanFactory 的插件:

用Android开发的方式开发Flutter - 那些开发小技巧

咱们就能主动生成代码啦,和Java的Json类生成相同的办法:

用Android开发的方式开发Flutter - 那些开发小技巧

输入Json与类名就能生成对应的实体啦:

用Android开发的方式开发Flutter - 那些开发小技巧

代码很简练,内部的代码结束就在生成的代码中,咱们无需管它。

用Android开发的方式开发Flutter - 那些开发小技巧

最便利一点的便是,能够自在加减字段,之后只需求从头生成即可:

用Android开发的方式开发Flutter - 那些开发小技巧

点击顶部的 Build 栏中的 FlutterBeanFactory 就能够从头生成:

用Android开发的方式开发Flutter - 那些开发小技巧

是不是和 Android 开发的体验很相似了!

四、常用展现控件的封装

除了这些好用的工具能够协助咱们加快开发步伐,咱们还能够对常用的一些展现控件进行封装,除了咱们之前的文章介绍过的一些容器和排版的运用,终究显现的元素无非便是文本,图片,按钮这三者用的最多了。

文本的常用封装:

class MyTextView extends StatelessWidget {
  double? padding = 0;
  double? margin = 0;
  double? paddingLeft = 0;
  double? paddingRight = 0;
  double? paddingTop = 0;
  double? paddingBottom = 0;
  double? marginLeft = 0;
  double? marginRight = 0;
  double? marginTop = 0;
  double? marginBottom = 0;
  double? fontSize = 0;
  Color? textColor = Colors.black;
  Color? backgroundColor = Colors.transparent;
  AlignmentGeometry? alignment = Alignment.center;
  double? cornerRadius = 0;
  double? borderWidth = 0;
  Color? borderColor = Colors.transparent;
  String content = "";
  bool? singleLine = false;
  VoidCallback? onClick;
  bool? isFontLight;
  bool? isFontRegular;
  bool? isFontMedium;
  bool? isFontBold;
  FontWeight? fontWeight;
  TextAlign? textAlign;
  MyTextView(this.content,
      {Key? key,
      this.textColor,
      this.backgroundColor,
      this.padding,
      this.paddingTop,
      this.paddingBottom,
      this.paddingRight,
      this.paddingLeft,
      this.cornerRadius,
      this.borderColor,
      this.borderWidth,
      this.marginBottom,
      this.marginLeft,
      this.marginRight,
      this.marginTop,
      this.margin,
      this.fontSize,
      this.singleLine,
      this.isFontLight,
      this.isFontRegular,
      this.isFontMedium,
      this.isFontBold,
      this.fontWeight,
      this.textAlign,
      this.onClick})
      : super(key: key) {
    if (padding != null) {
      if (padding != null && padding! > 0) {
        paddingLeft = padding;
        paddingRight = padding;
        paddingBottom = padding;
        paddingTop = padding;
      }
    }
    if (margin != null) {
      if (margin != null && margin! > 0) {
        marginLeft = margin;
        marginTop = margin;
        marginRight = margin;
        marginBottom = margin;
      }
    }
    if (isFontLight != null && isFontLight!) {
      fontWeight = FontWeight.w300;
    } else if (isFontRegular != null && isFontRegular!) {
      fontWeight = FontWeight.w400;
    } else if (isFontMedium != null && isFontMedium!) {
      fontWeight = FontWeight.w500;
    } else if (isFontBold != null && isFontBold!) {
      fontWeight = FontWeight.w700;
    } else {
      fontWeight = FontWeight.normal;
    }
  }
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.fromLTRB(marginLeft ?? 0, marginTop ?? 0, marginRight ?? 0, marginBottom ?? 0),
      decoration: BoxDecoration(
        border: Border.all(width: borderWidth ?? 0, color: borderColor ?? Colors.transparent),
        color: backgroundColor,
        borderRadius: BorderRadius.all(Radius.circular(cornerRadius ?? 0)),
      ),
      padding: EdgeInsets.fromLTRB(paddingLeft ?? 0, paddingTop ?? 0, paddingRight ?? 0, paddingBottom ?? 0),
      child: onClick != null
          ? GestureDetector(
              onTap: onClick,
              child: _childText(),
            )
          : _childText(),
    );
  }
  Widget _childText() {
    return Text(
      content,
      textAlign: textAlign ?? TextAlign.start,
      style: TextStyle(
        color: textColor,
        fontSize: fontSize ?? 14,
        fontWeight: fontWeight,
        overflow: singleLine ?? false ? TextOverflow.ellipsis : TextOverflow.clip,
      ),
    );
  }
}

为了减少嵌套,咱们这儿能够运用 if else 来判别是否需求加上装修容器。

运用文本:

    MyClickItem(
    title: '测试SP-存和取',
    drawablePadding: 0,
    onTap: () {
    SpUtil.putString("username", "Sky24n");
    SpUtil.putString(SPConstant.SP_KEY_TOKEN, "1234");
    String? userName = SpUtil.getString("username");
    if (!TextUtil.isEmpty(userName)) {
    SmartDialog.compatible.showToast('username:$userName');
    }
    },
    backgroundColor: ColorConstants.dividerColor,
    ),

图片的常用封装:

class MyLoadImage extends StatelessWidget {
  MyLoadImage(
    this.image, {
    Key? key,
    this.width,
    this.height,
    this.fit = BoxFit.cover,
    this.placeholderPath = '',
    this.cacheWidth,
    this.cacheHeight,
    this.isCircle,
    this.cornerRadius,
    this.borderColor,
    this.borderWidth,
    this.onClick,
  }) : super(key: key) {
    if (isCircle != null) {
      if (isCircle ?? true) {
        cornerRadius = width ?? 0 / 2;
      }
    }
  }
  final String? image;
  final double? width;
  final double? height;
  final BoxFit fit;
  final String placeholderPath;
  final int? cacheWidth;
  final int? cacheHeight;
  bool? isCircle = false;
  double? borderWidth = 0;
  Color? borderColor = Colors.transparent;
  VoidCallback? onClick;
  double? cornerRadius = 0;
  @override
  Widget build(BuildContext context) {
    //占位图
    final Widget? placeholder =
        placeholderPath.isEmpty ? null : MyAssetImage(placeholderPath, height: height, width: width, fit: fit);
    if (image == null || image!.isEmpty || image!.startsWith('http')) {
      //加载网络图片
      return _buildDecorationNetImage(placeholder);
    } else if (!TextUtil.isEmpty(image) && RegCheckUtils.isLocalImagePath(image!)) {
      //加载本地File途径的图片
      return _buildDecorationFileImage();
    } else {
      //加载本地资源的图片
      return onClick != null
          ? GestureDetector(
              onTap: onClick,
              child: _buildAssetImg(),
            )
          : _buildAssetImg();
    }
  }
  // 网络图片加载布局- 是否带着接触事情与圆角
  Widget _buildDecorationNetImage(Widget? placeholder) {
    if (cornerRadius != null && cornerRadius! > 0) {
      return Container(
        decoration: BoxDecoration(
          border: Border.all(width: borderWidth ?? 0, color: borderColor ?? Colors.transparent),
          borderRadius: BorderRadius.all(Radius.circular(cornerRadius ?? 0)),
        ),
        child: _buildGestureNetImg(placeholder),
      );
    } else {
      return _buildGestureNetImg(placeholder);
    }
  }
  // 网络图片加载布局- 是否带着接触事情
  Widget _buildGestureNetImg(Widget? placeholder) {
    return onClick != null
        ? GestureDetector(
            onTap: onClick,
            child: _buildClipImg(placeholder, placeholder),
          )
        : _buildClipImg(placeholder, placeholder);
  }
  /// 真正的网络图片布局 (ExtendedImage结构)
  ClipRRect _buildClipImg(Widget? placeholderWidget, Widget? errorWidget) {
    return ClipRRect(
        borderRadius: BorderRadius.circular(cornerRadius ?? 0),
        //加载网络图片
        child: ExtendedImage.network(
          image ?? "",
          width: width,
          height: height,
          fit: fit,
          cache: true,
          //是否启用缓存
          //状况监听
          loadStateChanged: (ExtendedImageState state) {
            switch (state.extendedImageLoadState) {
              case LoadState.loading:
                return placeholderWidget;
              case LoadState.completed:
                return null;
              case LoadState.failed:
                return errorWidget;
            }
          },
        ));
  }
  // 文件加载 - 是否带着接触事情与圆角
  Widget _buildDecorationFileImage() {
    if (cornerRadius != null && cornerRadius! > 0) {
      return Container(
        decoration: BoxDecoration(
          border: Border.all(width: borderWidth ?? 0, color: borderColor ?? Colors.transparent),
          borderRadius: BorderRadius.all(Radius.circular(cornerRadius ?? 0)),
        ),
        child: _buildGestureFileImage(),
      );
    } else {
      return _buildGestureFileImage();
    }
  }
  // 文件加载 - 是否带着接触事情
  Widget _buildGestureFileImage() {
    return onClick != null
        ? GestureDetector(
            onTap: onClick,
            child: _buildFileImage(),
          )
        : _buildFileImage();
  }
  // 文件的加载
  Widget _buildFileImage() {
    return ClipRRect(
      borderRadius: BorderRadius.circular(cornerRadius ?? 0),
      child: Image.file(
        File(image!),
        height: height,
        width: width,
        cacheWidth: cacheWidth,
        cacheHeight: cacheHeight,
        fit: fit,
        excludeFromSemantics: true,
      ),
    );
  }
  //真正的本地图片布局
  MyAssetImage _buildAssetImg() {
    return MyAssetImage(
      image ?? "",
      height: height,
      width: width,
      fit: fit,
      cacheWidth: cacheWidth,
      cacheHeight: cacheHeight,
    );
  }
}
/// 加载本地资源图片
class MyAssetImage extends StatelessWidget {
  const MyAssetImage(this.image,
      {Key? key, this.width, this.height, this.cacheWidth, this.cacheHeight, this.fit, this.color})
      : super(key: key);
  final String image;
  final double? width;
  final double? height;
  final int? cacheWidth;
  final int? cacheHeight;
  final BoxFit? fit;
  final Color? color;
  @override
  Widget build(BuildContext context) {
    return Image.asset(
      ImageUtils.getImgPath(image),
      height: height,
      width: width,
      cacheWidth: cacheWidth,
      cacheHeight: cacheHeight,
      fit: fit,
      color: color,
      /// 疏忽图片语义
      excludeFromSemantics: true,
    );
  }
}

这样就兼容了网络图片的加载,File 文件的加载,与 Asset 资源的加载。

运用:

      MyLoadImage(
        Assets.homeItemMoreGrayIcon,
            width: 5.5,
            height: 9.5,
        )
        MyLoadImage(
            item.member?.member_avatar,
            width: 55,
            height: 55,
            isCircle: true,
            placeholderPath: Assets.homeDefaultAvatarPlaceholder,
        )

关于 Button 的常用封装

Button的封装咱们需求注意的便是圆角,暗影,Z轴,点击水波纹,点击事情等。

class MyButton extends StatelessWidget {
  const MyButton({
    Key? key,
    required this.onPressed, //必选,点击回调
    this.text = '',
    this.fontSize = 16,
    this.textColor,
    this.disabledTextColor,
    this.backgroundColor,
    this.disabledBackgroundColor,
    this.minHeight = 43.0, //最高高度,默许43
    this.minWidth = double.infinity, //最小宽度,默许充溢控件
    this.padding = const EdgeInsets.symmetric(horizontal: 16.0), //内距离,默许是横向内距离
    this.radius = 5.0, //圆角
    this.enableOverlay = true, //是否支撑水波纹作用,不过这个作用对比InkWell比较克制,推荐敞开
    this.elevation = 0.0, //是否支撑暗影,设置Z轴高度
    this.shadowColor = Colors.black, //暗影的色彩
    this.side = BorderSide.none, //边框的设置
    this.fontWeight,
  }) : super(key: key);
  final String text;
  final double fontSize;
  final Color? textColor;
  final Color? disabledTextColor;
  final Color? backgroundColor;
  final Color? disabledBackgroundColor;
  final double? minHeight;
  final double? minWidth;
  final VoidCallback? onPressed;
  final EdgeInsetsGeometry padding;
  final double radius;
  final BorderSide side;
  final bool enableOverlay;
  final double elevation;
  final Color? shadowColor;
  final FontWeight? fontWeight;
  @override
  Widget build(BuildContext context) {
    return TextButton(
        onPressed: onPressed,
        style: ButtonStyle(
          // 文字色彩
          //               MaterialStateProperty.all            //各种状况都是这个色彩
          foregroundColor: MaterialStateProperty.resolveWith(
            //依据不同的状况展现不同的色彩
            (states) {
              if (states.contains(MaterialState.disabled)) {
                return DarkThemeUtil.multiColors(
                    disabledTextColor ?? Colors.grey,
                    darkColor: Colors.grey);
              }
              return DarkThemeUtil.multiColors(textColor ?? Colors.white,
                  darkColor: Colors.white);
            },
          ),
          // 布景色彩
          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);
          }),
          // 水波纹
          overlayColor: MaterialStateProperty.resolveWith((states) {
            return enableOverlay
                ? DarkThemeUtil.multiColors(textColor ?? Colors.white)
                    .withOpacity(0.12)
                : Colors.transparent;
          }),
          // 按钮最小巨细
          minimumSize: (minWidth == null || minHeight == null)
              ? null
              : MaterialStateProperty.all<Size>(Size(minWidth!, minHeight!)),
          padding: MaterialStateProperty.all<EdgeInsetsGeometry>(padding),
          shape: MaterialStateProperty.all<OutlinedBorder>(
            RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(radius),
            ),
          ),
          side: MaterialStateProperty.all<BorderSide>(side),
          elevation: MaterialStateProperty.all<double>(elevation),
          shadowColor: MaterialStateProperty.all<Color>(
              DarkThemeUtil.multiColors(shadowColor ?? Colors.black,
                  darkColor: Colors.white)),
        ),
        child: Text(
          text,
          style: TextStyle(
              fontSize: fontSize, fontWeight: fontWeight ?? FontWeight.w400),
        ));
  }
}

运用起来:

    MyButton(
    fontSize: 16,
    textColor: Colors.black,
    text: "My Button的封装按钮",
    backgroundColor: ColorConstants.gray,
    onPressed: () {
    SmartDialog.compatible.showToast("MyButton的封装按钮");
    },
    radius: 15,
    side: BorderSide(color: Colors.black,width: 1.0),
    ),

这样封装一下之后的确便利许多,不过每人都有各自的封装办法。我的办法也是仅供参考,假如不想运用封装的控件,直接用原生的硬怼其实也没什么缺点,功用或许还会更好。

五、扩展函数与高阶函数

咱们都知道 Kotlin 能够运用扩展函数与高阶函数,十分的便利,那么它和 Dart 的这些用法和 Kotlin 什么异同呢?

5.1 扩展函数

Dart 和 Kotlin 都支撑扩展办法,这两种言语的扩展办法有一些异同点:

相同点:

扩展办法答应在不修正原有类代码的状况下为该类增加新的办法。 扩展办法能够用于内置类型、自界说类型以及第三方库类型。 扩展办法被调用时,编译器会将其转化为静态函数调用。

不同点:

Kotlin的扩展办法能够界说为成员办法,而Dart的扩展办法只能界说为尖端函数。 Kotlin支撑扩展属性,而Dart现在不支撑。 Kotlin的扩展办法能够被重载,而Dart的扩展办法不支撑办法重载。 Kotlin的扩展办法能够界说在一个包等级中,而Dart的扩展办法只能界说在一个库等级中。 Kotlin的扩展办法能够承继和掩盖,而Dart的扩展办法不能被子类承继或许掩盖。 总的来说,Dart和Kotlin的扩展办法都是十分强大的特性。它们能够协助咱们在不修正原有类代码的状况下为类增加新的功用,提高代码的复用性和灵敏性。

根本的运用:

比方咱们给一个String增加一个扩展函数办法:

// 界说一个扩展办法,用于计算字符串的长度
extension StringExtension on String {
  int get lengthDouble {
    return this.length * 2;
  }
}
void main() {
  // 运用扩展办法
  String str = 'Hello World!';
  print(str.lengthDouble); // 输出:26
}

在Dart中,能够运用extension关键字来界说扩展办法,其中 this 关键字指向被扩展的目标自身。上面的示例中它为String类型增加了一个名为lengthDouble的办法。

内置函数:

写过 Kotlin 的同学,是不是觉得 Kotlin 的几个内置函数很好用,apply let run 等。咱们就能够用扩展函数的办法自己手动的界说这些函数:

extension LetRunApply<T> on T {
  /// let 函数与之前的示例相同,它承受一个闭包并回来其结果。
  R let<R>(R Function(T) block) {
    return block(this);
  }
 /// run 函数也与之前的示例相同,它承受一个闭包但不回来任何内容。
  void run(void Function(T) block) {
    block(this);
  }
  /// apply 函数它承受一个闭包并在当时目标上履行该闭包。然后,它回来当时目标自身
  T apply(void Function(T) block) {
    block(this);
    return this;
  }
}

运用起来:

 state.homeDataEntity?.run((it) {
      state.avatarPath = it.avatar;
      TextEditingController nameEditingController =
          state.dataArr[0]['controller'];
      nameEditingController.text = it.name ?? "";
      TextEditingController titleEditingController =
          state.dataArr[1]['controller'];
      titleEditingController.text = it.company?.pivot?.position ?? "";
      TextEditingController whatsappEditingController =
          state.dataArr[2]['controller'];
      whatsappEditingController.text = it.whatsapp ?? "";
      state.email = it.email;
    });
    update();
  }

是不是有 Kotlin 那味了

实战运用:

除了这些用法,咱们还能在实践开发中运用扩展的办法给根底布局增加束缚或容器或装修。

比方界说一个束缚扩展:

extension ExWidget on Widget {
  /// 束缚
  Widget constrained({
    Key? key,
    double? width,
    double? height,
    double minWidth = 0.0,
    double maxWidth = double.infinity,
    double minHeight = 0.0,
    double maxHeight = double.infinity,
  }) {
    BoxConstraints constraints = BoxConstraints(
      minWidth: minWidth,
      maxWidth: maxWidth,
      minHeight: minHeight,
      maxHeight: maxHeight,
    );
    constraints = (width != null || height != null)
        ? constraints.tighten(width: width, height: height)
        : constraints;
    return ConstrainedBox(
      key: key,
      child: this,
      constraints: constraints,
    );
  }
}

运用的时分:

用Android开发的方式开发Flutter - 那些开发小技巧

就很便利的给笔直的线性布局束缚一个宽度。

其实便是对当时控件包裹一层或多层嵌套的作用,视觉上减少了嵌套层级,也对一些常用的作用进行了封装与共用。

比方给控件加上一些装修作用:

 /// 束缚 高度
  Widget height(
    double height, {
    Key? key,
  }) =>
      ConstrainedBox(
        key: key,
        child: this,
        constraints: BoxConstraints.tightFor(height: height),
      );
  /// 约束盒子 最大宽高
  Widget limitedBox({
    Key? key,
    double maxWidth = double.infinity,
    double maxHeight = double.infinity,
  }) =>
      LimitedBox(
        key: key,
        maxWidth: maxWidth,
        maxHeight: maxHeight,
        child: this,
      );
  /// 偏移
  Widget offstage({
    Key? key,
    bool offstage = true,
  }) =>
      Offstage(
        key: key,
        offstage: offstage,
        child: this,
      );
  /// 透明度
  Widget opacity(
    double opacity, {
    Key? key,
    bool alwaysIncludeSemantics = false,
  }) =>
      Opacity(
        key: key,
        opacity: opacity,
        alwaysIncludeSemantics: alwaysIncludeSemantics,
        child: this,
      );
  /// 溢出
  Widget overflow({
    Key? key,
    AlignmentGeometry alignment = Alignment.center,
    double? minWidth,
    double? maxWidth,
    double? minHeight,
    double? maxHeight,
  }) =>
      OverflowBox(
        key: key,
        alignment: alignment,
        minWidth: minWidth,
        maxWidth: minWidth,
        minHeight: minHeight,
        maxHeight: maxHeight,
        child: this,
      );
  /// 内距离
  Widget padding({
    Key? key,
    EdgeInsetsGeometry? value,
    double? all,
    double? horizontal,
    double? vertical,
    double? top,
    double? bottom,
    double? left,
    double? right,
  }) =>
      Padding(
        key: key,
        padding: value ??
            EdgeInsets.only(
              top: top ?? vertical ?? all ?? 0.0,
              bottom: bottom ?? vertical ?? all ?? 0.0,
              left: left ?? horizontal ?? all ?? 0.0,
              right: right ?? horizontal ?? all ?? 0.0,
            ),
        child: this,
      );

当然还有更多的扩展办法,代码太多了粘不过来,网上也有许多相似的工具类。

5.2 高阶函数

高阶函数是指能够以其他函数作为参数或回来值的函数。Kotlin与Dart在高阶函数方面的异同点如下:

相同点:

都支撑将函数作为参数传递给另一个函数。

都支撑将函数作为回来值回来给另一个函数。

都支撑运用Lambda表达式来界说匿名函数

不同点:

Kotlin的高阶函数能够运用函数类型作为参数,而Dart则需求运用typedef来界说函数类型,然后才干将其作为参数或回来值。

Kotlin的Lambda表达式有一个约束,便是只答应运用函数字面值作为参数,而Dart的Lambda表达式则没有这个约束,能够直接运用恣意表达式作为参数。

在界说 Lambda 表达式时,Kotlin 能够推断出参数类型,而 Dart 需求显式指定参数类型。 Kotlin 支撑运用函数类型的变量来存储函数引用,而 Dart 则需求运用 Function 类型的变量来存储函数引用。

全体而言,Kotlin 和 Dart 的高阶函数具有相似的特性

一个简略的运用办法,想 Kotlin 相同的界说高阶函数:

  Future fetchHomeData(void Function(dynamic arg) action) async {
     ...
     final result = await UserService.to.getUserProfile();
     action(result);
     ...
  }

void Function(dynamic arg) 是一个高阶函数类型,假如是熟悉 Kotlin 的同学或许上手很快,可是新入门的或许看的一脸懵。

所以 Dart 也更推荐用别名的办法代替高阶函数的界说,如下:

typedef VoidCallback = void Function();

那咱们就能这么回调了:

  Future fetchHomeData(VoidCallback action) async {
     ...
     final result = await UserService.to.getUserProfile();
     action();
     ...
  }

假如是想要带参数的,也能界说:

typedef FrameCallback = void Function(Duration duration);

运用的时分:

  Future fetchHomeData(FrameCallback action) async {
     ...
     final result = await UserService.to.getUserProfile();
     action(123);
     ...
  }

关于高阶函数这一点倒没什么好说的,只需会 Kotlin 的同学,了解起来都不困难。

六、运用Loading占位布局加快页面发动速度

关于一些列表或杂乱的页面,咱们能够优化页面加载的速度,在 Android 中咱们也是相同的做法,运用一个占位布局,展现Lading,success,error,nodata,四种状况。

在 Flutter 中咱们能够运用相同的办法来操作,也能拿到优化发动速度,统一办理页面加载的状况。

假如运用的是 GetX 结构,能运用 GetX 的混入状况办法:

class DemoController extends GetxController with StateMixin<dynamic> {
  DemoController({required this.apiRepository});
  final ApiRepository apiRepository;
  //调用接口
  Future<void> getUserInfo() async {
    change(null, status: RxStatus.loading());
    //测试Post恳求,用户登陆
    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));
    }
  }
Text(obtainTextStr(controller.status) ?? "-")
 String? obtainTextStr(RxStatus status) {
    if (status.isLoading) {
      return "Loading...";
    } else if (status.isSuccess) {
      return "Success";
    } else if (status.isEmpty) {
      return "Empty";
    } else if (status.isError) {
      return status.errorMessage;
    }
    return "";
  }

手动的在每一个页面写四种状况的布局办法。费事是费事点,咱们也能经过抽取封装的办法,界说一个 Widget 内部结束四种状况的切换。

界说的类如下:

///四种视图状况
enum LoadState { State_Success, State_Error, State_Loading, State_Empty }
///依据不同状况来展现不同的视图
class LoadStateLayout extends StatefulWidget {
  final LoadState state; //页面状况
  final Widget? successWidget; //成功视图
  final VoidCallback? errorRetry; //过错事情处理
  String? errorMessage;
  LoadStateLayout(
      {Key? key,
      this.state = LoadState.State_Loading, //默许为加载状况
      this.successWidget,
      this.errorMessage,
      this.errorRetry})
      : super(key: key);
  @override
  _LoadStateLayoutState createState() => _LoadStateLayoutState();
}
class _LoadStateLayoutState extends State<LoadStateLayout> {
  @override
  Widget build(BuildContext context) {
    return Container(
      //宽高都充溢屏幕剩余空间
      width: double.infinity,
      height: double.infinity,
      child: _buildWidget,
    );
  }
  ///依据不同状况来显现不同的视图
  Widget get _buildWidget {
    switch (widget.state) {
      case LoadState.State_Success:
        return widget.successWidget ?? const SizedBox();
      case LoadState.State_Error:
        return _errorView;
      case LoadState.State_Loading:
        return _loadingView;
      case LoadState.State_Empty:
        return _emptyView;
      default:
        return _loadingView;
    }
  }
  ///加载中视图
  Widget get _loadingView {
    return Container(
      width: double.infinity,
      height: double.infinity,
      alignment: Alignment.center,
      child: Column(
        mainAxisSize: MainAxisSize.max,
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          const CircularProgressIndicator(
            strokeWidth: 3,
            valueColor: AlwaysStoppedAnimation(ColorConstants.appBlue),
          ),
          MyTextView('loading'.tr, marginTop: 15, fontSize: 15.5)
        ],
      ),
    );
  }
  ///过错视图
  Widget get _errorView {
    return Container(
        width: double.infinity,
        height: double.infinity,
        alignment: Alignment.center,
        padding: const EdgeInsets.only(bottom: 80),
        child: GestureDetector(
            onTap: widget.errorRetry,
            child: Column(
              mainAxisSize: MainAxisSize.max,
              crossAxisAlignment: CrossAxisAlignment.center,
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                const MyAssetImage('other/page_no_data.webp', width: 180, height: 180, fit: BoxFit.contain),
                MyTextView(widget.errorMessage??'Load Error Try Again'.tr, marginTop: 10, fontSize: 15.5),
              ],
            )));
  }
  ///数据为空的视图
  Widget get _emptyView {
    return Container(
      width: double.infinity,
      height: double.infinity,
      alignment: Alignment.center,
      padding: const EdgeInsets.only(bottom: 80),
      child: Column(
        mainAxisSize: MainAxisSize.max,
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          const MyAssetImage('other/page_no_data.webp', width: 180, height: 180, fit: BoxFit.contain),
          MyTextView('No Data'.tr, marginTop: 10, fontSize: 15.5),
        ],
      ),
    );
  }
}

运用的时分咱们就能经过包裹内容布局的办法结束:

      child: GetBuilder<NotificationListController>(builder: (controller) {
        return LoadStateLayout(
          state: controller.loadingState,
          errorMessage: controller.errorMessage,
          errorRetry: () {
            controller.retryRequest();
          },
          successWidget: EasyRefresh(
            controller: controller.refreshController,
            onRefresh: controller.onRefresh,
            onLoad: controller.loadMore,
            child: Scrollbar(
              child: ListView.builder(
                itemCount: controller.datas.length,
                itemBuilder: (BuildContext context, int index) {
                  return _buildNotificationItem(controller, index, () {
                    controller.gotoMessageChatPage(index);
                  });
                },
              ),
            ),
          ),
        );
      }),

咱们能够挑选运用 GetX 的办法手动更新,也能运用原生的 StatefullWidget 手动的 update 都是能够的。

例如咱们结合 GetX 运用的话,界说下面的办法,手动改写即可结束状况的切换啦。

 //页面PlaceHolder的展现
  LoadState loadingState = LoadState.State_Success;
  String? errorMessage;
  //改写页面状况
  void changeLoadingState(LoadState state) {
    loadingState = state;
    update();
  }

作用如图:

用Android开发的方式开发Flutter - 那些开发小技巧

七、异步与并发的运用以及差异

Dart 的异步在某些方面与 Kotlin 的协程有点相似,可是又有很大的不同。

Dart的异步是根据言语层面的,这意味着在Dart中,所有异步操作都是由言语自身供给的内置机制(Future 和 async/await),而Kotlin的协程则需求借助 kotlinx.coroutines 库。

其次,Dart的异步模型是根据事情循环的,而Kotlin协程则是根据线程的。在Dart中,程序履行时会保护一个事情行列,异步使命结束后主动放回行列中等候履行,而在Kotlin中,协程是在独自的线程或线程池中履行的。

在Dart中,运用 async/await 关键字来编写异步代码,而在Kotlin中,运用 suspend 关键字来符号能够挂起的函数。

  Future fetchNotifyChat() async {
    if (_needShowPlaceholder) {
      changeLoadingState(LoadState.State_Loading);
    }
    //获取到数据
    var result = await messageRepository.fetchMessageChat(_curPage, _pageSize, memberId);
    ...
  }

哪里用到 async 哪里加上就能够,无回来值能够为void,有回来值的话能够指定回来类型,如 Future

Kotlin 协程的并发,咱们知道都很 async await 就能够自行并发,而 Dart 中的并发则需求用 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;
      }
    }

尽管看着很好,可是咱们要知道 Dart 是单线程,假如是不消耗 CPU 资源的这种网络恳求延时等候的异步就没问题的,一旦涉及到两个使命都是消耗 CUP 资源的,就会造成卡顿。

所以比较推荐的做法,例如

  1. 两个网络恳求的 Future 并发,两者都不消耗CUP资源。

  2. 或许一个网络恳求,一个加载布局,然后当网络恳求与布局都加载结束之后再一起展现出来。(只要布局加载消耗CUP资源)

FutureBuilder(
  future: Future.wait([future1, future2]),
  builder: (BuildContext context, AsyncSnapshot snapshot) {
    if (snapshot.connectionState == ConnectionState.done) {
      // 两个Future都已结束,能够更新UI了
      return Text('Both futures have completed!');
    } else {
      // 至少有一个Future还未结束,显现加载指示器或许占位符
      return CircularProgressIndicator();
    }
  },
);

可是假如两个使命都是消耗CUP资源的,比方一个处理图片 ,一个紧缩视频之类的,就会导致卡顿,此刻就应该多开一个线程来结束,而不是在单线程中异步的办法结束。

Isolate 并发

所谓 Isolate ,你能够简略的了解是一种特殊的线程,每个 Isolate 都有自己独立的内存,这样设计的好处便是你不用加锁,也能安全的操作自己的数据。

Isolate 的特点:

1、Isolate 之间不能共享内存

2、Isolate 之间只能经过音讯通讯

简略的运用:

void main() async{
   // 创立一个 ReceivePort 用于接纳音讯
   var recv = ReceivePort();
   // 创立一个 Isolate,泛型参数为 SendPort,进口函数为 subTask
   // subTask 进口函数的参数为 SendPort 类型,因而 spawn 第二个参数,传入 recv 的 sendPort 目标
   Isolate.spawn<SendPort>(subTask, recv.sendPort);
   // 运用 await 等候 recv 的第一条音讯
   var result = await recv.first;
   print("receive:$result");
}
// Isolate 进口函数界说,接纳一个 SendPort 目标作为参数
void subTask(SendPort port){
  // 运用 SendPort 发送一条字符串音讯
  port.send("subTask Result");
}

ReceivePort 负责接纳 SendPort 发送的音讯,SendPort 和 ReceivePort 是捆绑关系, SendPort 是由 ReceivePort 创立的

八、单例与音讯总线的界说

尽管咱们运用 Getx 结构的话现已是能够结束状况办理,咱们能够直接经过依靠注入次拿到对方的 Controller 操作,爽是爽了,但为什么作为一个 Android 开发者仍是喜爱运用 EventBus 这样的音讯总线。

和 Kotlin 类型,结束音讯总线的办法不少,包括 RXxx 的办法也能结束音讯总线,这儿贴出一个自用的音讯总线代码。

class EventBus {
  EventBus._internal();
  //保存单例
  static final EventBus _singleton = EventBus._internal();
  //工厂构造函数
  factory EventBus() => _singleton;
  final _subscriptions = <String, List<Subscription>>{};
  /// 增加订阅者,回来一个 Subscription 目标,用于撤销订阅
  Subscription on(String eventName, EventCallback callback) {
    final sub = Subscription(eventName: eventName, callback: callback);
    _subscriptions.putIfAbsent(eventName, () => []).add(sub);
    return sub;
  }
  /// 撤销订阅(假如指定了 callback,则只撤销对应的订阅者;否则撤销所有订阅者)
  void off(String eventName, [Subscription? subscription]) {
    if (_subscriptions.containsKey(eventName)) {
      if (subscription == null) {
        _subscriptions[eventName]!.clear();
      } else {
        _subscriptions[eventName]!.removeWhere((sub) => sub.callback == subscription.callback);
      }
    }
  }
  /// 撤销所有订阅
  void offAll() {
    _subscriptions.clear();
  }
  /// 触发事情,通知所有订阅者
  void emit(String eventName, [arg]) {
    for (final sub in _subscriptions[eventName] ?? []) {
      sub.callback(arg);
    }
  }
}
/// Subscription 目标,用于撤销订阅
class Subscription {
  final String eventName;
  final EventCallback callback;
  Subscription({required this.eventName, required this.callback});
}
typedef void EventCallback(dynamic arg);
//界说一个top-level(大局)变量,页面引入该文件后能够直接运用bus
var bus = EventBus();

运用单例的办法办理 EventBus ,关于单例的界说与运用,前文现已说过了,固定的三板斧,怎样运用 EventBus,也是和 Android 的相似,注册与解注册。结束的原理也与 Anroid 相似,便是通知者的办法。

运用的话也很简略:

  Subscription? subscribe;
  void registerEventBus() {
    subscribe = bus.on(AppConstant.eventProfileRefresh, (arg) {
      Log.d("Home - 接纳音讯");
    });
  }
  void unregisterEventBus() {
    bus.off(AppConstant.eventProfileRefresh, subscribe);
    Log.d("Home - 解注册这个Key的音讯");
  }
  //发送事情
    bus.emit(AppConstant.eventProfileRefresh, true);

这下又能音讯满天飞了

九、常用的功用插件大全

最终讲一下一些常用的插件

权限的处理

不管是 iOS 仍是 Android 都少不了动态权限的恳求

  1. permission_handler:这是一个Flutter插件,用于在iOS和Android上恳求和查看权限。它能够处理各种权限类型,如相机、麦克风、位置、存储等。

  2. simple_permissions:这是另一个Flutter插件,用于恳求杂乱权限,例如读取联系人、发送短信等。

  3. flutter_easypermissions:这个插件是一个对Google供给的EasyPermissions库的封装,它简化了Android上的权限办理。它使您能够恳求多个权限,并处理用户拒绝权限恳求的状况。

  4. permission_builder:这是一个Flutter包,它供给了一种更简略的办法来恳求和查看权限。它经过运用Widget构建器和回调函数来处理权限恳求和查看,使得代码更易读和更易于保护。

个人是用的第一种

改写与加载

  1. pull_to_refresh:这是一个功用强大的下拉改写和上拉加载更多结构。它支撑各种自界说选项,而且具有杰出的功用。

  2. flutter_easyrefresh:这是一个灵敏的改写结构,支撑下拉改写、上拉加载更多、翻滚边际触发等特性。一起供给了十分便利的自界说功用,如定制改写头部、尾部及款式等。

  3. easy_refresh:供给多种内置的 Header 和 Footer 款式,而且支撑彻底自界说 Header 和 Footer 的办法。

我个人比较喜爱 easy_refresh ,或许是因为和 Android 的 SmartRefresh 比较相似吧,用起来比较习气。

软键盘处理

Flutter 的软键盘与原生对比仍是有些缺点,焦点切换问题,点击切换输入框软键盘的切换问题,提交之后隐藏问题,提交结束的焦点问题等等。所以才发展出一些软键盘的工具类。

  1. flutter_keyboard_visibility:这是一个轻量级的 Flutter 插件,可用于检测软键盘是否显现,并供给回调函数以呼应键盘状况改变。

  2. keyboard_actions:这个结构供给了一种简略易用的办法来处理文本输入框的焦点和软键盘。它答应您将操作按钮增加到软键盘上,例如“下一个”和“结束”按钮等。

我个人用的是 flutter_keyboard_visibility 大局设置之后不需求我再次设置,比较省心就主动适配了,假如需求穿透事情的时分包裹一层就能够了。

列表悬停吸顶

这个也是咱们常用的功用,列表翻滚到一些当地之后能够吸顶展现,假如有多个要吸顶的布局也能支撑,十分的便利。

  1. SliverAppBar:Flutter自带的一个组件,能够轻松创立一个悬停在页面顶部的AppBar,支撑翻滚作用,而且能够经过扩展SliverPersistentHeaderDelegate来结束更高档的自界说。

  2. sticky_headers:一个开源的Flutter插件,能够将与ListView或GridView相关联的头部固定在页面顶部,一起答运用户进行滑润的翻滚,而且支撑自界说翻滚作用和色彩。

  3. flutter_sticky_header:另一个开源的Flutter插件,能够结束相似StickyHeaders的功用。该组件还支撑各种自界说选项,例如布景色彩、暗影作用等。

我个人比较喜爱的便是原生 SliverAppBar ,假如不好结束就用 sticky_headers ,两者看状况运用根本上能结束大部分嵌套翻滚作用。

弹窗与气泡

想要结束自界说的弹窗与吐司的功用呢,需求自界说结束,系统其实自带就有各种弹窗,包括 GetX 中也是带弹窗的运用,可是不好用,混合用也或许导致显现层级的问题,所以咱们就用一个第三方的依靠统一办理。

  1. fluttertoast:一个简略易用的弹窗和吐司结构,能够轻松地在运用程序中显现文本提示。该结构支撑自界说款式和持续时间,而且供给了多种位置选项。

  2. flutter_custom_dialog:一个支撑自界说布局和动画作用的弹窗结构,能够结束各种杂乱的弹窗需求,例如登录框、挑选框等。该结构还支撑各种自界说选项,例如布景色彩、圆角巨细等。

  3. flutter_easyloading:一个支撑自界说款式和动画作用的加载提示结构,能够在运用程序中显现各种加载状况。该结构还支撑各种自界说选项,例如文字色彩、字体巨细等。

  4. flutter_smart_dialog是一个根据Flutter开发的弹窗库,它供给了十分易于运用和自界说的弹窗组件,包括提示框、加载框、成功/失利提示框、输入框以及底部菜单等等。

都能用,我个人运用的是 flutter_smart_dialog 。横竖也能自界说款式与动画,比较便利。

轮播

和 Android 的 Banner 库相似,原生也能结束,可是用结构更为简略。

  1. flutter_swiper:一个易于运用且高度可定制的轮播结构,支撑横向和纵向滑动,而且能够显现图片、文本等多种类型的内容。该结构还支撑各种自界说选项,例如布景色彩、指示器款式等。

  2. carousel_slider:一个简略易用的轮播结构,支撑无限循环翻滚和手势控制,能够快速地创立一个根本的轮播作用。该结构还支撑各种自界说选项,例如轮播间隔时间、翻滚速度等。

比较流行的便是这两个,个人运用的是 flutter_swiper 。

相机与相册

除了官方的一些插件,还有一些第三方的优异结构。

  1. camera: 这是Flutter官方供给的相机插件,能够轻松地访问摄像头并捕获视频和相片。它支撑Android和iOS,并供给了一些自界说选项。

  2. image_picker: 这个插件能够让你从相册或相机中挑选图片,并回来选定的图片文件。它支撑Android和iOS,并供给了一些自界说选项。

  3. photo_manager: 这个插件能够让你轻松地办理相片和视频。它支撑Android和iOS,并供给了一些自界说选项。

  4. lutter_image_picker_futures: 这个插件供给了一个简略易用的接口,让你从相机或相册中挑选图片。它支撑Android和iOS,并供给一些自界说选项。

  5. wechat_assets_picker: 根据 Flutter 开发的相册挑选器插件,它能够让你从相册中挑选相片和视频,并支撑媒体预览、多选、裁剪等功用。除此之外,wechat_assets_picker 还供给了灵敏的自界说选项,比方能够自界说相册封面、相册排序规则、图片过滤等。

  6. wechat_camera_picker: 根据 Flutter 开发的相机插件,它能够让你轻松地访问设备摄像头,并结束摄影、录像等功用。除此之外,wechat_camera_picker 还支撑自界说选项,比方设置摄影质量、设置预览尺寸和旋转视点等。

  7. image_cropper: 这个插件能够让你裁剪图片,以便显现或上传。它支撑Android和iOS,并供给一些自界说选项。

  8. flutter_image_compress: 这个插件能够紧缩图片巨细,以便更快地上传或发送。它支撑Android和iOS,并供给一些自界说选项。

等等,要说去这个真的是太多了,我个人比较喜爱 wechat_assets_picker,wechat_camera_picker 和 image_cropper 全家桶,相机相册接裁剪与紧缩,一套流程下来运用也是蛮便利,默许的微信风格也是和运用风格很搭。

图片加载

自带的控件就能加载图片,为什么还要用第三方,主要是能做到占位图展现,圆角,缓存等,相似 Android Glide 的功用。

  1. Flutter自带的Image组件:Flutter供给了内置的Image组件,能够直接运用。它支撑从本地、网络、Asset Bundle等多种来历加载图片。

  2. CachedNetworkImage:一个支撑缓存网络图片的库。它运用了flutter_cache_manager来办理缓存,能够有效地减少网络恳求,提高运用功用。

  3. extended_image :Flutter的图片加载和缩放库,比Flutter自带的Image组件功用更强大,支撑多种特性,加载,预加载,高斯模糊,GIF,镜像,缩放平移,圆角与裁剪,支撑多种资源加载图片。

我个人运用的 extended_image ,之前文章的图片封装便是根据这个结束的。

其他

extended 是国内的大佬出的一系列插件,除了 extended_image 还有其他的相似:

extended_tabs : Tab的扩展,支撑 TabView 嵌套翻滚,支撑设置翻滚方向和缓存巨细。

extended_text: Text的扩展,支撑富文本的功用,文本变色,文本变图片

extended_sliver: 嵌套滑动的作用,有点相似 MotionLayout 与 Behavor 的办法了

除了这些,还有一些常用的不行代替的插件

video_player : 视频播映

audioplayers : 音频播映

shared_preferences : 这个咱们都知道了

device_info_plus:获取设备信息

share_plus :共享工具

url_launcher:发动运用于其他Intent的

qr_flutter:生成二维码

google_ml_kit : 谷歌的AI库,支撑扫码,人脸检测,文本辨认,图片辨认等功用

rive : Rive动画

lottie: lottie动画咱们都熟

waterfall_flow:瀑布流

等等,这儿就不列举了,等到咱们需求用到的时分去查找一下真的是大把的资源,Flutter 不比之前的,生态真的好,可挑选的太多了。

跋文

乱,太乱了,杂七杂八的说了好多,而且好多都是带有个人的喜好风格,并不讨喜。

因为篇幅原因,自身也是相似总结或大纲的列表,讲的东西比较抽象,没有更细致的写到点上,假如有不明白或我了解不正确运用过错的办法,咱们都能够谈论区指出。

全体 Flutter 开发的的感觉,有不如原生的当地,也有体验超出原生的当地,也算是痛并快乐着。

究竟咱们自己公司的项目也多,以后有时机的话大概率仍是会持续用。再往后或许要改造老项目,改造并参加 Flutter 模块了。

后期还会出一篇上线 Flutter 集成谷歌内购的坑,相似相关的文章吧。再往后应该 Flutter 的文章不多。

那么本期内容就到这儿,如讲的不到位或讹夺的当地,期望同学们能够谈论区交流。

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

Ok,这一期就此结束。

用Android开发的方式开发Flutter - 那些开发小技巧