11 KiB
yx_async_throttle_flutter
一个健壮的 Flutter 异步任务防抖(Debounce)与节流(Throttle)工具类。
它完美结合了 时间策略(Throttle/Debounce)与 异步任务锁(Async Task Locking),旨在解决复杂场景下(如弱网环境或用户快速连点)的重复执行问题。
功能特性
- 双重保护机制:
- 时间策略: 防止短时间内快速重复点击(基于 easy_debounce)。
- 异步锁: 即使时间间隔已过,如果上一个异步任务(如网络请求)尚未完成,也会阻止新任务执行。
- 节流模式 (Throttle - 默认): 立即执行,并在指定时间内忽略后续调用。非常适合“提交”、“登录”等按钮。
- 防抖模式 (Debounce): 延迟执行,直到停止调用一段时间后才执行。非常适合“搜索框”、“实时输入校验”。
- 内存安全: 在防抖模式下正确管理
Completer,防止因任务被取消导致的await永久挂起和内存泄漏。 - 简单易用: 单例调用,API 简洁。
快速开始 (Getting Started)
在你的 pubspec.yaml 文件中添加依赖:
dependencies:
yx_async_throttle_flutter:
git:
url: https://gitea.23544.com/wangyang/yx_async_throttle_flutter.git
ref: 1.0.0
使用指南 (Usage)
1. 基础节流 (按钮防暴力点击)
适用于“提交订单”、“登录”、“保存”等场景。 即使网络很慢(请求耗时 > 500ms),或者用户手速很快,该函数也能确保在上一个请求完成且时间间隔满足之前,不会重复执行。
import 'package:yx_async_throttle_flutter/async_throttle.dart';
void onSubmit() async {
// 使用唯一的 tagId 标识这个动作
await AsyncThrottle.instance.execute(
'submit_order_btn',
() async {
// 你的异步逻辑(例如 API 调用)
print('正在提交订单...');
await Future.delayed(Duration(seconds: 2)); // 模拟耗时网络请求
print('订单提交完成!');
},
duration: Duration(milliseconds: 500), // 默认 300ms
enableDebounce: false, // 默认 false (节流模式)
);
}
1.1 UI 回调最佳实践(不影响 UI / 不抛到全局)
像 onTap / onPressed / PopupMenuItem.onTap 这类回调通常不能 await(即使写成 async 也经常没人等待它)。
为了避免 onExecute 内部异常变成 unhandled async error 影响 UI,建议用 executeSafe:
PopupMenuItem(
onTap: () {
AsyncThrottle.instance.executeSafe(
'withdraw_task',
controller.withdrawTask,
onError: (e, s) {
// 这里做日志/Toast/上报,不影响 UI
},
);
},
child: ...,
)
2. 防抖模式 (搜索或输入)
适用于搜索框、实时输入联想等场景。只有当用户停止输入指定时间后,才会触发逻辑。
import 'package:yx_async_throttle_flutter/async_throttle.dart';
void onSearchChanged(String query) async {
await AsyncThrottle.instance.execute(
'search_input',
() async {
// 只有当用户停止输入 500ms 后才会执行
print('搜索内容: $query');
await fetchSearchResults(query);
},
duration: Duration(milliseconds: 500),
enableDebounce: true, // 启用防抖模式
);
}
3. 检查执行状态
你可以随时检查某个任务是否正在执行中:
// 检查指定 tagId 是否有任务正在执行
bool isRunning = AsyncThrottle.instance.isExecuting('submit_order_btn');
if (isRunning) {
print('订单正在提交中,请稍候...');
}
4. 清除锁 (可选)
在极少数情况下(例如用户退出登录时),你可能需要强制清除所有内部锁。
AsyncThrottle.instance.clearAllLocks();
⚠️ 注意:
clearAllLocks()仅清除异步锁状态,不会影响 EasyThrottle/EasyDebounce 的内部时间窗口。
API 参考
execute 方法
Future<void> execute(
String tagId,
Future<void> Function() onExecute, {
Duration duration = const Duration(milliseconds: 300),
bool enableDebounce = false,
})
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tagId |
String |
必填 | 任务的唯一标识符 |
onExecute |
Future<void> Function() |
必填 | 要执行的异步函数 |
duration |
Duration |
300ms |
节流/防抖的时间间隔 |
enableDebounce |
bool |
false |
false=节流模式,true=防抖模式 |
executeSafe 方法
Future<void> executeSafe(
String tagId,
Future<void> Function() onExecute, {
Duration duration = const Duration(milliseconds: 300),
bool enableDebounce = false,
void Function(Object error, StackTrace stackTrace)? onError,
})
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tagId |
String |
必填 | 任务的唯一标识符 |
onExecute |
Future<void> Function() |
必填 | 要执行的异步函数 |
duration |
Duration |
300ms |
节流/防抖的时间间隔 |
enableDebounce |
bool |
false |
false=节流模式,true=防抖模式 |
onError |
Function? |
null |
错误回调,用于日志/上报 |
isExecuting 方法
bool isExecuting(String tagId)
检查指定 tagId 的任务是否正在执行中。
clearAllLocks 方法
void clearAllLocks()
强制清除所有异步锁和等待中的 Completer。
核心优势:为什么需要异步锁?
普通的 Throttle 库通常只根据 时间 来控制执行。
典型 Bug 场景:
设定节流时间为 500ms。
- 用户点击“提交”。请求发出,但网络很慢,耗时
2000ms。 - 在
600ms时(此时节流时间已过),用户因焦急再次点击“提交”。 - 问题: 普通 Throttle 库会认为 500ms 已到,允许执行第二次请求,导致 重复下单。
本库的解决方案:
yx_async_throttle_flutter 会检查 isExecuting 状态。虽然时间已过,但因为第一个请求(2000ms)还在运行,第二次点击会被直接拦截,从而完美防止重复提交。
💡 最佳实践: 如果在
onExecute函数内部再添加 Loading 蒙层(UI 阻断),配合本插件即可实现 “三层防护”(UI 蒙层 + 时间策略 + 异步锁),彻底杜绝任何异常场景下的重复操作。
更多使用示例
表单多按钮场景
当页面有多个按钮时,使用不同的 tagId 确保它们互不影响:
class OrderPage extends StatelessWidget {
void onSave() {
AsyncThrottle.instance.executeSafe('order_save', () async {
await saveOrder();
});
}
void onSubmit() {
AsyncThrottle.instance.executeSafe('order_submit', () async {
await submitOrder();
});
}
void onCancel() {
AsyncThrottle.instance.executeSafe('order_cancel', () async {
await cancelOrder();
});
}
}
配合 GetX/Provider 状态管理
class OrderController extends GetxController {
final isLoading = false.obs;
Future<void> submitOrder() async {
await AsyncThrottle.instance.execute(
'submit_order',
() async {
isLoading.value = true;
try {
await api.submitOrder();
Get.snackbar('成功', '订单提交成功');
} finally {
isLoading.value = false;
}
},
);
}
}
列表项点击防重
ListView.builder(
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
onTap: () {
// 使用 item.id 作为唯一标识,确保不同列表项互不影响
AsyncThrottle.instance.executeSafe(
'list_item_${item.id}',
() async {
await navigateToDetail(item);
},
);
},
);
},
)
实时输入验证(防抖)
TextField(
onChanged: (value) {
AsyncThrottle.instance.execute(
'username_validation',
() async {
final isAvailable = await api.checkUsername(value);
setState(() {
usernameError = isAvailable ? null : '用户名已被占用';
});
},
enableDebounce: true,
duration: const Duration(milliseconds: 500),
);
},
)
注意事项与最佳实践
✅ 推荐做法
-
使用有意义的 tagId
// ✅ 好 'submit_order_page_main' 'search_product_list' // ❌ 不好 'btn1' 'click' -
在 onTap/onPressed 中使用 executeSafe
// ✅ 推荐:不会影响 UI onTap: () { AsyncThrottle.instance.executeSafe('action', doSomething); } // ⚠️ 需要注意:异常可能变成 unhandled async error onTap: () async { await AsyncThrottle.instance.execute('action', doSomething); } -
合理设置 duration
- 按钮点击:
300ms ~ 500ms - 搜索防抖:
300ms ~ 800ms - 实时验证:
500ms ~ 1000ms
- 按钮点击:
⚠️ 注意事项
-
tagId 全局唯一:不同功能使用不同的 tagId,避免意外阻塞
-
单例模式:
AsyncThrottle.instance是全局单例,状态跨页面共享 -
页面销毁时的处理:如果需要在页面销毁时取消未完成的任务,可以在
dispose中调用:@override void dispose() { // 可选:清除该页面相关的锁 // 注意:这会清除所有锁,请谨慎使用 // AsyncThrottle.instance.clearAllLocks(); super.dispose(); } -
异常处理:
execute会传播异常,executeSafe会吞掉异常并调用onError
与其他方案的对比
| 特性 | yx_async_throttle | 普通 Throttle | 手动锁变量 |
|---|---|---|---|
| 时间策略 | ✅ | ✅ | ❌ |
| 异步锁 | ✅ | ❌ | ✅ |
| 弱网保护 | ✅ | ❌ | ✅ |
| 内存安全 | ✅ | ✅ | ⚠️ 需自行处理 |
| 防抖模式 | ✅ | ✅ | ❌ |
| 代码简洁 | ✅ | ✅ | ❌ |
测试覆盖
本库已通过 75 项 全面的单元测试,覆盖:
- ✅ 单例模式
- ✅ 节流/防抖核心功能
- ✅ 异常处理与传播
- ✅ 并发场景(100+ 次快速调用)
- ✅ 内存安全(无泄漏)
- ✅ 重入防护(无死锁)
- ✅ 边界条件(极端参数)
- ✅ 真实业务场景模拟
详细测试报告请参阅 TEST_REPORT.md。
常见问题 (FAQ)
Q: 为什么我的第二次点击没有执行?
A: 这正是本库的设计目的。检查以下两点:
- 是否在
duration时间窗口内? - 上一次任务是否还在执行中?(使用
isExecuting检查)
Q: 如何让同一按钮在不同页面独立工作?
A: 使用包含页面标识的 tagId:
'submit_order_${widget.pageId}'
Q: executeSafe 和 execute 有什么区别?
A:
execute: 异常会向外抛出,适合可以 await 的场景executeSafe: 异常被内部捕获,通过 onError 回调处理,适合 onTap 等无法 await 的场景
Q: clearAllLocks 后为什么还是不能立即执行?
A: clearAllLocks 只清除异步锁,EasyThrottle 的时间窗口仍然有效。需要等待时间窗口过期,或使用不同的 tagId。
其他信息
本库底层的时间策略依赖于 easy_debounce。
License
MIT License