前面《渲染计算》一文中,我们提到了对于长耗时的渲染计算的优化方案,其中便包括了将大的计算任务拆分为小任务的方式。

本文我们以在线表格为例子,详细介绍下如何对长耗时的计算进行任务拆解。

# 渲染引擎计算任务分片优化

在表格中,当数据发生更新变化时(可能是用户本身的操作,也可能是协作者),渲染引擎接收到数据变更,然后进行计算和更新渲染。流程如下:

  1. 渲染引擎监听表格数据的数据变化。
  2. 数据变化发生时,渲染引擎筛选出相关的,并对数据进行计算,转换为渲染引擎需要的数据。
  3. 根据计算后的数据,将表格内容绘制到 Canvas 画布中(收集 + 渲染)。

上述的步骤 2 中,渲染引擎计算均为同步计算,因此随着计算范围的增加,所需耗时会随之增长。

在这样的基础上,我们提出了将渲染引擎计算任务进行分片的方案。该方案主要优化的位置位于渲染引擎的计算过程,可减少在大范围、大表下的操作(如列宽调整、大范围选区的样式设置等)卡顿。

# 核心优化方案

本次渲染引擎计算任务分片的方案核心点在于:只进行可视区域的渲染计算,非可视区域的部分做异步计算。

如图,当一次数据变更发生时,渲染引擎会根据变更范围,将计算任务拆成两部分:可视区域和非可视区域的计算任务。

整个计算异步分片方案中,有以下几个核心设计点:

  1. 对于当前可视区域的部分,会进行同步的计算和渲染。
  2. 对于非可视区域的部分,会进行异步分片(约 50ms 为一次计算分片)。
  3. 异步计算时,会优先计算当前可视区域附近范围的部分区域。
  4. 异步计算过程中,如涉及当前可视区域的变动,会触发重新渲染;对于非可视区域部分的计算,不会触发重新渲染。
  5. 对多次的操作,未计算部分的区域会进行合并计算,可减少整体的计算量。

我们来看一下其中的待计算区域管理和异步任务管理的部分设计。

# 待计算区域管理

首先,我们提供了一个区域管理的能力,里面存储了未计算完成的区域。区域管理的能力需要满足:

  1. 区域生成:生成一个区域,包括行/列范围、计算任务的类型(分行/覆盖格/边框线等);
  2. 区域合并:对两个区域进行合并,并更新区域范围;
  3. 区域获取:根据提供的区域范围,获取该区域内的待计算任务;
  4. 区域更新:行/列变化快速更新区域范围。

由于渲染引擎计算的特殊性(大多数计算为按行计算),区域考虑以行为首要维度、列为次要维度的方式来管理,因此区域的设计大概为:

export type IAreaRange = {
  // 开始行 index
  rowStart: number;
  // 结束行 index
  rowEnd: number;
  // 列范围 [开始列 index, 结束列 index]
  colRanges: [number, number][];
  // 行范围的计算类型
  calculateTypes: CalculateType[];
};

# 区域合并

对于两个区域的合并,需要考虑相交和不相交的情况。不相交时不需要做合并,而对于相交的情况,还需要考虑合并的方式,主要考虑单边相交和包含关系的合并:

根据计算类型和列范围,且考虑边界场景下,两个区域合并后可能会转换为 1/2/3 个区域。

# 区域更新

由于区域本身依赖了行列位置,因此当行列发生改变时,比如插入/删除/隐藏/移动(即插入+删除)等场景,我们需要及时更新区域。以行变化为例:

同样需要考虑边界场景,比如删除区域覆盖了整个(或局部)区域等。

# 异步任务管理

异步任务管理的设计采用了十分简洁的方式(一个setTimeout任务)来实现:

class AsyncCalculateManager {
  // 每次执行任务的耗时
  static timeForEveryTask = 50;

  /**
   * 跑下一次任务
   */
  private runNext() {
    if (this.timer) clearTimeout(this.timer);

    this.timer = setTimeout(() => {
      // 一个任务跑 50 ms
      const calculateRange = this.calculateRunner.calculateNextTask(
        AsyncCalculateManager.timeForEveryTask
      );

      // 处理完之后,剩余任务做异步
      this.runNext();
    }, 10);
  }
}

上述代码可以看到,每个任务执行耗时满 50ms 后,会结束当前任务,并设置下一个异步任务。通过这样的方式,我们将每次计算任务控制在 50ms 左右,避免计算过久而导致的卡顿问题。

# 异步任务设计

对于异步任务,每次执行的时候,都需要:

  1. 根据当前可视区域,优先选出可视区域附近的任务来进行计算。
  2. 计算完成后,清理和更新待计算区域范围。

对于 1,可视区域内如果存在未计算的任务,会以符合阅读习惯的从上到下进行计算;如果可视区域内均已计算完毕,则会以可视区域为中心,向两边寻找未计算任务,并进行计算。如图:

异步任务计算时,还需要考虑计算的范围是否涉及可视区域,如果在可视区域内有计算任务,则需要进行渲染;如果计算任务处于非可视区域,则可以避免进行不必要的渲染。

# 异步计算的问题

将原本同步计算的任务拆成多个异步的计算任务,会面临一些问题包括:

  • 各个计算任务之间的顺序,比如边框线依赖覆盖格、行高依赖分行等;
  • 可视区域的锁定(避免跳动),由于行高会在滚动过程中进行异步计算和更新,可能会存在可视区域内容跳动(原本可见变为不可见)的问题;
  • 按坐标滚动(位置记忆、会议跟随等功能),考虑到行高会在滚动过程发生变化,按坐标滚动的相关功能会受到计算不准确等影响;
  • 边滚动边计算,如果更新不及时,可能导致一些组件的闪动和错位的问题;

解决方案大概是:确保每次计算后,行列宽高、可视区域、画布偏移等位置数据的一致性。要做到所有数据的一致性,需要对各个节点的流程做整体梳理,这里就不详细展开了。

# 结束语

本文以在线表格的分片计算为例,详细介绍了如何将大的计算任务拆分成小任务,减少了渲染等待的计算耗时。

我们常常会将产品和技术分离,认为技术需求占用了产品需求的人力,或是认为产品需求导致技术频繁变更。实际上,技术依附于产品而得以实现,产品亦是需要技术作为支撑。

每一个项目都需要不断地打磨,我们在产品快速向前迭代的同时,也需要实时关注项目本身的基础能力是否能满足产品未来的规划和方向。

部分文章中使用了一些网站的截图,如果涉及侵权,请告诉我删一下谢谢~
温馨提示喵