1. 什么是节流
在 《Flutter 组件集录 | 后悔药 UndoHistory》 一文中,我们瞄了一下 UndoHistory 的源码,发现其中有节流 Throttled 操作。节流的价值在于:
对于频繁触发的事件,在一定时间间隔之内,忽略其间的事件。
从而达到限制函数在一定时间内的执行次数的功能。
UndoHistory 组件的应用场景中,输入事件 是频繁触发的,而每次触发都需要将变更记录到历史栈中。Throttled 可以降低这一动作触发的频率,从而节约系统资源。
在 【Flutter 异步编程 - 拾】 | 探索 Stream 的转换原理与拓展 一文中介绍过基于 Stream 实现的防抖和节流,其中的这张图很好地展示出移动事件中,防抖和节流的效力:
2. Flutter 源码中的节流
首先看到其中定义了两个函数类型:
_Throttleable
是一个 T 泛型入参的函数,也就是事件来临时的处理函数;_Throttled
是一个 T 泛型入参,返回 Timer 对象的函数。
/// A function that can be throttled with the throttle function.
typedef _Throttleable<T> = void Function(T currentArg);
/// A function that has been throttled by [_throttle].
typedef _Throttled<T> = Timer Function(T currentArg);
最核心的方法是下面的 _throttle
:
- 它接收一个时长 duration 和 处理函数 function;
- 它返回
_Throttled
类型,也就是一个返回 Timer 的函数;
可以看到 return
关键字后返回一个闭包作为_Throttled
函数对象 ;这个函数会校验 timer 是否激活,激活状态下会返回 timer 。这样就不会创建 Timer 触发 function 函数,从而保证在 duration 之内只触发一次 function 函数:
_Throttled<T> _throttle<T>({
required Duration duration,
required _Throttleable<T> function,
}) {
Timer? timer;
late T arg;
return (T currentArg) {
arg = currentArg;
if (timer != null && timer!.isActive) {
return timer!;
}
timer = Timer(duration, () {
function(arg);
timer = null;
});
return timer!;
};
}
3. UndoHistory 中使用节流
在 UndoHistory 的状态类初始化时,会触发 _throttle
方法,创建一个 _Throttled
函数对象,其中 function 就是需要被节流的函数。这里是向栈中添加历史记录和更新状态的操作:
在 _push
方法中,_throttledPush
函数触发,加入新元素。该函数返回计时器对象为 _throttleTimer
赋值。该计时器可以用于提前结束处理函数:
比如现在栈中是 a,b,c,d
元素, 当 e 元素进入时立刻 undo,那么 e 操作还没来及入栈,撤销一步,Timer 才结束触发 function。就会出现你撤销一步,但 e 操作在栈顶。所以 undo 时需要取消之前的计时器:
late final _Throttled<T> _throttledPush;
Timer? _throttleTimer;
void _push() {
// 略...
_lastValue = nextValue;
// 触发函数,加入元素
_throttleTimer = _throttledPush(nextValue);
}
@override
void undo() {
///略...
if (_throttleTimer?.isActive ?? false) {
_throttleTimer?.cancel(); // Cancel ongoing push, if any.
_update(_stack.currentValue);
///略...
}
这就是 UndoHistory 中节流的实际使用的代码处理,是不是还挺有意思。
4. 我们如何自己使用节流
可能 UndoHistory 的处理逻辑稍微多了一点,下面用一个极其简单的案例,让你明白 _throttle
的使用。场景是这样的:
在 100 次的遍历中,每 16ms 会在 pool 中加入当前索引值:
void main() async {
List<int> pool = [];
for (int i = 0; i < 100; i++) {
await Future.delayed(Duration(milliseconds: 16));
pool.add(value);
}
print(pool);
}
现在想要限制 pool 加入的规则:相邻两个元素添加的时长不小于 200 ms。此时就可以通过节流来实现:
将 pool.add 换成 _throttledPush
,_throttle
中将 pool.add
作为被节流的触发函数即可:
void main() async {
List<int> pool = [];
_Throttled<int> _throttledPush = _throttle<int>(
duration: Duration(milliseconds: 200),
function: pool.add,
);
for (int i = 0; i < 100; i++) {
await Future.delayed(Duration(milliseconds: 16));
_throttledPush(i);
}
await Future.delayed(Duration(milliseconds: 200));
print(pool);
}
这样,就可以达到节流的目的,减少 pool.add
事件触发的频率,你学废了吗?
5. 节流在实际中的应用
节流可以用于任何高频事件中 限制操作的频率
,比如 拖动手势 时的绘制或图形变换;窗口调整大小 引发的事件;输入框实时验证 等;
最近生命游戏中,拖拽和绘制事件频繁触发,限制操作频率采用的是记录时间戳,两次之间小于指定时长,不触发处理函数。这样会导致在快速交互时,最后一次事件被忽略,而导致如下的违和感。
这个场景就非常适合 _throttle
节流,因为最后一次操作,只要不主动取消计时器,就一定对触发。下一篇的生命游戏,将会先介绍一下节流的实践使用,敬请期待 ~