简介
- 上次参考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的源码才能超出问题根源。