在应用开发过程中经常会遇到因用户短时间内连续多次重复触发某个事件,导致对应事件的业务逻辑重复执行而出现业务异常,此时就需要对事件进行节流或者防抖处理避免出现业务异常。本文将介绍在 Flutter 开发中如何实现节流和防抖的统一封装。
首先我们来了解一下节流和防抖的定义,以及在什么场景下需要用到节流和防抖。
节流是在事件触发时,立即执行事件的目标操作逻辑,在当前事件未执行完成时,该事件再次触发时会被忽略,直到当前事件执行完成后下一次事件触发才会被执行。
按指定时间节流是在事件触发时,立即执行事件的目标操作逻辑,但在指定时间内再次触发事件会被忽略,直到指定时间后再次触发事件才会被执行。
防抖是在事件触发时,不立即执行事件的目标操作逻辑,而是延迟指定时间再执行,如果该时间内事件再次触发,则取消上一次事件的执行并重新计算延迟时间,直到指定时间内事件没有再次触发时才执行事件的目标操作。
节流多用于按钮点击事件的限制,如数据提交等,可有效防止数据的重复提交。防抖则多用于事件频繁触发的场景,如滚动监听、输入框输入监听等,可实现滚动停止间隔多久后触发事件的操作或输入框输入变化停止多久后触发事件的操作。
先看一下最终封装完成后的使用示例及效果,实现计数器功能,对点击分别进行节流、指定时间节流、防抖限制。
/// 事件目标操作
void increase() {
count += 1;
}
/// 节流
() async{
await Future.delayed(Duration(seconds: 1));
increase();
}.throttle()
/// 指定时间节流
increase.throttleWithTimeout(2000)
///防抖
increase.debounce(timeout: 1000)
increase
是事件目标操作,即这里的数字加一,分别进行节流、指定时间节流、防抖限制,调用封装的 throttle
、 throttleWithTimeout
、 debounce
扩展方法实现。其中节流为了模拟事件耗时操作增加了一秒延迟。
实现效果:
接下来将通过从单事件的节流/防抖限制到封装抽取一步一步实现对节流和防抖的通用封装。
首先来看一下节流的简单实现,前面讲了节流的原理,就是在事件未执行完成时忽略事件的再次触发,根据这个原理添加一个变量标识事件是否可执行,默认为 true 可执行,当事件执行时设置为 false,执行完成后重新设置为 true,当标识为 false 时忽略事件,这样就实现了对事件的节流,代码实现如下:
Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}
/// 事件是否可执行
bool enable = true;
void throttleIncrease() async{
if(enable){
enable = false;
await increase();
enable = true;
}
}
添加一个 enable
变量标识事件是否可执行。这里为了模拟事件的耗时操作在 increase
方法里添加了一秒的延时。这样就简单实现了事件的节流,运行看一下效果:
通过上面的简单代码实现了对事件的节流,但是只对某一个确定的事件有效,如果还有其他事件也需要实现节流效果那就得重新写一遍上面的代码,这样很明显是不科学的。那么我们就需要对上面的代码进行封装,使其能应用到多个事件上。
上面的代码事件调用是直接写在节流的实现里的,那么将事件进行抽象,把事件的具体执行方法抽取为一个参数,这样就能满足多个事件的节流控制了,实现如下:
bool enable = true;
void throttle(Function func) async{
if(enable){
enable = false;
await func();
enable = true;
}
}
/// 使用
throttle(increase);
throttle(decrease);
经过前面的封装后,确实可以对多个事件进行节流限制,但在实际开发过程中发现有两个问题:
问题一:所有事件的节流控制使用的是一个 enable
变量控制,这样就会导致在事件 1 执行过程中事件 2 会被忽略,这显然不是我们想要的效果。
举一个典型的场景,在 Flutter 中跳转新页面并获取页面的返回值,此时实现如下:
Future toNewPage() async{
var result = await Navigator.pushNamed(context, "/newPage");
/// do something
}
此时如果对 toNewPage
进行节流控制,并且跳转的页面里的按钮事件也做了同样的节流控制,就会导致新界面的按钮事件无法执行,因为我们节流用的是同一个变量进行控制,而 toNewPage
需要接收页面返回值,事件未执行完一直在等待页面返回值导致 enable
变量一直为 false 所以新界面的点击事件就会被忽略。
问题二:当事件的执行报错,会导致后续所有使用该方式节流的事件都不会被触发。原理跟上面的一样,当事件执行报错时不会继续向下执行,此时 enable
无法赋值为 true,一直为 false 从而导致后续事件都不会被执行。
怎么解决上面两个问题呢?首先解决简单的问题二,问题二很好解决,加一个 try-catch-finally 即可:
void throttle(Function func) async{
if(enable){
enable = false;
try {
await func();
} catch (e) {
rethrow;
} finally {
enable = true;
}
}
}
在方法调用上增加 try-catch-finally ,在 finally 中将 enable 设置为 true,在 catch 中不对异常做任何处理,使用 rethrow
将异常重新抛出去即可,这样就解决了问题二。
再来看问题一,既然使用同一个 enable 会有问题,那就使用多个变量来控制,每个事件用一个 enable 变量来控制,实现如下:
Map<String, bool> _funcThrottle = {};
void throttle(Function func) async{
String key = func.hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if(enable){
_funcThrottle[key] = false;
try {
await func();
} catch (e) {
rethrow;
} finally {
_funcThrottle.remove(key);
}
}
}
使用一个 Map 来存放事件的 enable 变量,使用事件方法的 hashCode 作为事件的 key,这样就解决了问题一。
但实际开发过程中发现还是有问题,封装后的 throttle
方法在使用时有下面两种方式:
/// 1
Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}
throttle(increase);
/// 2
throttle(() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
});
使用第一种方式时是没有问题,但是第二种发现就有问题,节流不起作用了,为什么呢?是因为第二种使用的是匿名函数或者叫 lambda 函数,这种方式每次触发事件相当于都会重新创建一个函数参数传入 throttle
就会导致 func.hashCode.toString()
获取的值每次都不一样,所以导致节流无效。
那这种情况又该怎么解决呢?首先想到的是给 throttle
增加一个参数 key ,不同的事件传入不同的 key 值。这样确实能解决问题,但是增加了使用成本,每个事件都得传入一个 key,对于已有代码改造也相对来说不方便。于是想到了另外一种解决办法,也是本方案最终实现的方法,用一个对象来代理执行事件,具体实现如下:
class FunctionProxy {
static final Map<String, bool> _funcThrottle = {};
final Function? target;
FunctionProxy(this.target);
void throttle() async {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
try {
await target?.call();
} catch (e) {
rethrow;
} finally {
_funcThrottle.remove(key);
}
}
}
}
创建一个方法的代理类,在该类里实现 throttle
,此时使用的 key 为该代理类的 hashCode , 使用如下:
onPressed: FunctionProxy(increase).throttle
/// or
onPressed: FunctionProxy(() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}).throttle
这样最终返回给 onPressed
的是 FunctionProxy
的 throttle
函数,而 throttle
是一个确定的函数,这就最终解决了上述问题。
但是使用时每次都创建 FunctionProxy
类,看着不太友好,给 Function 增加一个 throttle
方法,让使用更加简单:
extension FunctionExt on Function{
VoidCallback throttle(){
return FunctionProxy(this).throttle;
}
}
/// 使用
Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}
onPressed: increase.throttle()
onPressed: () async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}.throttle()
节流是事件执行完后才允许下次事件执行,指定时间节流是事件开始执行指定时间后允许下次事件执行,使用延迟指定时间后将 enable 设置为 true 来实现,代码如下:
class FunctionProxy {
static final Map<String, bool> _funcThrottle = {};
final Function? target;
final int timeout;
FunctionProxy(this.target, {int? timeout}) : timeout = timeout ?? 500;
void throttleWithTimeout() {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
Timer(Duration(milliseconds: timeout), () {
_funcThrottle.remove(key);
});
target?.call();
}
}
}
增加了 timeout 参数,即指定的节流时间,使用 Timer 延迟指定时间后将 key 从 _funcThrottle 中移除,这里没有加 try-catch ,因为事件异常并不会影响 Timer 的执行,同样的为 Function 增加一个 throttleWithTimeout
扩展:
extension FunctionExt on Function{
VoidCallback throttleWithTimeout({int? timeout}){
return FunctionProxy(this, timeout: timeout).throttleWithTimeout;
}
}
使用:
Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}
onPressed: increase.throttleWithTimeout(timeout: 1000)
onPressed: () async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}.throttleWithTimeout(timeout: 1000)
防抖是在事件触发指定时间内该事件未再次触发时再执行,同样可以使用 Timer 来实现:
class FunctionProxy {
static final Map<String, Timer> _funcDebounce = {};
final Function? target;
final int timeout;
FunctionProxy(this.target, {int? timeout}) : timeout = timeout ?? 500;
void debounce() {
String key = hashCode.toString();
Timer? timer = _funcDebounce[key];
timer?.cancel();
timer = Timer(Duration(milliseconds: timeout), () {
Timer? t = _funcDebounce.remove(key);
t?.cancel();
target?.call();
});
_funcDebounce[key] = timer;
}
}
同样增加 timeout 参数用于指定防抖的时间间隔,与节流不同的是防抖的 Map 的 value 不是 bool 类型而是 Timer 类型,当事件触发时创建一个 Timer 设置延迟 timeout 后执行,并将 Timer 添加到 Map 中,如果在 timeout 时间内事件再次触发则将 Map 中的 Timer 取消再重新创建新的 Timer,从而实现防抖效果。
同样为 Function 添加 debounce
防抖扩展方法:
extension FunctionExt on Function{
VoidCallback debounce({int? timeout}){
return FunctionProxy(this, timeout: timeout).debounce;
}
}
使用:
Future increase() async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}
onPressed: increase.debounce(timeout: 1000)
onPressed: () async{
count += 1;
await Future.delayed(const Duration(seconds: 1));
}.debounce(timeout: 1000)
至此节流、防抖的封装就完成了,完整代码如下:
extension FunctionExt on Function{
VoidCallback throttle(){
return FunctionProxy(this).throttle;
}
VoidCallback throttleWithTimeout({int? timeout}){
return FunctionProxy(this, timeout: timeout).throttleWithTimeout;
}
VoidCallback debounce({int? timeout}){
return FunctionProxy(this, timeout: timeout).debounce;
}
}
class FunctionProxy {
static final Map<String, bool> _funcThrottle = {};
static final Map<String, Timer> _funcDebounce = {};
final Function? target;
final int timeout;
FunctionProxy(this.target, {int? timeout}) : timeout = timeout ?? 500;
void throttle() async {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
try {
await target?.call();
} catch (e) {
rethrow;
} finally {
_funcThrottle.remove(key);
}
}
}
void throttleWithTimeout() {
String key = hashCode.toString();
bool enable = _funcThrottle[key] ?? true;
if (enable) {
_funcThrottle[key] = false;
Timer(Duration(milliseconds: timeout), () {
_funcThrottle.remove(key);
});
target?.call();
}
}
void debounce() {
String key = hashCode.toString();
Timer? timer = _funcDebounce[key];
timer?.cancel();
timer = Timer(Duration(milliseconds: timeout), () {
Timer? t = _funcDebounce.remove(key);
t?.cancel();
target?.call();
});
_funcDebounce[key] = timer;
}
}
完成对节流、防抖的封装后,我们还可以对点击组件进行封装,这样不管是对现有 Flutter 代码还是新代码增加节流、防抖功能都会更加的简单。比如对 GestureDetector
组件可做如下封装:
enum ClickType {
none,
throttle,
throttleWithTimeout,
debounce
}
class ClickWidget extends StatelessWidget {
final Widget child;
final Function? onTap;
final ClickType type;
final int? timeout;
const ClickWidget(
{Key? key,
required this.child,
this.onTap,
this.type = ClickType.throttle,
this.timeout})
: super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: child,
onTap: _getOnTap(),
);
}
VoidCallback? _getOnTap() {
if (type == ClickType.throttle) {
return onTap?.throttle();
} else if (type == ClickType.throttleWithTimeout) {
return onTap?.throttleWithTimeout(timeout: timeout);
}else if (type == ClickType.debounce) {
return onTap?.debounce(timeout: timeout);
}
return () => onTap?.call();
}
}
增加 type,用于指定节流、指定时间节流、防抖或者不限制,分别调用对应的方法。默认为节流,可根据项目实际需求设置默认方式或对项目中使用到的其他点击组件进行封装,经过封装后,修改已有代码增加默认限制功能就可以直接替换组件名字而无需改动其他代码实现事件限制的功能。
使用:
/// before
GestureDetector(
child: Text("xxx"),
onTap: increase,
)
/// after
ClickWidget(
child: Text("xxx"),
onTap: increase,
)
ClickWidget(
child: Text("指定时间节流"),
type: ClickType.throttleWithTimeout,
timeout: 1000,
onTap: increase,
)
ClickWidget(
child: Text("防抖"),
type: ClickType.debounce,
timeout: 1000,
onTap: increase,
)
开发过程中,大部分的事件处理都需要进行节流或者防抖限制,防止事件的重复处理导致业务的异常,经过封装后不管是对老项目的优化改造还是新项目的开发,节流和防抖的处理都将变得更简单快捷。
源码:flutter_app_core[1]
[1]
flutter_app_core: https://github.com/loongwind/flutter_app_core