美文网首页
小程序高性能数据同步

小程序高性能数据同步

作者: yibuyisheng | 来源:发表于2020-01-22 10:52 被阅读0次

    小程序中,一个重要的性能环节就是同步 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 数量。

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

    相关文章

      网友评论

          本文标题:小程序高性能数据同步

          本文链接:https://www.haomeiwen.com/subject/xumuzctx.html