小程序高性能数据同步

小程序中,一个重要的性能环节就是同步 worker 进程数据到渲染进程。对于使用响应式来管理状态的情况,搜索社区实现,可以发现很多只是粗暴地递归遍历一下复杂对象,从而监听到数据变化。

Goldfish 中,同样使用了响应式引擎来管理状态数据。响应式天生的好处是:能够精确监听状态数据变化,然后生成最小化的数据更新对象。

举个例子,假如现在有一个响应式对象:

const observableObj = {
  name: '禺疆',
  address: {
    city: 'ChengDu',
  },
};

如果将 city 修改为 'HangZhou',那么很容易生成小程序中 setData 能直接使用的如下数据更新对象:

const updateObj = {
  'address.city': 'HangZhou',
};

当然,我们不可能数据每次变化的时候,就立即调用 setData 去更新数据,毕竟频繁更新是很耗性能的。所以,我们需要使用 setData$spliceData$batchedUpdates 批量更新。

批量时机

要做批量更新,第一步就是划分什么时间段内的更新算是一个批量。

很自然地,我们想到使用 setTimeout:在监听到数据更新请求时,使用 setTimeout 计时,搜集时间段内所有的数据更新需求,在计时结束时统一更新。

实际上,在移动端应当谨慎使用 setIntervalsetTimeout 计时,由于移动设备节省电量,很容易不准。比如 setInterval 设置时间间隔为 8 分钟,在移动设备上很容易出现时间间隔变长为 16 分钟左右。

既然 setTimeout 不行,那么我们第二个想到的可能是 requestAnimationFrame。很遗憾,小程序 worker 进程里面没有 requestAnimationFrame

最后,只剩下 Microtask 了。在小程序的 worker 进程里,我们可以借助 Promise.resolve() 来生成 Microtask,参考如下伪代码:

setData request 1
setData request 2
setData request 3

await Promise.resolve()

combine request 1 2 3
setData

实际上,由于响应式引擎的监听回调触发做了 Promise.resolve() 批量处理的逻辑,并且在我们的业务代码中,也很容出现 Microtask,数据更新请求(setData Request)并不是上述规规矩矩从上到下同步执行的,很可能在若干个 Microtask 中穿插请求。因此,上述搜集到的数据更新请求是不完整的,我们需要搜集到当前同步代码块同步代码块中产生的所有 Microtask 生成的数据更新请求:

export class Batch {
  private segTotalList: number[] = [];

  private counter = 0;

  private cb: () => void;

  public constructor(cb: () => void) {
    this.cb = cb;
  }

  // 每次有数据请求的时候,都调用一下 set。
  public async set() {
    const segIndex = this.counter === 0
      ? this.segTotalList.length
      : (this.segTotalList.length - 1);

    if (!this.segTotalList[segIndex]) {
      this.segTotalList[segIndex] = 0;
    }

    this.counter += 1;
    this.segTotalList[segIndex] += 1;

    await Promise.resolve();

    this.counter -= 1;

    // 同步块中最后一个 set 调用对应的 Microtask
    if (this.counter === 0) {
      const segLength = this.segTotalList.length;
      // 看看下一个 Microtask 触发前,是否还有新的更新请求进来。
      // 如果没有,说明更新请求稳定了,立即触发更新逻辑(this.cb)
      await Promise.resolve();
      if (this.segTotalList.length === segLength) {
        this.cb();
        this.counter = 0;
        this.segTotalList = [];
      }
    }
  }
}

优化更新对象

搞定更新时机之后,我们只需要在合适的时机,将积累的更新逻辑放置在 $batchedUpdates 中执行就好了。

但是在项目中发现,页面初始数据格式化的时候,如果数据结构很复杂,就很容易产生具有大量扁平 key 的更新对象,类似这样:

setData({
  'state.key1': 'xxx',
  'state.key2.key21': 'xx',
  'state.key3': 'xxx',
  ...
});

虽然更新对象看起来都很“最小化”,但是传递给渲染进程并还原成正常对象的过程中,肯定少不了耗时的 key 恢复处理。我们也实际测试过,如果直接调用 setData 去更新复杂数据对象,小程序还是比较流畅的,但是换成“最小化”更新对象之后,小程序有明显的卡滞。

因此,在构造更新数据时,应当设置一个 key 数量上限,如果超出上限,应当合并,形成 key 数量更小的更新对象。比如上述示例,可以合并成:

setData({
  state: {
    ...this.data.state,
    ...{
      key1: 'xxx',
      key2: {
        key21: 'xx',
      },
      key3: 'xxx',
    },
  },
  ...
});

我们可以把更新对象当做一棵树,比如上述例子,对应的树形结构如下:

       state
     /   |   \
 key1   key2  key3
         |
        key21

有多少个叶子节点,就会生成多少个 key。

在搜集更新请求阶段,可以顺手构造对应的树形结构。在更新时,按照深度优先的顺序遍历树,生成更新对象。遍历过程中,记录已生成的 key 数量。可能遍历到树中某个节点时,发现加上直接子节点数量,已经超过 key 数量限制了,此时就不要向下遍历了,直接在该节点处生成更新对象。代码参考:

class UpdateTree {
  private root = new Ancestor();

  private view: View;

  private limitLeafTotalCount: LimitLeafCounter;

  public constructor(view: View, limitLeafTotalCount: LimitLeafCounter) {
    this.view = view;
    this.limitLeafTotalCount = limitLeafTotalCount;
  }
 
  // 构造树
  public addNode(keyPathList: (string | number)[], value: any) {
    let curNode = this.root;
    const len = keyPathList.length;
    keyPathList.forEach((keyPath, index) => {
      if (curNode.children === undefined) {
        if (typeof keyPath === 'number') {
          curNode.children = [];
        } else {
          curNode.children = {};
        }
      }

      if (index < len - 1) {
        const child = (curNode.children as any)[keyPath];
        if (!child || child instanceof Leaf) {
          const node = new Ancestor();
          node.parent = curNode;
          (curNode.children as any)[keyPath] = node;
          curNode = node;
        } else {
          curNode = child;
        }
      } else {
        const lastLeafNode: Leaf = new Leaf();
        lastLeafNode.parent = curNode;
        lastLeafNode.value = value;
        (curNode.children as any)[keyPath] = lastLeafNode;
      }
    });
  }

  private getViewData(viewData: any, k: string | number) {
    return isObject(viewData) ? viewData[k] : null;
  }

  private combine(curNode: Ancestor | Leaf, viewData: any): any {
    if (curNode instanceof Leaf) {
      return curNode.value;
    }

    if (!curNode.children) {
      return undefined;
    }

    if (Array.isArray(curNode.children)) {
      return curNode.children.map((child, index) => {
        return this.combine(child, this.getViewData(viewData, index));
      });
    }

    const result: Record<string, any> = isObject(viewData) ? viewData : {};
    for (const k in curNode.children) {
      result[k] = this.combine(curNode.children[k], this.getViewData(viewData, k));
    }
    return result;
  }

  private iterate(
    curNode: Ancestor | Leaf,
    keyPathList: (string | number)[],
    updateObj: Record<string, any>,
    viewData: any,
    availableLeafCount: number,
  ) {
    if (curNode instanceof Leaf) {
      updateObj[generateKeyPathString(keyPathList)] = curNode.value;
      this.limitLeafTotalCount.addLeaf();
    } else {
      const children = curNode.children;
      const len = Array.isArray(children)
        ? children.length
        : Object.keys(children || {}).length;
      if (len > availableLeafCount) {
        updateObj[generateKeyPathString(keyPathList)] = this.combine(curNode, viewData);
        this.limitLeafTotalCount.addLeaf();
      } else if (Array.isArray(children)) {
        children.forEach((child, index) => {
          this.iterate(
            child,
            [
              ...keyPathList,
              index,
            ],
            updateObj,
            this.getViewData(viewData, index),
            this.limitLeafTotalCount.getRemainCount() - len,
          );
        });
      } else {
        for (const k in children) {
          this.iterate(
            children[k],
            [
              ...keyPathList,
              k,
            ],
            updateObj,
            this.getViewData(viewData, k),
            this.limitLeafTotalCount.getRemainCount() - len,
          );
        }
      }
    }
  }
    
  // 生成更新对象
  public generate() {
    const updateObj: Record<string, any> = {};
    this.iterate(
      this.root,
      [],
      updateObj,
      this.view.data,
      this.limitLeafTotalCount.getRemainCount(),
    );
    return updateObj;
  }

  public clear() {
    this.root = new Ancestor();
  }
}

到此为止,我们已经能在合适的时机,针对某个页面或组件生成限定数量的 key 去同步数据了。

还有个问题需要解决:更新顺序。上述更新过程,我们会针对普通对象,使用 setData,针对数组,使用 $spliceData。在这两个方法之前,会分别准备好两个方法的对象参数。假设如下场景:

// page 的 data.list 中已经存在一个元素
pageInstance.data = {
  list: ['0'],
};

// 某个时刻,调用 setData 和 $spliceData 更新数据
pageInstance.setData({
  'list[1]': '1',
});
pageInstance.$spliceData({
  list: [1, 0, '2'],
});

更新完成之后,pageInstance.data.list 变为 ['0', '2', '1'],如果调换 setData$spliceData 的顺序,那么 pageInstance.data.list 将会变为 ['0', '1']

因此,我们不能打乱批量更新中 setData$spliceData 的调用顺序。

此时,我们构造的批量更新逻辑必须满足:

  • 不能打乱顺序;
  • 控制 key 数量上限。

为了保持顺序,在批量更新块中,比如:

setData request
setData request
spliceData request
spliceData request
setData request

前两个合并成一个 setData 更新对象,中间两个合并成一个 $spliceData 更新对象,最后一个是单独的 setData 更新对象。

前后两个 setData 更新对象的 key 数量,统一受 key 数量的限制。

绝大多数情况下,$spliceData 更新对象会比较小,因此不限制该更新对象的 key 数量。

至此,所有已知问题处理完毕,完整代码参考此处

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容