从Flutter对Timer的管理看堆的应用

292 阅读5分钟

前言

在之前的文章《深入理解Flutter/Dart事件机制》我们提到Flutter中带有延时的计时器Timer是使用堆来管理的。原因嘛很容易就能想到,我们在添加Timer的时候每个定时器都有可能有着不同的延时。同时我们显然期望这些定时器按照延时的长短先后触发。也就是说加入的顺序是随机的,但是取出的顺序却是以延时为优先级的,延时最短的最先被取出。而堆正好可以满足这样的需求。

如果日常工作中不经常接触的话,我们对堆的理解和应用可能只会局限在堆排序或其他一些算法题上。而Flutter使用堆来管理定时器则为我们提供了一个难得的机会来学习堆的实际应用。本文会结合源码来做解析。大家可以先问自己一个问题,如果让你来设计实现管理定时器的堆,你会如何做呢?(万一面试的时候被问到呢。)

本文不会介绍关于堆的基础知识,有需要的同学可以先搜索一下相关文章预先熟悉一下堆。

堆的应用

Flutter中负责管理定时器的类叫_TimerHeap。不过在了解堆之前我们需要先了解堆内会存储的元素是啥,也就是定时器_Timer

_Timer

_Timer的一些属性,这里只列出堆管理需要用到的属性

//这就是管理定时器的堆了,是个静态变量
static final _heap = new _TimerHeap();
//唤醒时间
int _wakeupTime;
//对于有延时的定时器来说,这个属性代表的是其存贮在数组中的下标
Object? _indexOrNext;
// 每个定时器都有唯一的id
int _id;

注意第三个属性,Object? _indexOrNext;对于不同的场景这个属性会有不同的意义。

  • 如果这个定时器是无延时的,那么这个属性是指向下一个定时器的“引用”;
  • 如果这个定时器是有延时的,那么这个属性放的是此定时器在存储数组中的下标。

像这种“一鱼两吃”的做法在Flutter中其实是很普遍的。大家在遇到这种属性名中带个or的,留个心眼就是了。

普通的_Timer构造函数:

_Timer._internal(
  this._callback, 
  this._wakeupTime, 
  this._milliSeconds, 
  this._repeating)
  : _id = _nextId();

普通的定时器在构造的时候就会被赋予一个id,这个id是自增的。后构造的定时器会有更大的id。

哨兵定时器构造函数:

_Timer._sentinel()
: _callback = null,
_wakeupTime = 0,
_milliSeconds = 0,
_repeating = false,
_indexOrNext = null,
_id = -1;

这个哨兵定时器只是用来填充定时器存储数组的。

要用到堆的话肯定需要涉及到对堆中元素大小的比较。那么定时器是如何比较大小的呢?

int _compareTo(_Timer other) {
    int c = _wakeupTime - other._wakeupTime;
    if (c != 0) 
        return c;
    return _id - other._id;
}

从上述代码可见,比较定时器首先会比较唤醒时间。如果唤醒时间一样的话,会接着比较id。这是可以理解的,如果两个定时器唤醒时间相同的话,我们显然希望这两个定时器会按照添加顺序来运行。

了解完定时器,接下来就可以看看管理定时器的堆怎么做了。

_TimerHeap

既然是个堆,那么一个堆该有的东西_TimerHeap肯定也会有了。

底层存储肯定得有个数组吧

List<_Timer> _list;
int _used = 0;

属性_used用来计数,表示当前堆内有多少个计时器。

父子节点下标的换算也得有吧

//父节点下标
static int _parentIndex(int index) => (index - 1) ~/ 2;
//左孩子下标
static int _leftChildIndex(int index) => 2 * index + 1;
//右孩子下标
static int _rightChildIndex(int index) => 2 * index + 2;

注意父节点坐标的计算,Dart做整除是~/, /可不是整除,区别看下面的例子

assert(5 / 2 == 2.5);
assert(5 ~/ 2 == 2);

交换元素也是需要的

void _swap(_Timer first, _Timer second) {
    var newFirstIndex = second._indexOrNext as int;
    var newSecondIndex = first._indexOrNext as int;
    first._indexOrNext = newFirstIndex;
    second._indexOrNext = newSecondIndex;
    _list[newFirstIndex] = first;
    _list[newSecondIndex] = second;
}

这里也能看到,_indexOrNext里放的就是_Timer在数组中的下标。

_TimerHeap的构造函数

_TimerHeap([int initSize = 7])

: _list = List<_Timer>.filled(initSize, _Timer._sentinelTimer);

构造的时候可以给一个初始大小,数组内会填充哨兵计时器。

那么接下来就是关键点了。_TimerHeap的存取操作。 添加定时器的操作,那必然是把新元素先放在数组的末尾,然后再做堆化操作。

void add(_Timer timer) {
    if (_used == _list.length) {
        _resize();
    }
    var index = _used++;
    timer._indexOrNext = index;
    _list[index] = timer;
    _bubbleUp(timer);
}

从代码可见和我们的猜测差不多,首先是如果数组已经存满的话要扩容,然后新计时器放在数组末尾,堆化操作必然就是_bubbleUp调用了。

我们先把_bubbleUp放一边,接着看移除操作,对于移除我们也能想到的操作自然是把最后一个节点移动到要移除的位置,然后再做堆化操作。

void remove(_Timer timer) {
    _used--;
    ...
    var last = _list[_used];
    if (!identical(last, timer)) {
        var index = timer._indexOrNext as int;
        last._indexOrNext = index;
        _list[index] = last;
        if (last._compareTo(timer) < 0) {
            _bubbleUp(last);
        } else {
            _bubbleDown(last);
        }
    }
    _list[_used] = _Timer._sentinelTimer;
    timer._indexOrNext = null;
}

这里的堆化操作就稍微复杂了一些,如果换过来的节点"小于"要删除的节点,则调用_bubbleUp做堆化,这和新增操作里的堆化调用的是相同函数。但是如果过来的节点"大于"要删除的节点,则调用一个新函数_bubbleDown做堆化。

所以不管是添加还是删除,堆化操作就是两个函数_bubbleUp_bubbleDown。这两个函数从名字上就能看出来,_bubbleUp就是往上浮呗,也就是看看要不要和父节点去交换:

void _bubbleUp(_Timer timer) {
    while (!isFirst(timer)) {
        _Timer parent = _parent(timer);
        if (timer._compareTo(parent) < 0) {
            _swap(timer, parent);
        } else {
            break;
        }
    }
}

代码比较简单,比父节点小就和父节点交换,直到交换到顶(根节点)。

void _bubbleDown(_Timer timer) {
    while (true) {
        var leftIndex = _leftChildIndex(timer._indexOrNext as int);
        var rightIndex = _rightChildIndex(timer._indexOrNext as int);
        _Timer newest = timer;
        //先和左孩子比较,newest取父节点和左孩子之间小的
        if (leftIndex < _used && _list[leftIndex]._compareTo(newest) < 0) {
            newest = _list[leftIndex];
        }
        //再和右孩子比较,newest取父节点和左右孩子之间最小的
        if (rightIndex < _used && _list[rightIndex]._compareTo(newest) < 0) {
            newest = _list[rightIndex];
        }
        if (identical(newest, timer)) {
            // 父节点是最小的,沉不下去了,堆化完成
            break;
        }
        // 和比较小的孩子节点交换,父节点下沉,接着下一轮循环
        _swap(newest, timer);
    }
}

_bubbleDown稍微复杂一些,需要父节点和左右孩子节点做三角比较,找出最小的那个,父节点自己是最小的话那就不动,否则就和左右孩子节点里最小的那个做交换,直到"沉底"为叶子节点。

上浮只有一条路,而下沉则可能有两条路可选,所以会复杂一些。

那么为什么添加的时候只需要调用_bubbleUp就可以堆化了呢?很简单,新添加的计时器是放在数组末尾的,初始位置肯定是个叶子节点,那么做堆化就只有一个方向,往上浮。

而删除一个计时器,会把最后一个节点拿过来填在被删除的位置上。那这个填过来的节点有可能在半山腰上,那它就会有两个方向可调整,如果比原来的节点重,那就往下沉,比原来的节点轻的话就往上浮。

删除操作有一个特例,也是最常用的一个操作,那就是取堆顶的元素,此时我们可以想象,从尾部拿过来的元素放在了堆顶,那堆化就只有向下一个方向了,只需要_bubbleDown就可以了。

通常我们使用堆的时候只需要从堆顶删除节点,但是定时器的堆又做的稍微复杂了一些,可以随时删除任意节点,为啥?因为定时器是可以取消的,当我们调用Timer.cancel(),就是要从堆中把这个定时器给移除掉。

这里也能看出为啥定时器要附带_indexOrNext来保存其在数组中的下标。看似维护起来很麻烦,但是省去了遍历数组寻找定时器的步骤,可以提高效率。

以上就是对_TimerHeap一些分析。看似比较简单,但实际实现还会有很多需要考虑的细节。还是那个问题,这个堆如果让你来实现,你能考虑到多少个点?

最后,顺手实现一个Dart版的堆排序吧。

堆排序

前面就都是常规操作

void main() {
  List<int> array = [8,5,3,9,4,2,6,7,1];
  sort(array);
}
//排序
void sort(List<int> array) {
  // 1. 全员堆化
  for (int i=array.length~/2-1; i>=0; i--) {
    adjustHeap(array, i, array.length);
  }
  // 2.从右向左交换并堆化
  for (int j=array.length-1; j>0; j--) {
    swap(array, 0, j);
    adjustHeap(array, 0, j);
  }

}
//交换
void swap(List<int> array, int i, int j) {
  var tmp = array[i];
  array[i] = array[j];
  array[j] = tmp;
}
// 下标转换
int leftChildIndex(int parentIndex) => parentIndex*2+1;
int rightChildIndex(int parentIndex) => parentIndex*2+2;
int parentIndex(int childIndex) => (childIndex-1)~/2;

重点看下堆化操作:

void adjustHeap(List<int> array,int parentIndex ,int length) {
  while(true) {
    int left = leftChildIndex(parentIndex);
    int right = rightChildIndex(parentIndex);
    int des = parentIndex;
    if (left < length && array[left] > array[des]) {
      des = left;
    }
    if (right < length && array[right] > array[des]) {
      des = right;
    }
    if (des == parentIndex) {
      break;
    }
    swap(array, parentIndex, des);
    parentIndex = des;
  }
}

这个堆化函数adjustHeap完全是依照_bubbleDown的实现改造过来的。要注意的一点是_TimerHeap是个小顶堆,而排序用到的是大顶堆。至于排序的时候做堆化为什么只需要"下沉"而不需要“上浮“,这个问题留给大家去思考吧。

(全文完)