Flutter之用mixin代替继承 2025-07-03 周四

简介

  • 上次参考GetView写了个BasePage,对应的也给了个BaseLogic,作为基类来使用;
  • 在交易记录,提现列表,购物车,包裹详情,预演等页面使用,能正常工作;
  • 但是,在Flutter中使用BasePage和BaseLogic,始终感觉怪怪的;
  • 学习成本也较高,BaseLogic还好,BasePage比较难理解,不大会用;
  • 所以考虑避开使用继承的方式,改用mixin和封装组件的方式来替代,降低学习成本。

mixin 的on关键字

  • 在 Dart 中,mixin 的 on 关键字用于限制 mixin 可以应用的类类型,确保 mixin 只能被特定类型的类使用。这为 mixin 提供了更强的类型约束和依赖管理。
  • mixin 只能被继承自指定基类的子类使用。
  • 确保 mixin 依赖的属性或方法在目标类中存在。
  • 最关键的是:mixin可以使用基类的方法,效果和继承一样。on关键字可以看做是mixin的专用extend
class Animal {
  void eat() => print('Eating...');
}

mixin CanFly on Animal {
  void fly() {
    print('Flying...');
    eat(); // 可以调用 Animal 的方法
  }
}

// 正确:Bird 继承自 Animal,可以使用 CanFly
class Bird extends Animal with CanFly {}

// 错误:Car 未继承 Animal,无法使用 CanFly
// class Car with CanFly {} // 编译错误!
class HasEngine {
  void startEngine() => print('Engine started');
}

class Vehicle extends HasEngine {}

mixin Electric on Vehicle, HasBattery {
  // on就像是extend,可以使用基类的方法
  void charge() {
    startEngine(); // 来自 HasEngine
    useBattery();  // 来自 HasBattery
  }
}

class Car extends Vehicle with Electric {} // 需实现 HasBattery

替换继承

  • 记得当时在封装上拉下拉逻辑的时候,优先考虑的是mixin方式;
  • 在默认的fetchData方法中,网络请求开始,网络执行中,网络执行之后,需要刷新界面,需要用到GetxController中的update()方法。
  • 当时不知道on关键字的作用,所以不论是使用发消息,或者属性观察等方法,虽然能实现,但是使用起来很别扭。
  • 把上拉下拉封装成一个集成自GetxController的类,当做页面的基类来用,可以解决问题,使用起来学习成本也不高。
/// 刷新相关方法
class BaseRefreshLogic extends GetxController {}

/// 继承刷新基类,复用上拉下拉逻辑
class MineLogic extends BaseRefreshLogic with AuthMixin {}
  • 使用on关键字,可以把BaseRefreshLogic改成mixin,代码可以一点都不改,非常方便。
import 'package:get/get.dart';
import 'package:gobuy/network/core/base_response.dart';
import 'package:gobuy/utils/common_utils/log_util.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';

/// 配合上拉下拉刷新组件使用
mixin RefreshMixin on GetxController {
  int page = 1;
  int size = 20;
  dynamic resData;
  List dataList = [];
  bool isRefresh = true;
  bool hasMore = true;
  RefreshController refreshController = RefreshController(initialRefresh: false);
  Future<BaseResponse>? loadFuture;
  bool isLoading = false;
  bool isNetworkError = false;
  bool enableLoad = true;
  bool enableRefresh = true;

  /// 是否为空
  bool get isEmpty => dataList.isEmpty;

  /// 接口的loading控制
  bool showLoading = false;

  /// 重写这个方法,构造网络请求
  void buildFuture() {}

  /// 重写这个方法,定义除了上拉下拉之外的其他网络请求
  Future<void> customRefresh() async {}

  /// 重写这个方法,定义网络请求结束后的操作
  void onComplete() {}

  /// 用于刷新组件
  void onRefresh({bool showLoading = false}) async {
    this.showLoading = showLoading;
    page = 1;
    isRefresh = true;
    isNetworkError = false;

    hasMore = true;
    refreshController.resetNoData();

    /// 特殊情况禁用的加载,需要在这里恢复
    enableLoad = true;

    /// 其他的网络请求先执行
    await customRefresh();

    fetchData();
  }

  /// 用于加载更多组件
  void onLoad({bool showLoading = false}) async {
    this.showLoading = showLoading;
    page++;
    isRefresh = false;
    isNetworkError = false;

    fetchData();
  }

  /// 重写这个方法,业务方自行处理刷新逻辑
  void fetchData() async {
    /// 参数检查
    buildFuture();
    if (loadFuture == null) {
      LogUtil.e("loadFuture为空");
      return;
    }

    isLoading = true;
    update();
    LogUtil.e("开始网络请求");

    BaseResponse baseResponse = await loadFuture!;

    isLoading = false;
    update();
    LogUtil.e("网络请求结束");

    /// 不论成功失败,刷新都要清空数据
    if (isRefresh) {
      dataList.clear();
    }

    if (baseResponse.isSuccess) {
      //判断data是否有值 并且是不是数组
      //如果data是数组,则直接赋值
      //如果data是对象,则判断list是否存在
      //如果list存在,则赋值
      //如果list不存在,则尝试获取data中的list
      List list = [];
      if (baseResponse.data is List) {
        list = baseResponse.data;
      } else {
        resData = baseResponse.data;
        list = baseResponse.list ?? [];
      }
      if (list.isNotEmpty) {
        dataList.addAll(list);
      }
      if (list.length < size) {
        hasMore = false;
      } else {
        hasMore = true;
      }
      if (resData != null && resData is bool) {
        hasMore = resData;
      }
    }

    onFinish(baseResponse.isSuccess);
  }

  void onFinish(bool isSuccess) {
    isNetworkError = !isSuccess;
    if (isSuccess) {
      /// 先结束处理中状态
      if (isRefresh) {
        refreshController.refreshCompleted();
      } else {
        refreshController.loadComplete();
      }

      /// 不论上拉下拉,都要判断是否还有更多数据
      if (!hasMore) {
        refreshController.loadNoData();
      }

      /// 刷新,没数据,会显示空视图,此时禁用加载更多
      if (isRefresh && isEmpty) {
        enableLoad = false;
      }
    } else {
      if (isRefresh) {
        refreshController.refreshFailed();
      } else {
        refreshController.loadFailed();
      }
    }

    update();
    LogUtil.e("onFinish数据更新;isSuccess:$isSuccess");

    /// 网络请求结束后的操作
    onComplete();
  }
}
  • 将继承改成mixin之后,使用更加灵活,可以仍然使用默认的GetxController做基类。
/// 使用mixin不用改基类,直接在with关键字之后追加就可以了
class MineLogic extends GetxController with AuthMixin, RefreshMixin {}

用组件替代BasePage

  • 在Flutter中采用继承的方式复用页面,感觉不是很好,还是封装成组件的思路更自然一点。
  • material提供了默认的脚手架Scaffold,可以进行第1层封装,设定一下背景色等等。
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:get/get.dart';
import 'package:gobuy/utils/theme/style_utils.dart';

// ignore: must_be_immutable
class GBScaffold extends StatefulWidget {
  String? title;
  Widget body;
  Widget? titleWidget;
  Widget? leading;
  double? leadingWidth;
  List<Widget>? actions;
  PreferredSizeWidget? bottom;
  Widget? floatingActionButton;
  bool dataReady;
  FloatingActionButtonLocation? floatingActionButtonLocation;
  Color? backgroundColor;
  Color? flexibleSpaceColor;
  GBScaffold({
    super.key,
    this.title,
    this.titleWidget,
    this.leading,
    this.leadingWidth,
    this.actions,
    this.bottom,
    required this.body,
    this.floatingActionButton,
    this.floatingActionButtonLocation,
    this.backgroundColor,
    this.flexibleSpaceColor,
    this.dataReady = true,
  });

  @override
  State<GBScaffold> createState() => _WejoyScaffoldState();
}

class _WejoyScaffoldState extends State<GBScaffold> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        FocusScope.of(Get.context!).requestFocus(FocusNode());
      },
      child: RepaintBoundary(
        child: Scaffold(
          backgroundColor: widget.backgroundColor ?? ColorUtils.c_f8f8f8,
          // backgroundColor: Colors.white,
          appBar: AppBar(
            flexibleSpace: Container(
              color: widget.flexibleSpaceColor ?? ColorUtils.c_ffffff,
            ),
            centerTitle: true,
            elevation: 0,
            backgroundColor: widget.backgroundColor ?? ColorUtils.c_ffffff,
            // backgroundColor: Colors.white,
            title: widget.title != null ? Text(widget.title!, style: StyleUtils.ts_1f_17_600) : widget.titleWidget,
            iconTheme: IconThemeData(color: ColorUtils.c_1f1f1f),
            leading: widget.leading,
            leadingWidth: widget.leadingWidth,
            actions: widget.actions,
            bottom: widget.bottom,
          ),
          body: widget.dataReady ? widget.body : buildEmptyWidget(),
          floatingActionButton: widget.floatingActionButton,
          floatingActionButtonLocation: widget.floatingActionButtonLocation,
        ),
      ),
    );
  }

  Widget? buildEmptyWidget() {
    return Container(
      padding: const EdgeInsets.only(bottom: 120),
      width: double.infinity,
      height: double.infinity,
      alignment: Alignment.center,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          SpinKitRotatingCircle(
            size: 80,
            color: Theme.of(context).colorScheme.primary,
          ),
        ],
      ),
    );
  }
}

上拉下拉脚手架

  • 通用的脚手架可以用在几乎所有的页面上。正常情况,进行以上第1层封装也就够用了。
  • 项目中,简单的上拉下拉列表还是很多的,比如这样交易记录页面,就一个上拉下拉组件。
上拉下拉列表
  • 所以考虑在第1层封装的基础上进行二次封装,整个页面就一个封装好的上拉下拉组件。
import 'package:flutter/material.dart';
import 'package:gobuy/components/GBScaffold.dart';
import 'package:gobuy/utils/refresh_utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';

class ScaffoldRefresh extends StatelessWidget {
  const ScaffoldRefresh({
    super.key,
    this.title = "",
    this.actions,
    this.bottom,
    this.margin,
    this.padding,
    required this.controller,
    required this.onRefresh,
    required this.onLoad,
    required this.enableLoad,
    required this.enableRefresh,
    required this.isEmpty,
    this.emptyWidget,
    required this.slivers,
  });

  final String title;
  final List<Widget>? actions;
  final PreferredSizeWidget? bottom;
  final EdgeInsetsGeometry? margin;
  final EdgeInsetsGeometry? padding;
  final RefreshController controller;
  final void Function() onRefresh;
  final void Function() onLoad;
  final bool enableLoad;
  final bool enableRefresh;
  final bool isEmpty;
  final Widget? emptyWidget;
  final List<Widget> slivers;

  @override
  Widget build(BuildContext context) {
    return GBScaffold(
      title: title,
      actions: actions,
      bottom: bottom,
      body: Container(
        margin: margin,
        padding: padding,
        child: RefreshWidget(
          controller: controller,
          onRefresh: onRefresh,
          onLoad: onLoad,
          enableRefresh: enableRefresh,
          enableLoad: enableLoad,
          isEmpty: isEmpty,
          emptyWidget: emptyWidget,
          slivers: slivers,
        ),
      ),
    );
  }
}
  • 看着参数挺多,但是一大半都是上拉下拉逻辑,结合封装好的RefreshMixin,基本上不用写。
/// 交易记录的logic,上拉下拉逻辑都在RefreshMixin,不用重复写
class TradeRecordLogic extends GetxController with RefreshMixin {}
  • 页面使用的时候,一大半参数可以直接给。
class TradeRecordPage extends StatelessWidget {
  TradeRecordPage({super.key});

  final TradeRecordLogic logic = Get.put(TradeRecordLogic());

  @override
  Widget build(BuildContext context) {
    return GetBuilder<TradeRecordLogic>(
      builder: (_) {
        return ScaffoldRefresh(
          title: "trade_record".tr,
          actions: _buildActions(),
          controller: logic.refreshController,
          onRefresh: logic.onRefresh,
          onLoad: logic.onLoad,
          enableLoad: logic.enableLoad,
          enableRefresh: logic.enableRefresh,
          isEmpty: logic.isEmpty,
          emptyWidget: Kong(img: R.assetsImgKongNorecords, title: logic.isLoading ? "" : "no_record".tr),
          slivers: _buildSlivers(),
        );
      },
    );
  }
}
  • 通过以上例子可以看出,mixin和组件的套装比先前的BaseLogic和BasePage更加简洁易懂。学习和使用成本更低。

渐变脚手架

  • 有些页面,比如包裹详情页,要求实现渐变背景。基本思路是通过Stack和滑动监听,修改透明度来实现。
渐变头部
  • 当有多个页面的时候,重复的代码可以抽出来,封装成组件。
  • 将渐变需要的透明度参数和渐变函数封装成mixin
import 'dart:math';
import 'package:get/get.dart';

/// 渐变导航栏需要的渐变函数
mixin GradientMixin on GetxController {
  double alpha = 0;
  void onScrollChange(double position) {
    double shift = min(position, 100.0);
    alpha = shift / 100.0;
    update();
  }
}
  • 对通用脚手架进行二次封装,把一些重复代码进行复用
class ScaffoldGradient extends StatelessWidget {
  const ScaffoldGradient({
    super.key,
    this.title,
    this.backgroundImageName,
    required this.alpha,
    required this.onScrollChange,
    required this.slivers,
    this.bottomWidget,
  });

  /// 页面标题
  final String? title;

  /// 背景图片
  final String? backgroundImageName;

  /// 透明度
  final double alpha;

  /// 渐变函数
  final void Function(double position) onScrollChange;

  /// 内容
  final List<Widget> slivers;

  /// 底部视图
  final Widget? bottomWidget;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          _buildBackground(),
          _buildContent(),
          _buildNavigationBar(),
        ],
      ),
    );
  }

  /// 第1层:背景
  Widget _buildBackground() {
    return Image.asset(
      backgroundImageName ?? R.assetsImgBgPack,
      width: ScreenUtil().screenWidth,
      fit: BoxFit.fitWidth,
    );
  }

  /// 第2层内容
  Widget _buildContent() {
    return Column(
      children: [
        Expanded(
          child: Container(
            margin: EdgeInsets.symmetric(horizontal: 15.r),
            child: NotificationListener(
              onNotification: (notification) {
                if (notification is ScrollUpdateNotification) {
                  /// 监听滚动位置,导航栏渐变
                  double scrollPosition = notification.metrics.pixels;
                  onScrollChange(scrollPosition);
                }
                return true;
              },
              child: CustomScrollView(
                slivers: slivers,
              ),
            ),
          ),
        ),
        bottomWidget ?? Container(),
      ],
    );
  }

  /// 第3层:背景色渐变的导航栏
  Widget _buildNavigationBar() {
    return Container(
      color: ColorUtils.c_f8f8f8.withValues(alpha: alpha),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(height: GBUtils.getTopSafeHeight()),
          SizedBox(
            height: 44.r,
            child: Stack(
              children: [
                Container(
                  padding: EdgeInsets.symmetric(horizontal: 40.r),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text(
                        title ?? "",
                        style: StyleUtils.ts_1f_17_600,
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ],
                  ),
                ),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    GestureDetector(
                      behavior: HitTestBehavior.opaque,
                      onTap: () {
                        Get.back();
                      },
                      child: Row(
                        children: [
                          SizedBox(width: 22.r),
                          Icon(
                            Icons.arrow_back_ios,
                            size: 22.r,
                            color: ColorUtils.c_000000,
                          ),
                          SizedBox(width: 22.r),
                        ],
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
  • 使用的时候,渐变逻辑只要引入GradientMixin就可以了
class PackageDetailLogic extends GetxController with GradientMixin {}
  • 页面中,GradientMixin的变量和函数可以直接用,不需要再重复写。
class PackageDetailPage extends StatelessWidget {
  PackageDetailPage({super.key});

  final PackageDetailLogic logic = Get.put(PackageDetailLogic());

  @override
  Widget build(BuildContext context) {
    return GetBuilder<PackageDetailLogic>(
      builder: (_) {
        return ScaffoldGradient(
          backgroundImageName: IconUtil.bgPackDetail,
          alpha: logic.alpha,
          onScrollChange: logic.onScrollChange,
          slivers: _buildSlivers(),
        );
      },
    );
  }
}

可滑动脚手架

  • 大多数页面,用到ListView,或者是CustomScrollView;可以滑动,性能也好。
  • 还有写页面,内容有限,直接用Column就可以了。但是有些时候,内容会有点多,超过一屏的话,Column会报错。
  • 所以考虑在Column的外面套一个SingleChildScrollView,解决超长报错的问题。
  • 这个组件没有额外的逻辑,不需要mixin,只要在通用的脚手架外再封装一层就可以了。
  • 内容少的时候,就是静态页面,内容多了,就可以滑动,符合普通的思维习惯。整个效果就像是一个可滑动的Column
  • 有些时候,底部还有一个固定的操作区,可以提供一个默认的,增加一个参数配置一下就可以了。
可滑动页面
  • 封装代码很简单,就是Column和SingleChildScrollView相互结合,并且提供一个默认的操作区视图
class ScaffoldScroll extends StatelessWidget {
  const ScaffoldScroll({
    super.key,
    this.title,
    this.actions,
    this.bottom,
    this.margin,
    this.padding,
    required this.children,
    this.showConfirmWidget = false,
    this.operationWidget,
    this.confirmButtonText,
    this.onConfirm,
  });

  final String? title;
  final List<Widget>? actions;
  final PreferredSizeWidget? bottom;
  final EdgeInsetsGeometry? margin;
  final EdgeInsetsGeometry? padding;
  final List<Widget> children;
  final Widget? operationWidget;
  final bool showConfirmWidget;
  final String? confirmButtonText;
  final void Function()? onConfirm;

  @override
  Widget build(BuildContext context) {
    return GBScaffold(
      title: title,
      actions: actions,
      bottom: bottom,
      body: Column(
        children: [
          Expanded(
            child: Container(
              margin: margin,
              padding: padding,
              child: SingleChildScrollView(
                child: Column(
                  children: children,
                ),
              ),
            ),
          ),
          operationWidget ?? _buildOperationWidget(),
        ],
      ),
    );
  }

  Widget _buildOperationWidget() {
    return Visibility(
      visible: showConfirmWidget,
      child: BottomButtonWidget(
        confirmButtonText: confirmButtonText,
        onConfirm: onConfirm,
      ),
    );
  }
}
  • 使用的时候更简单,直接提供一个内容的Widget数组就可以了,其他的配置一下就好。
class PreviewSubmitPage extends StatelessWidget {
  PreviewSubmitPage({super.key});

  final PreviewSubmitLogic logic = Get.put(PreviewSubmitLogic());

  @override
  Widget build(BuildContext context) {
    return GetBuilder<PreviewSubmitLogic>(
      builder: (_) {
        return ScaffoldScroll(
          title: "preview_package".tr,
          actions: _buildActions(),
          margin: EdgeInsets.symmetric(horizontal: 10.r, vertical: 16.r),
          showConfirmWidget: true,
          confirmButtonText: 'submit_rehearsal'.tr,
          onConfirm: logic.onConfirm,
          children: _buildChildren(),
        );
      },
    );
  }
}

小结

  • 统一的BaseLogic和BasePage比较难理解,学习曲线比较陡峭,难以推广。
  • 拆成以上3种mixin和组件之后,功能更加单一,使用更加灵活和简单。
  • mixin的on关键字,效果相当于与继承,非常好用。
  • 本来想把GetBuilder也封装到组件中的,但是试了一下,却发现logic和page之间的联系断了,原因不明,估计要研究GetX的源码才能超出问题根源。
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容