10w單元格滾動卡頓如何解決?騰訊文檔的7個祕笈

圖片

導語 |騰訊文檔 SmartSheet 視圖是多種視圖中的一種,該模式下 FPS 僅 20 幾幀(普通 Sheet 視圖下 58 幀),用戶體驗非常卡頓。騰訊文檔團隊針對該問題進行優化,通過禁用取色、多卡片離屏渲染等方式實現 FPS 接近 60 幀,提升兩倍多。本文將詳細介紹其挑戰和解決方案,並輸出通用的經驗方法。希望本文對你有幫助。

目錄

1 前言

2 增量渲染

3 分析火焰圖

4 禁用取色

5 減少搜索結果匹配

6 避免使用 clone

7 多卡片離屏渲染

7.1 多卡片 vs 整屏

7.2 實現

8 文本緩存

9 最後

01

前言

騰訊文檔智能表格是一種擁有多視圖的新型表格。智能表格也是一個天然的低代碼平臺,只要使用開放的增刪改查 API 就能實現一個後臺管理系統,利用提供的各種視圖將數據展示出來。它本質上是一個在線數據庫,擁有更豐富的列類型和視圖。智能表格可以讓一份數據多種維度展示。目前已經有表格視圖、看板視圖(SmartSheet 視圖)、畫冊視圖、甘特視圖、日曆視圖等。

除了最被熟知的表格視圖之外,SmartSheet 看板視圖以卡片的形式來展現,非常適合做一些運營活動和項目管理,從而開始得到關注。看板視圖可以根據單選列作爲分組依據,進行卡片的一個聚合分組展示。卡片的高度是不固定的,只有當前列有內容纔會展示出來。下圖是騰訊文檔智能表格 SmartSheet 看板視圖的無封面版本和有封面版本:

圖片

圖片

SmartSheet 看板視圖上線後,10 w 單元格場景下的 FPS只有 20 多幀,比起Sheet 視圖的 58 幀差距比較大,用戶體驗非常卡頓。

圖片

FPS (Frames Per Second) 就是每秒鐘畫面的更新次數。理論上 FPS 越高,動畫就會越流暢。由於大多數設備屏幕刷新率都是 60 次 / 秒,所以一般來說 FPS 爲 60 幀的時候最流暢,此時每幀的消耗時間約爲 16.67 ms。如果 FPS 低於 30 幀,就會出現明顯的卡頓和不流暢。所以騰訊文檔團隊優化的重點目標是:儘量將每一幀的耗時降低到 16.67 ms。

02

增量渲染

Smart Sheet 看板是多種視圖中的一種。它主要是多個分組來組成的,每個分組又包括了多個卡片。滾動的時候包括左右分組滾動、分組內卡片上下滾動兩種。

先來了解渲染層的實現,Smart Sheet 看板渲染層初始化分爲4個階段

  • 第一階段,收集計算文本寬高、截斷等等;

  • 第二階段,收集各種樹形結構的 widget,比如 textPainter、cardPainter、groupPainter 等等。

  • 第三階段,基於 widget 進行繪製,從根 layoutTree 開始遞歸子節點執行 painter 方法;

  • 第四階段,Konva 執行 Layer 的 batchDraw 方法,遞歸執行子節點的 draw 方法。

10 w 單元格不會將全部卡片都給繪製出來。因爲它一方面會導致繪製時間過長,另一方面存放繪製信息佔用的內存太多。

圖片

所以只會收集可視區域內的 widget 進行繪製。在滾動的時候,會計算出需要銷燬的卡片和需要新增的卡片,然後開始銷燬前面的節點,重新創建新的節點,進行增量渲染。對應上面的第 2、3 步,但此時只會收集增量的 Painter。

圖片

03

分析火焰圖

首先需要知道滾動的時候主要是耗時在哪裏。打開 Chrome 的 Performance 選項,選擇最左邊的實心圓錄製,在頁面上用鼠標滾動。最後生成了下面這份火焰圖,可以看到有很多紅色倒三角,說明這裏出現了一些很耗時的操作。

圖片

放大這個火焰圖,可以看到其中的一個 Task 的耗時,也就是一幀的耗時。可以看到兩種情況,後者明顯比前者耗時多太多了。

  • Task1:

圖片

  • Task2:

    圖片

那滾動的時候渲染層做了哪些事情呢?主要是下面幾步:

  • 第一步,對原來的分組設置偏移量;

  • 第二步,計算新的可視區域,包括需要銷燬、創建的分組和卡片;

  • 第三步,收集分組或者卡片的 widget;

  • 第四步,基於 widget 進行繪製,主要是創建 Konva 節點,添加子節點等;

  • 第五步,觸發 Layer 的 batchDraw 方法,遍歷子節點進行繪製。

04

禁用取色

可以從上面看到 getImageData 耗時非常多,那爲什麼滾動的時候會用到 getImageData 呢?這就不得不說到 Canvas 的事件系統了。

Canvas 不像 DOM 一樣擁有事件系統,所以無法直接知道當前點擊的是哪個圖形,需要開發者自己實現一套事件系統。簡單來說,就是知道某個座標點當前對應的是什麼圖形。

Konva 爲了能夠根據座標點匹配到觸發的元素,採用了**色值法——**也就是在內存裏面的 hitCanvas 裏面繪製一模一樣的圖形,給這個圖形加一個隨機填充色,生成一個 colorKey。然後以這個 colorKey 作爲 key,Shape 作爲 value,存了起來。

事件觸發時通過 hitCanvas 的 getImageData 方法拿到 colorKey,進一步拿到對應的 Shape。

圖片

我們在自己電腦本地執行了 1000 次 getImageData,發現耗時非常多。在滾動的時候,很容易觸發大量調用 getImageData。

   Navigated to file xx

    getImageData 1000次: 250.051025390625 ms

    Navigated to file xx

    getImageData 1000次: 245.02587890625 ms

    Navigated to file xx

    getImageData 1000次: 245.637939453125 ms

    Navigated to file xx

    getImageData 1000次: 254.847900390625 ms

怎麼避免調用 getImageData 呢?我們來翻翻 Konva 的源碼。

圖片

圖片

滾動的時候,觸發的是 wheel 事件。只需要在滾動的時候設置 layer 的 isListening 爲 false 即可。等滾動結束後再設置回來,所以這裏是 debounce 的邏輯。

05

減少搜索結果匹配

前面我們說過,渲染層在渲染的時候會進行收集,在滾動的時候由於可能會有搜索結果高亮的存在,所以也要計算當前卡片是否匹配搜索結果。如果匹配了,那就設置背景色。

但如果在沒有啓動搜索的時候,不應該遍歷 layoutTree,而是應該直接返回。提前返回,可以節省大約 2 ms 的搜索高亮收集時間。

06

避免使用 clone

很多文本和矩形有共同屬性,所以我們原本是先創建了一個節點,使用的時候通過 clone 的方式複用,然後用 setAttrs 來設置新的 config。

圖片

但 clone 的實現比較複雜。可以理解成進行了一次深拷貝,會帶來一些性能損耗。

圖片

圖片

這裏不夠優雅,可以提前緩存通用的 config 值,然後直接使用 new 來創建節點。

圖片

從圖上可以看到,很明顯耗時下降了。

圖片

當我們優化到這一步發現:在沒有出現新的卡片時,滾動的耗時已經非常少了,基本上耗時都在繪製階段。

圖片

繪製階段的耗時達到了 13 ms 之多。

圖片


07

多卡片離屏渲染

繪製階段要怎麼去優化耗時呢?頁面滾動的時候,每次其實只移動了一小段距離,只有這部分是新增的。那也就意味着前面大部分都是不變的,只是增加了一些偏移量,如果能夠對其進行復用,那肯定可以大大減少耗時。

離屏渲染是 Canvas 的一種普遍的優化手段。比如騰訊文檔團隊的 Sheet 和 Word 都有離屏渲染,思路都是在滾動的時候,通過 drawImage 來複用前面已經繪製的部分,然後再繪製增量的部分,這樣可以減少大量文本的繪製。

7.1 多卡片 vs 整屏

Smart Sheet 相比 Sheet 和 Word 來說會特殊一些,騰訊文檔團隊使用了 Konva 這個框架,它自身封裝了一套渲染邏輯,所以對於 Word 這種離屏渲染來說,實現起來比較麻煩。

因此,針對看板的情況,可以針對多個卡片做離屏渲染。多個卡片離屏渲染比整屏離屏渲染更有優勢。

圖片

看板滾動主要有兩種情況:

  • 第一種,沒有出現新的分組和卡片,當前只是在可視區域的卡片內滾動;

  • 第二種,出現了新的分組和卡片,涉及到了節點的銷燬和新增。

對於第一種情況來說,此時沒有新增卡片,多卡片離屏渲染只需要把離屏 Canvas 裏面的內容繪製到主屏就行了。但整屏離屏渲染依然會去多渲染增量部分,因爲它是以整個屏幕爲緯度的;對於第二種情況來說,兩者都需要繪製增量部分的卡片,所以理論上消耗是一樣的。

但在快速滾動的情況下,大部分時間都是沒有出現新的分組的,大概率是在可視區內的幾個分組移動,所以這種情況下,如果使用整屏渲染,就不得不多去渲染一個分組。

7.2 實現

在創建 Group 的時候,增加一個 offscreen 選項,它會多創建一個離屏 Canvas。也就是 offscreenCanvas,這個 canvas 會根據主屏的 Group 裏面的子元素來先繪製一遍。

圖片

在 Group 的實際繪製方法 drawScene 方法裏面,判斷當前 Group 是否存在離屏 Canvas。如果存在離屏 Canvas,那就直接用 drawImage 的方式。

圖片

圖片

那離屏的 Canvas 什麼時候失效呢?由於看板的特殊性,用戶修改了某個單元格有可能造成寬高等信息的變化。所以不得不重新計算一遍,這個時候也會重新繪製。

之前的節點都會被銷燬掉,然後創建新的節點。因此這個時候重新創建了新的離屏  Canvas 就不會失效了。滾動的時候同理,滾出屏幕外的節點被銷燬了,新增的節點重新創建了離屏 Canvas。各位開發者可以看到最終的優化效果,繪製的耗時只有 2 ms。

圖片

但正如前面說的,離屏渲染只是針對已經渲染好的卡片進行的。那如果滾動的時候,出現了新的卡片怎麼辦?這部分渲染依然會很耗時。

08

文本緩存

繪製可複用的部分處理完了,但是繪製增量的部分耗時依然很高,經常可以達到 20 ms 。因爲它需要先收集 painter,然後去繪製 widget。收集部分耗時已經優化到很低了,但繪製部分耗時依然很高。那要怎麼處理呢?

圖片

如果是在文本量不多的時候,這部分耗時已經非常低,每幀耗時降至 58 ms,但文本量大的時候耗時就增多了。從圖上可以發現,耗時主要發生在文本的計算和繪製上面。那文本計算了哪些呢?

  • 第一,如果給定文本寬度,那文本需要在哪個字符進行截斷、換行;

  • 第二,文本最後一行的後面是否需要添加省略號。

文本換行和截斷,在 Konva 裏面進行了非常複雜的計算。主要是對文本進行二分查找,依次找到最終需要截斷的字符位置。如果有換行符,需要對換行符進行特殊處理。如果傳入的截斷方式是  'word',那還需要對空格和-進行特別的處理。如果傳入的是 ellipsis,那需要在最後一行增加省略號。

這些複雜的計算本身會消耗一些時間,其中通過二分查找也會大量調用 measureText 方法。那要怎麼處理呢?看板由於需要記錄用戶上次打開滾動條的位置,再次打開的時候需要跳轉過去。爲了避免滾動的時候,再去實時計算當前應該新增或減少哪些卡片,會在最開始的時候一次性計算好所有的卡片寬高。

卡片寬度涉及到文本、圖片等寬高,也就是說最開始已經處理過文本計算,那這部分緩存起來不就好了?所以在最開始計算的時候可以把屬性爲 key、寬高等信息作爲value 一起存入 cacheText 裏面,然後在 setTextData 裏面判斷 cacheText 裏面是否有緩存,如果有的話就不需要重新計算一遍了。

這裏緩存了三個信息,分別是文本寬度、文本高度、文本子串數組(被截斷分成了好幾個)。

圖片

但這樣還是會有一些問題:如果文本特別長的話,那 textArr 也會比較大,容易導致內存增長。我們修改策略:不存 textArr,而是存每個子串結束的 index 值(換行的 index 值)

另外,在最開始計算的時候,只是爲了算出文本的高度,繪製階段最多隻展示 4 行,超過 4 行就需要添加省略號,所以算出高度後還要判斷是否超過了 4 行。如果直接用最開始計算的結果,它可能包括了超過 4 行的信息,導致繪製階段不準確。例如存了六行,那繪製的時候需要繪製前 4 行;然而省略號是在第六行,導致在第 4 行丟失了省略號。

因此需要基於業務進一步深度定製,針對 Text 進行一次封裝。爲了避免動到計算換行的邏輯,我們增加了一個標誌位,用於判斷當前傳入的 height 表示最大高度。

09

總結與思考

騰訊文檔團隊優化後的FPS接近 60 幀,從 20 多幀提升到 58 幀左右,也就是提升了兩倍多。

圖片

在這期間,團隊總結了相關經驗:**應該儘量避免滾動的時候有阻塞主線程的耗時操作。**很多地方不易被發現,如深拷貝、序列化、反序列化等等。一些複雜又耗時的計算可以將計算工作的結果提前緩存起來,這樣滾動的時候就可以直接從緩存裏面讀取了。由於這裏原本就需要在加載的時候去計算這些,所以就進行了一些改造,讓其支持緩存。

如果想不拖慢首屏渲染速度,還可以放到 Web Worker 裏面去計算,比如多計算幾個分組的文本信息。針對一些比較耗時的繪製操作可以使用離屏渲染的形式來避免重複繪製。這裏還可以考慮使用原生的 Offscreen 配合 Web Worker 來發揮離屏渲染的優勢。以上是本次分享全部內容,歡迎各位開發者在評論區分享交流。

你可能感興趣的騰訊工程師作品

|微信全文搜索耗時降94%?我們用了這種方案

|騰訊工程師聊ChatGPT技術「文集」

|騰訊雲開發者熱門技術乾貨彙總

|一文讀懂 Redis 架構演化之路

技術盲盒:前端後端AI與算法運維|工程師文化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章