異步分片計算在騰訊文檔的實踐

1. 背景

幾個月前對騰訊文檔 Smart Sheet 中看板視圖的排版計算進行了一次優化,主要是利用異步分片計算來提高當前的 FPS 值,避免用戶操作被阻塞。感謝 kylehr 的支持和幫助。

目前項目中主要有三個地方用到了異步分片計算,分別是:

  1. 表格視圖的列統計計算

  2. 看板視圖的排版計算

  3. 甘特視圖的時間條區域計算

這三個都有共同的特點,在大文檔情況下計算量比較大、耗時久,會阻塞當前的主線程,導致用戶的操作無法被響應。「最嚴重的是看板視圖,因爲排版是優先於渲染的,所以會出現白屏情況。」

可以看到在 5000 條數據場景下,刷新頁面白屏時間過久:

更新階段計算時間也很久,嚴重阻塞用戶操作:

爲了解決白屏問題,臨時基於同步計算的版本用 requestIdleCallback 來做了優化,但還存在一些問題。

  1. 計算粒度不好控制,粒度太大,依然會卡頓,粒度太小,耗時更久。

  2. requestIdleCallback 優先級比較低,可能會一直不被調用。

  3. 排版計算的規則順序有一些問題。

由於當時是直接設置了一個粒度(比如 300 個卡片作爲一片),在刷新或者更新後去滾動頁面,「雖然沒有白屏現象了,但卡頓依然非常明顯。」

從火焰圖可以看到滾動階段會有很多 long task,所以滾動很卡。

原本看板是基於分組 - 卡片的維度進行遍歷計算排版的,現在我們需要一種能夠支持打斷、恢復的時間分片計算,而不是設置固定的粒度進行計算。

因此,本文主要以看板視圖的首屏排版計算作爲切入點,來講解異步分片計算的實踐。

2. 什麼是智能表格?

智能表格是一種擁有多視圖的新型表格,和傳統 Excel 不同的是它擁有更豐富的列類型和視圖。

它本質上是一個在線數據庫,一份數據多種維度展示,目前已經有表格視圖、看板視圖、畫冊視圖、甘特視圖、日曆視圖等。

智能表格也是一個天然的低代碼平臺,只要使用開放的增刪改查 API 就能實現一個後臺管理系統,利用提供的各種視圖將數據展示出來,幾乎沒什麼成本。

表格視圖:

看板視圖(無封面):

看板視圖(有封面):

甘特視圖:

畫冊視圖:

日曆視圖:

其中看板視圖和畫冊視圖是以卡片的形式來展現,非常適合做一些運營活動和項目管理。

看板視圖可以根據單選列作爲分組依據,進行卡片的一個聚合分組展示,而且卡片的高度是不固定的,只有當前列有內容纔會展示出來。

對於多行文本來說,內容超過四行就展示四行,否則有幾行就展示幾行,多選項也是類似的邏輯,所以每個卡片的高度都需要單獨計算。

畫冊視圖雖然也是卡片,但沒有分組,卡片高度始終固定,所以不會被排版計算的問題困擾。

3. 爲什麼會慢?

表格裏面的排版意思就是渲染之前根據數據來計算渲染需要的佈局信息,這點兒類似於 Flutter。

在看板裏面,每個分組的高度都不一樣,都是根據裏面的卡片高度累加計算的,所以計算每個卡片的高度成爲了重點。

爲什麼計算卡片高度會慢呢?這裏就要介紹一下 Canvas 繪製的一些問題了。

由於智能表格完全是使用 Canvas 進行繪製的,所以不像在 DOM 裏面擁有很多 CSS 屬性,比如文本的換行、省略等等。在 Canvas 這些都是需要自己去計算的,Canvas 提供了 measureText 來計算文本的寬度。

以下面這段話爲例,我們來給定一個寬度,需要計算出來文本在哪個字符處換行、添加省略號。

這裏最初使用的是二分查找對整段文本進行計算,不斷進行二分,最終找到在哪個字符處進行換行。

但是二分查找有一些明顯的問題,假如二分查找了 10 次,就意味着圖中的騰訊文檔四個字被重複計算了 10 遍,明顯是性能的浪費。

所以可以看出來,耗時的地方主要是大量調用 measureText 進行文本寬度計算。

爲了解決重複調用的問題,按照一定規則對文本進行了分詞,計算好每個詞的寬度,將其緩存起來,後續根據詞來匹配緩存,這樣就能避免大量的重複測量,性能提升很明顯(這些是後話了)。

那麼,即使不考慮重複的文本,計算量也是很大的,有沒有什麼解決方法呢?

4. 思考

解決上述問題有兩種思路,一個是用 Web Worker 進行計算,另一個是異步計算,最終我們採用了異步計算。

爲什麼不用 Web Worker 呢?Web Worker 的內存和主線程不共享,會讓項目佔用的內存增長,同時和主線程通信也有一定開銷,耗時未必會少。

基於這兩種考量,我們優先考慮使用異步計算。

提到異步計算,首先想到的應該就是 React Fiber 的優化(如果已經瞭解,可以跳過此部分)。

在 React15 中,觸發 setState 在組件更新階段,由於是對組件進行遍歷更新,在組件很多的情況下,耗時比較高。

此時如果用戶有一些操作,響應會比較慢,體驗是比較卡頓的,這和我們的情況是非常類似的。

在 React16 中做了一定優化,支持異步分片計算,將耗時長的任務分成一片片,雖然不會降低總耗時,但每執行完一片任務後,會將控制權交還給瀏覽器,所以可以避免阻塞用戶操作。

對於看板的這種計算情況,也是非常類似的。最初也是遍歷計算排版的,現在我們首先考慮實現一個類似 React Scheduler 的調度器。

5. 異步分片計算

異步分片計算需要保證的是,我們將任務分成一片片,保證當前一片剛好是一幀的執行時間,等到下一幀再去執行下一個異步任務。

也就是說只要保證每個異步任務的執行時間不能超過 16ms,如果超過 16ms 了,那麼就停止執行,將控制權交給瀏覽器,等待下一個異步任務的執行。

要求異步任務執行時間不超過 16ms,這個也比較容易,可以簡單來寫一個:

const DEFAULT_RUNTIME = 16;
let sum = 0;
const runner = (tasks) ={
    const prevTime = performance.now();
    do {
        if (tasks.length === 0) {
            return;
        }
        const task = tasks.shift();
        const value = task();
        sum += value;
    } while (performance.now() - prevTime < DEFAULT_RUNTIME);

    setTimeout(() => runner(tasks));
};

那我們來試試這個調度器的效果如何吧,以一個耗時的循環爲栗子:

console.time();
for (let i = 0; i < 10000; i++) {
    for (let j = 0; j < 1000000; j++) {}
}
console.timeEnd();

這段代碼在我的 MacBook M1Pro 上面執行都要耗時 3 秒多,這期間頁面上的任何操作都不會響應了。

tips: 還可以使用 navigator.scheduling.isInputPending 來判斷用戶是否輸入來提高調度效率。

換來我們的異步調度任務呢?其實主要耗時在於一開始生成 tasks 數組,因爲 push 操作也有一定開銷。

const tasks = [];
for (let i = 0; i < 10000; i++) {
    tasks.push(() ={
        for (let j = 0; j < 1000000; j++) {}
    });
}
// 這裏先只看 runner 的耗時
console.time();
runner(tasks);
console.timeEnd();

可以發現 runner 耗時很低,頁面也不會卡頓了,這裏循環數量越大,差距越明顯一些。

但這個調度任務還有很多問題:

  1. setTimeout 的最小值是 4ms,造成了時間的浪費,考慮到一幀 16ms,4ms 是一個很大的開銷。。

  2. 調用方無法知道什麼時候調用結束了。

  3. 調用方無法手動取消任務調用。

那麼對我們的調度器進行調整,首先將 setTimeout 換成更好的 MessageChannel

那麼爲什麼要使用 MessageChannel,而不是 requestAnimationFrame 呢?raf 的調用時機是在渲染之前,但這個時機不穩定,導致 raf 調用也不穩定,所以不適合。

其實 MessageChannel 也是 React 調度使用的方案,如果瀏覽器不支持,纔會降級到 setTimeout

const schduler = (tasks) ={
    const DEFAULT_RUNTIME = 16;
    const { port1, port2 } = new MessageChannel();
    let sum = 0;

    // 運行器
    const runner = () ={
        const prevTime = performance.now();
        do {
            if (tasks.length === 0) {
                return;
            }
            const task = tasks.shift();
            const value = task();
            sum += value;
        } while (performance.now() - prevTime < DEFAULT_RUNTIME);
        // 當前分片執行完成後開啓下一個分片
        port2.postMessage('');
    };
    
    port1.onmessage = function () {
        runner();
    };
    
    port2.postMessage('');
};

調用方需要知道什麼時候執行結束和手動取消調用,可以利用 Promise 的特性來進行封裝。

首先是在結束的時候來執行 Promiseresolve 方法。

const schduler = (tasks) ={
    const DEFAULT_RUNTIME = 16;
    const { port1, port2 } = new MessageChannel();
    let sum = 0;
    
    return new Promise((resolve) ={
        // 運行器
        const runner = () ={
            const prevTime = performance.now();
            do {
                // 如果任務隊列已經空了
                if (tasks.length === 0) {
                    return resolve(sum);
                }
                const task = tasks.shift();
                const value = task();
                sum += value;
            } while (performance.now() - prevTime < DEFAULT_RUNTIME);
            // 當前分片執行完成後開啓下一個分片
            port2.postMessage('');
        };
        
        port1.onmessage = function () {
            runner();
        };
        
        port2.postMessage('');
    });
};

schduler(/.../).then(sum => console.log(sum));

那麼如何取消當前任務呢?想取消 MessageChannel 肯定是不發送就行了,因此這裏需要提供一個 abort 方法給用戶來調用。

最簡單的方式是設置一個標誌位,如果標誌位是 false,就取消後續調用。

const schduler = (tasks) ={
    const DEFAULT_RUNTIME = 16;
    const { port1, port2 } = new MessageChannel();
    
    let sum = 0;
    let isAbort = false;
    
    const promise = new Promise((resolve, reject) ={
        // 運行器
        const runner = () ={
            const prevTime = performance.now();
            do {
                if (isAbort) {
                    return reject();
                }
                // 如果任務隊列已經空了
                if (tasks.length === 0) {
                    return resolve(sum);
                }
                const task = tasks.shift();
                const value = task();
                sum += value;
            } while (performance.now() - prevTime < DEFAULT_RUNTIME);
            // 當前分片執行完成後開啓下一個分片
            port2.postMessage('');
        };
        
        port1.onmessage = function () {
            runner();
        };
        
        port2.postMessage('');
    });
    
    promise.abort = () ={
        isAbort = true;
    };
    
    return promise;
};

截止到這裏,一個基本的調度器就實現了,雖然和我們項目裏實際使用的差別很大,也缺少 React Scheduler 很多更高級的功能,比如優先級、延時任務等等。

但從火焰圖上可以看到當前每個 Task 都保持在 16ms 左右的耗時,FPS 基本穩定在 60 左右。

6. 基於可視區域收集

雖然異步調度器已經寫好了,但我們應該怎麼去分配異步任務呢?比如頁面上的卡片,應該按照什麼樣的規則來計算呢?

最初我們是從頭計算完一個分組所有卡片,再去計算下一個分組的,但是一個分組可能有很多的卡片,可能會影響了後面卡片的計算。

而且看板有記錄用戶上次滾動距離的邏輯,可能用戶這次打開的時候,文檔展示在中間位置,這樣可視區域渲染的時間被大大延長了。

一般來說,一個文檔不會有很多分組,所以卡片應該要橫向計算,優先計算所有分組的第一個卡片,然後再計算所有分組的第二個卡片,依次類推...

由於首屏速度對於用戶來說至關重要,異步計算雖然不會阻塞用戶操作,但讓整體的耗時變高了,所以這裏考慮設置一個閾值,對於 1000 個卡片以下的文檔保持原來的同步計算。

對於 1000 個卡片以上的文檔走異步分片計算,但可視區域內的卡片優先同步計算,這裏會在上下左右多計算幾個卡片,給用戶滾動留一定的緩衝。在可視區域計算完成後立即渲染一次,保證用戶能夠快速看到頁面。

然後開始計算可視區域之外的,這裏最好的方式是以可視區域作爲中心往兩邊擴散。如果此時文檔有更新,或者用戶滾動了頁面導致可視區域變化了,之前計算過的卡片高度應該緩存起來,再繼續剩餘的卡片。

7. 更新和緩存

在更新階段,需要根據用戶的具體操作來做差量計算。比如用戶點擊了複選框,此時當前卡片高度沒有發生變化,分組高度也沒有變化,所以不需要重新排版,直接渲染就行了。

如果用戶移動了卡片到另一個分組,此時應該將兩個分組標記爲 dirty,重新計算兩個分組的高度。但由於沒有任何一個卡片高度發生了變化,所以可以複用首屏計算緩存的卡片高度,這部分計算是同步的,幾乎是簡單的累加,所以幾乎不耗時。

如果用戶修改了某行文本,導致某個卡片高度需要重新計算,這裏會把當前分組和卡片都標記爲 dirty,對 dirty 的卡片高度重新同步計算並緩存,其他卡片依舊走緩存。

對於隱藏展示列的操作,因爲會改變所有卡片的高度,必須要全部異步分片重算,除非對列級別做緩存,但對內存佔用太大,這裏只做了卡片級別的緩存。

通過這種方式,在更新階段可以將 90% 場景的計算耗時幾乎降低到 0ms。

8. 總結

在大型文檔中,可能很小的一個功能就會出現性能瓶頸,類似的地方還有搜索替換,也是會造成卡頓的地方,一樣需要走異步分片計算。

React Scheduler 出來很久了,也有很多文章介紹,但在業務中真正能用到的地方很少。實現一個異步調度器很容易,也沒什麼技術難點,但對業務的提升是巨大的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sV4bIxFC6_c1FxetxtLn7A