Flutter 知识集锦 | 跟源码学节流 Throttled

952 阅读4分钟

1. 什么是节流

《Flutter 组件集录 | 后悔药 UndoHistory》 一文中,我们瞄了一下 UndoHistory 的源码,发现其中有节流 Throttled 操作。节流的价值在于:

对于频繁触发的事件,在一定时间间隔之内,忽略其间的事件。
从而达到限制函数在一定时间内的执行次数的功能。

UndoHistory 组件的应用场景中,输入事件 是频繁触发的,而每次触发都需要将变更记录到历史栈中。Throttled 可以降低这一动作触发的频率,从而节约系统资源。


【Flutter 异步编程 - 拾】 | 探索 Stream 的转换原理与拓展 一文中介绍过基于 Stream 实现的防抖和节流,其中的这张图很好地展示出移动事件中,防抖和节流的效力:

56.gif


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 就是需要被节流的函数。这里是向栈中添加历史记录和更新状态的操作:

image.png

_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 事件触发的频率,你学废了吗?

image.png


5. 节流在实际中的应用

节流可以用于任何高频事件中 限制操作的频率,比如 拖动手势 时的绘制或图形变换;窗口调整大小 引发的事件;输入框实时验证 等;

最近生命游戏中,拖拽和绘制事件频繁触发,限制操作频率采用的是记录时间戳,两次之间小于指定时长,不触发处理函数。这样会导致在快速交互时,最后一次事件被忽略,而导致如下的违和感。

image.png


这个场景就非常适合 _throttle 节流,因为最后一次操作,只要不主动取消计时器,就一定对触发。下一篇的生命游戏,将会先介绍一下节流的实践使用,敬请期待 ~