Flutter的防抖与节流与封装
前言
首先我们要区分防抖与节流,他们很类似,又有很大的不同。
防抖是延时操作,在触发事件时,不立即执行目标操作,而是给出一个延迟的时间,如果在指定的时间区间内再次触发了事件,则重置延时时间,只有当延时时间走完了才会真正执行。
节流是忽略操作,在触发事件时,立即执行目标操作,如果在指定的时间区间内再次触发了事件,则会丢弃该事件不执行,只有超过了指定的时间之后,才会再次触发事件。
节流与防抖在 App 中的应用是很广泛的,例如比较常见的根据输入框的输入文本进行查询,我们需要防抖,例如点击按钮进行网络请求或UI操作,为了避免重复触发事件我们需要节流。
在 Android 开发中我们可以通过 RxJava/RxKotlin 或者扩展函数封装等方式来实现类似的功能。
在 Flutter 中我们可以通过 Timer 类进行时间区域的限制,同时为了全局使用我们可以定义为扩展函数,再方便一点我们可以封装为可配置参数在我们的自定义Widget中使用。
一、实现默认的防抖与节流并定义全局扩展
由于网上很多方案都是基于 hashcode 为 key 的方案实现的,而这种方案在页面刷新之后导致 hashcode 的值变化会导致防抖失效,而我们一般App不会出现需要同时点击两个按钮去触发逻辑的场景,所以我这里直接用单 Timer 去判断 isActive ,更加的简单方便。
定义全局的扩展方法如下:
ini
// 扩展Function,添加防抖功能
extension DebounceExtension on Function {
void Function() debounce([int milliseconds = 500]) {
Timer? _debounceTimer;
return () {
if (_debounceTimer?.isActive ?? false) _debounceTimer?.cancel();
_debounceTimer = Timer(Duration(milliseconds: milliseconds), this);
};
}
}
// 扩展Function,添加节流功能
extension ThrottleExtension on Function {
void Function() throttle([int milliseconds = 500]) {
bool _isAllowed = true;
Timer? _throttleTimer;
return () {
if (!_isAllowed) return;
_isAllowed = false;
this();
_throttleTimer?.cancel();
_throttleTimer = Timer(Duration(milliseconds: milliseconds), () {
_isAllowed = true;
});
};
}
}
直接对函数进行防抖和节流,这里只是做了默认的 void Function() 的返回值,如果有特殊的参数或返回值的高阶函数返回对象可以重写,后面会举例。
使用:
less
GestureDetector(
key: key,
onTap: controller.gotoWalletPage.throttle(),
behavior: behavior ?? HitTestBehavior.opaque,
excludeFromSemantics: excludeFromSemantics,
dragStartBehavior: dragStartBehavior,
child: XX,
)
void gotoWalletPage() {
Log.d("去钱包页面");
}
二、全局点击事件扩展与防抖节流
当然每一个文本或图片都要加点击事件,我们只需要包裹 GestureDetector 即可,其实我们就可以用扩展方法的方式封装一个语法糖。
ini
//点击防抖类型
enum ClickType { none, throttle, debounce }
/// 手势
Widget onTap(
GestureTapCallback onTap, {
Key? key,
HitTestBehavior? behavior,
ClickType type = ClickType.none, //默认没有点击类型
int milliseconds = 500, //点击类型的时间戳(毫秒)
bool excludeFromSemantics = false,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
}) =>
GestureDetector(
key: key,
onTap: type == ClickType.debounce
? onTap.debounce(milliseconds)
: type == ClickType.throttle
? onTap.throttle(milliseconds)
: onTap,
behavior: behavior ?? HitTestBehavior.opaque,
excludeFromSemantics: excludeFromSemantics,
dragStartBehavior: dragStartBehavior,
child: this,
);
使用:
less
Container(
margin: const EdgeInsets.only(left: 10, right: 10),
padding: const EdgeInsets.only(left: 17, right: 20),
width: 356,
height: 84,
decoration: BoxDecoration(
image: DecorationImage(
image: Get.isDarkMode ? const AssetImage(Assets.homeEwalletHomeIbgDark) : const AssetImage(Assets.homeEwalletHomeIbg),
fit: BoxFit.fill,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const MyAssetImage(
Assets.homeEwalletHomeIcon,
width: 36.5,
height: 36.5,
),
MyAssetImage(
Assets.homeEwalletHomeLine,
width: 1.5,
height: 40,
color: DarkThemeUtil.multiColors(null, darkColor: ColorConstants.greye0),
).marginOnly(left: 12.5, right: 10.5),
...
],
),
).onTap(controller.gotoWalletPage, type: ClickType.throttle)
直接可以在文本,图片,容器中很方便的加入点击事件,顺便设置防抖类型与间隔时间,是很方便的语法糖。
三、按钮事件的防抖与节流
对应按钮的点击事件我们可以在 Button Widget 的 onPressed 中给函数加上扩展的方式实现,我们也能在封装的 Button 控件中根据防抖类型与间隔事件进行封装,由于我们项目本身就已经封装了一些常用控件方便对主题切换默认实现的一些默认效果,这里演示一下:
kotlin
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,
this.type = ClickType.none,
this.milliseconds = 500,
}) : 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;
final ClickType type; //默认没有点击类型
final int milliseconds; //点击类型的时间戳(毫秒)
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: type == ClickType.debounce
? onPressed?.debounce(milliseconds)
: type == ClickType.throttle
? onPressed?.throttle(milliseconds)
: 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 disabledBackgroundColor;
}
return backgroundColor;
}),
// 水波纹
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),
));
}
}
使用的时候:
less
//登录按钮
MyButton(
text: "登 录".tr,
textColor: ColorConstants.white,
fontSize: 17,
fontWeight: FontWeight.w700,
backgroundColor: ColorConstants.appBlue,
minHeight: 45,
radius: 5,
type: ClickType.throttle,
milliseconds: 1000,
onPressed: () => controller.doInputLogin(),
),
//电话号码一键登录
MyButton(
text: "本机号码一键登录".tr,
fontSize: 17,
fontWeight: FontWeight.w700,
textColor: ColorConstants.white,
backgroundColor: ColorConstants.appGreen,
minHeight: 45,
radius: 5,
type: ClickType.throttle,
milliseconds: 1000,
onPressed: () => controller.loginQuickWithPhone(),
),
效果:
当不停的点击登录按钮的时候,一秒钟只能响应一次,不停点击一键登录按钮,一秒钟只能响应一次,如果同时点击登录和一键登录按钮,则两者都可以触发,但是两者内部都有各自的一秒限制,如果你的业务场景就是需要登录按钮和一键登录按钮一起防抖则需要自定义了。
四、输入框等特色函数的防抖与节流
如果说之前的场景我们都是节流的应用场景,那么在输入框中就是典型的防抖的应用场景。
和之前的处理不同,不需要扩展方法,因为不知道每一种高阶函数是哪一种参数,我们只对默认的无参无返回的默认高阶函数做了全局的定义,这里直接用方法即可:
ini
//带参数的函数防抖,由于参数不固定就没有用过扩展,直接用方法包裹
void Function(String value) debounce(void Function(String value) callback, [int milliseconds = 500]) {
Timer? _debounceTimer;
return (value) {
if (_debounceTimer?.isActive ?? false) _debounceTimer?.cancel();
_debounceTimer = Timer(Duration(milliseconds: milliseconds), () {
callback(value);
});
};
}
//带参数的函数节流,由于参数不固定就没有用过扩展,直接用方法包裹
void Function(String value) throttle(void Function(String value) callback, [int milliseconds = 500]) {
bool _isAllowed = true;
Timer? _throttleTimer;
return (value) {
if (!_isAllowed) return;
_isAllowed = false;
callback(value);
_throttleTimer?.cancel();
_throttleTimer = Timer(Duration(milliseconds: milliseconds), () {
_isAllowed = true;
});
};
}
封装的TextFiled:
kotlin
class MyTextField extends StatelessWidget {
String formKey;
String value;
bool? enabled;
TextInputType inputType;
FocusNode? focusNode;
String? labelText;
TextStyle? labelStyle;
String? errorText;
double cursorWidth;
Color? cursorColor;
String? hintText;
TextStyle? hintStyle;
TextStyle? style;
bool? autofocus;
int? maxLines = 1;
InputBorder? border;
BoxBorder? boxBorder;
bool? showLeftIcon;
Widget? leftWidget;
bool? showRightIcon;
Widget? rightWidget;
bool? showDivider;
Color? dividerColor;
bool obscureText;
double height;
Color? fillBackgroundColor;
double? fillCornerRadius;
EdgeInsetsGeometry padding;
EdgeInsetsGeometry margin;
InputDecoration? decoration;
TextEditingController? controller;
TextInputAction textInputAction = TextInputAction.done;
Function? onChanged;
Function? onSubmit;
final ClickType changeActionType; //默认没有点击类型
final int changeActionMilliseconds; //点击类型的时间戳(毫秒)
final ClickType submitActionType; //默认没有点击类型
final int submitActionMilliseconds; //点击类型的时间戳(毫秒)
MyTextField(
this.formKey,
this.value, {
Key? key,
this.enabled = true, //是否可用
this.inputType = TextInputType.text, //输入类型
this.focusNode, //焦点
this.labelText,
this.labelStyle,
this.errorText, //错误的文本
this.cursorWidth = 2.0, // 光标宽度
this.cursorColor = ColorConstants.appBlue, // 光标颜色
this.hintText, //提示文本
this.hintStyle, //提示文本样式
this.style, //默认的文本样式
this.autofocus = false, // 自动聚焦
this.maxLines = 1, //最多行数,高度与行数同步
this.border = InputBorder.none, //TextFiled的边框
this.boxBorder, // 外层Container的边框
this.showLeftIcon = false, //是否展示左侧的布局
this.leftWidget, //左侧的布局
this.showRightIcon = false, //是否展示右侧的布局
this.rightWidget, //右侧的布局
this.showDivider = true, // 是否显示下分割线
this.dividerColor = const Color.fromARGB(255, 212, 212, 212), // 下分割线颜色
this.obscureText = false, //是否隐藏文本,即显示密码类型
this.height = 50.0,
this.fillBackgroundColor, //整体的背景颜色
this.fillCornerRadius, //整体的背景颜色圆角
this.padding = EdgeInsets.zero, //整体布局的Padding
this.margin = EdgeInsets.zero, //整体布局的Margin
this.decoration, //自定义装饰
this.controller, //控制器
this.textInputAction = TextInputAction.done, //默认的行为是Done(完成)
this.onChanged, //输入改变回调
this.onSubmit, //完成行为的回调(默认行为是Done完成)
this.changeActionType = ClickType.none, //默认没有点击类型
this.changeActionMilliseconds = 500, //回调类型的时间戳(毫秒)
this.submitActionType = ClickType.none, //默认没有点击类型
this.submitActionMilliseconds = 500, //回调类型的时间戳(毫秒)
}) : super(key: key);
@override
Widget build(BuildContext context) {
//抽取的改变的回调
changeAction(value) {
onChanged?.call(formKey, value);
}
//抽取的提交的回调
submitAction(value) {
onSubmit?.call(formKey, value);
}
return Container(
margin: margin,
decoration: BoxDecoration(
color: fillBackgroundColor ?? Colors.transparent,
borderRadius: BorderRadius.all(Radius.circular(fillCornerRadius ?? 0)),
border: boxBorder,
),
padding: padding,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: height),
child: Column(
mainAxisAlignment: maxLines == null ? MainAxisAlignment.start : MainAxisAlignment.center,
children: [
TextField(
enabled: enabled,
style: style,
maxLines: maxLines,
keyboardType: inputType,
focusNode: focusNode,
obscureText: obscureText,
cursorWidth: cursorWidth,
cursorColor: DarkThemeUtil.multiColors(cursorColor, darkColor: ColorConstants.white),
autofocus: autofocus!,
controller: controller,
decoration: decoration ??
InputDecoration(
hintText: hintText,
hintStyle: hintStyle,
icon: showLeftIcon == true ? leftWidget : null,
border: border,
suffixIcon: showRightIcon == true ? rightWidget : null,
labelText: labelText,
errorText: errorText,
errorStyle: const TextStyle(color: Colors.red, fontSize: 11.5),
errorBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.red),
),
),
onChanged: changeActionType == ClickType.debounce
? debounce(changeAction, changeActionMilliseconds)
: changeActionType == ClickType.throttle
? throttle(changeAction, changeActionMilliseconds)
: changeAction,
onSubmitted: submitActionType == ClickType.debounce
? debounce(submitAction, submitActionMilliseconds)
: submitActionType == ClickType.throttle
? throttle(submitAction, submitActionMilliseconds)
: submitAction,
textInputAction: textInputAction,
),
showDivider == true
? Divider(
height: 0.5,
color: dividerColor!,
).marginOnly(top: errorText == null ? 0 : 10)
: const SizedBox.shrink(),
],
),
),
);
}
使用:
less
MyTextField(
key,
state.formData[key]!['value'],
hintText: state.formData[key]!['hintText'],
hintStyle: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.w500,
),
controller: state.formData[key]!['controller'],
focusNode: state.formData[key]!['focusNode'],
margin: EdgeInsets.only(left: 20, right: 20, top: marginTop),
showDivider: false,
fillBackgroundColor: DarkThemeUtil.multiColors(ColorConstants.white, darkColor: ColorConstants.darkBlackItem),
fillCornerRadius: 5,
padding: EdgeInsets.only(left: 16, right: paddingRight, top: 2.5, bottom: 2.5),
height: 50,
style: TextStyle(
color: DarkThemeUtil.multiColors(ColorConstants.tabTextBlack, darkColor: ColorConstants.white),
fontSize: 14.0,
fontWeight: FontWeight.w500,
),
inputType: textInputType,
textInputAction: textInputAction,
onSubmit: onSubmit,
changeActionType: ClickType.debounce,
changeActionMilliseconds: 1000,
onChanged: (formKey, value){
Log.d("回调的值:$value");
},
cursorColor: ColorConstants.tabTextBlack,
obscureText: state.formData[key]!['obsecure'],
errorText: errorText,
showLeftIcon: true,
showRightIcon: showRightIcon,
rightWidget: rightWidget,
leftWidget: Row(
children: [
MyAssetImage(leftIconRes, width: leftIconWidth, height: leftIconHeight),
const Spacer(),
Container(
color: ColorConstants.graye5,
width: 1,
height: 15,
)
],
).constrained(width: 30),
)
效果:
后记
到此我们就把常用的防抖与节流效果都封装好了,逻辑入口都在基本的扩展方法中,如果有逻辑变动只需要修改一处地方即可。项目原因最终效果图就不放了,实现的效果我相信大家都心里有数,很简单的小功能。
你可以直接用原始的扩展函数,也可以根据你自己的业务封装使用,需要注意的是不能全部默认都加上防抖或节流,最好是做成配置选项,因为不是每一个按钮都需要节流或防抖的。
有了节流和防抖的限制之后,对应用程序的稳定性有很大的好处,只是每个人的使用方式和习惯不同。
由于我本人是客户端出身,可能我个人对于封装控件的方式比较偏爱,一个是方便,另一个也是为了一些全局默认配置和后期改动,也方便后期方便找锚点用扩展函数进行指定功能扩展。
我写 Flutter 这段时间也看了一些其他人的代码,有的人喜欢纯原生的写法,不喜欢过度封装,哪里用加到哪,自己手动的加扩展方法。有的人喜欢把控件封装之后使用,每个开发者的使用习惯不同,其实大家根据各自的喜好选择不同的方式即可,都挺好的。
那么本期内容就到这里,如讲的不到位或错漏的地方,希望同学们可以评论区指出。
本文的代码已经全部贴出,部分没贴出的代码可以在前文中找到,我的 Flutter Demo 项目正在整理中,后期开源了会更新文章链接。
如果感觉本文对你有一点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力啦!
Ok,这一期就此完结。