likes
comments
collection
share

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

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

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 - 那些开发小技巧