最近用 OEA 做的倉庫管理系統中,許多界面的都需要使用表格控件來顯示數據。一是這些表格的列非常多,有的甚至達到了 200 列,而且一個模塊的界面中可能同時顯示好幾個表格。這導致界面的速度比較慢,特別是較多數據需要展現時。經檢測,表現雖然表格的行已經做了虛擬化,但是由於列非常多,最終還是造成可視樹中的元素過多,而導致界面布局代碼運行過慢。假設只有 30 行,一個單元格僅生成 5 個可視元素,200 列的單元格都會產生 3W 個可視元素,而布局系統的 Measure 方法需要對可視樹中的每一個元素都調用其對應的 Measure 方法,可以想象,這當然會很慢。
那么,要解決上述的問題,只有同時實現表格的行、列虛擬化,才能有效地減少表格的可視元素,從而提高系統性能。還好,OEA 中的 TreeGrid 本身就是我們自己為 OEA 量身定制的控件,所以可以直接改造。
但是,要同時在一個表格控件中同時實現行、列虛擬化呢?我們得先看看如何在 WPF 中實現虛擬化。
WPF 虛擬化相關知識
我之前寫過一篇文章《精通 WPF UI Virtualization》,里面引用了許多老外的文章,說明了要實現界面虛擬化需要做的幾件事。這里我來匯總下:
- * 設置 ScrollViewer.CanContentScroll 為 True。默認為 False 時,ScollViewer 自己實現了滾動邏輯,在 Measure 時會把 Infinite 傳給 Content 元素;而當該值被設置為 True時,ScrollViwer 認為它的 Content 元素自己實現了 IScrollInfo 並處理所有的滾動邏輯。
- * 從 VirtualizingPanel 繼承出一個子類,並讓這個新的 Panel(以下稱為 UIVPanel) 實現 IScrollInfo。
- * 在 UIVPanel 中實現虛擬化邏輯,生成或銷毀界面元素。
1. 要知道如何實現 IScrollInfo,則需要明白 IScrollInfo 的設計原理:
如果 UIVPanel 元素自己要處理滾動信息,它必須知道當前滾動條的 OffSet,並告知 ScrollViewer 需要的總大小是多少,這樣才能正確地顯示滾動條。由於 UIVPanel 元素的 Measure 方法被 ScrollViwer 調用時,參數只能傳入和傳出視窗的大小,那么,外圍的 ScrollViewer 想要和 UIVPanel 交互更多的數據,例如傳入 OffSet(VerticalOffSet 及 HorizontalOffSet)、獲取 Extent(Height/Width),則只能通過 UIVPanel 本身的公有屬性來交互,也就要求 UIVPanel 必須實現 IScrollInfo 中定義的所有屬性及方法。(注意,IScrollInfo 中的所有方法,本質上只是期望設置新的 Offset,只是滾動的粒度不同而已。)
2. 實現 IScrollInfo 的 UIVPanel 與 ScrollViewer 交互的細節如下:
* ScollViewer 會在滾動條變更時,調用 UIVPanel 的 SetVerticalOffset 或者相關方法來變更 Offset 值,UIVPanel 則在 SetVerticalOffset 中調用 InvalidateMeasure 來重新測量自身。
* UIVPanel 的 MeasureOverride 方法中,參數是 ScrollViewer 傳入的視窗大小,再獲取其內部數據 VerticalOffset,最終計算出 IScrollInfo 中的 ExtentHeight/ExtentWidth(總高度/總寬度)。如果這個值有所變化,則應該調用 ScrollOwner.InvalidateScrollInfo 通知 ScrollOwner 來重新獲取最新的總高度,以計算出滾動條最新的大小。
在與 ScrollViewer 交互完成的同時,UIVPanel 還應該根據提供的視窗大小,調用基類 VirtualizingPanel 中 ItemContainerGenerator 屬性的一套元素生成方法,通過視窗大小、當前 Offset,來生成新的需要顯示的容器,並移除不可見的容器,最終達到虛擬化的效果。
3. GeneratorPosition 類的含義:
(不知道 GeneratorPosition 類型的朋友,可以先看一下這篇文章中的《Implementing a VirtualizingPanel part 2: IItemContainerGenerator》代碼。)
在使用 ItemContainerGenerator 來生成元素時,需要理解 GeneratorPosition 的含義。它中有兩個屬性:Index 及 Offset,它們的意義可以從 IndexFromGeneratorPosition 方法中理解出來:
Index 如果大於等於 0 時,則表示一個生成好的項容器在所有已經生成好的項容器中的索引。假設這個容器為 A,那么,在 A 的基礎上,如果 Offset 是 0,則整個 GeneratorPosition 就表示項容器 A;而如果 Offset 非 0,則表示一個還沒有生成的項容器 B,它距離 A 的相對位置正好是 Offset。
Index 若是 -1 時,OffSet 如果是正數表示目標容器到起點的偏移量,如果是負數則表示目標容器到終點的偏移量。
GeneratorPosition 類型的設計比較晦澀,不易理解。這跟 VirtualizingPanel.ItemContainerGenerator 中虛擬化的內部實現的數據結構是有關系的。虛擬化會把整個列表分割成多個小塊,這些小塊主要是兩類:UnrealizedItemBlock(未實例化塊)、RealizedItemBlock(已實例化塊)。整個列表由這些塊組合起來表示,假設一頁能顯示 30 條數據,則一個一萬行的列表可能由以下小塊組成:RealizedItemBlock 60,UnrealizedItemBlock 8000,RealizedItemBlock 150,UnrealizedItemBlock 1790,總和是一萬。所有的塊在 ItemContainerGenerator 中由一個雙向鏈表存儲在字段 _itemMap 中。_itemMap.Next 就是第一個塊,也可以理解為起點或者終點。 UnrealizedItemBlock 與 RealizedItemBlock 類都繼承自 ItemBlock。ItemBlock 中有兩個重要屬性:ItemCount、ContainerCount。ItemCount 表示本塊代表了多少條數據,二者實現一致。而 ContainerCount 表示已經生成的容器的個數,對於 UnrealizedItemBlock 來說,永遠返回 0; 而 RealizedItemBlock 返回它的 ItemCount 表示容器數就是項數。
所以,到現在已經能夠看出,其實 GeneratorPosition 存儲了某個 ItemBlock 的索引號,以及具體容器相對這個 ItemBlock 的偏移量。而操作 ItemContainerGenerator 都使用 GeneratorPosition,可以方便地和內部的數據結構交互。(這樣設計的原因可能是出於性能的考慮?)
說完了 UIV 的相關知識,接下來,那我們就開始設計 TreeGrid 表格的虛擬化。
表格的虛擬化
由前面的內容可以看出,如果要在 WPF 中實現一個行列都支持虛擬化的 UIVPanel,只需要從 VirtualizingPanel 上繼承下一個 UIVPanel 類型,並根據列的寬度來計算並生成相應的單元格就行了。但是如果這樣設計的話,將會導致所有的單元格,都必須放在 UIVPanel 中。也就是說,TreeGrid 作為一個 ItemsControl,其中的所有單元格 TreeGridCell 都必須作為它的邏輯子容器。這樣的設計雖然實現了界面虛擬化,但是並不可取。這是因為,開發人員對於 TreeGrid 的常見用法應該是:TreeGrid 中的每一項是一個表格行 TreeGridRow,而 TreeGridRow 又是一個 ItemsControl,行中其中的每一項才是橫向排列的單元格 TreeGridCell。這樣的場景導致 TreeGrid 的接口設計也應該是 TreeGrid -> TreeGridRow -> TreeGridCell 這樣層級的接口,邏輯樹、可視樹也都應該是按這樣的層次構建,易於使用、易於調試。
那么,在這樣層次要求下,要如何實現只使用一個滾動條的虛擬化呢?還好,WPF 自帶的 DataGrid 也帶有行列虛擬化的功能,我們可以先看一下 DataGrid 是如何實現的。 下圖是 DataGrid 打開行、列虛擬化功能后生成的可視樹:
結合上面這個圖,再查閱 DataGrid 源碼,可以看出:
* 整個 DataGrid 表格中只有一個 ScrollViewer,表格作為一個 ItemsControl,內部每一項是一個 DataGridRow,其內部作為 ItemsHost 使用的面板是 DataGridRowsPresenter 類型。DataGridRowsPresenter 繼承自 VirtualizingStackPanel,就間接繼承 VirtualizingPanel 並實現 IScrollInfo 接口,為最外層的 ScrollViewer 提供滾動信息,提供 DataGridRow 行的虛擬化功能。
* 每一個 DataGridRow 中,使用一個繼承自 ItemsControl 的 DataGridCellsPresenter 來生成每一個單元格的容器,而它則使用 DataGridCellsPanel 來作為 ItemsHost 面板。DataGridCellsPanel 也是一個繼承自 VirtualizingPanel 的虛擬化面板。但是,它並沒有實現 IScrollInfo。為了使用最外層 ScrollViewer 中的滾動條信息,它通過可視樹往上查找到 DataGridRowsPresenter 來獲取水平方向上的滾動條位置 HorizontalOffset,而通過這個值,來計算水平方向上需要顯示的單元格,以實現虛擬化。
* 另外,需要額外說明下兩個 ItemsControl 的數據源:DataGrid 的 ItemsSource 當然就是應用層指定的數據模型的列表,這樣,每一個 DataGridRow 的 DataContext 就是其中的一個數據模型對象。而有意思的是,表格行內的 DataGridCellsPresenter,作為一個橫向顯示單元格的控件,它也是一個 ItemsControl,也需要設置它的 ItemsSource 數據源屬性。由於每一個行的 DataContext,也應該是每一個單元格的 DataContext,所以 DataGridCellsPresenter.ItemsSource 應該被設置為一個數據模型對象列表,其中每一個元素都是 DataGridRow.DataContext 對象,列表的長度就是表格列的個數,這樣就可以生成和列的個數一致的單元格個數。(內部實現上,MS 使用了一個實現 IList 接口的 MultipleCopiesCollection 集合類型,只需要設置 CopiedItem 及 Count 兩個屬性,即可表現出長為 Count、每個元素都是 CopiedItem 的行為。)
TreeGrid 的虛擬化
根據之前的分析,我們已經知道表格 DataGrid 實現虛擬化都需要哪些元素,元素之間是如何交互的。而我們的 TreeGrid 控件也是模仿這個結構進行的設計,添加了相應的 TreeGridRowsPanel、TreeGridCellsPresenter、TreeGridCellsPanel 類型。最終的表格控件,經測試,給 20000 行數據,300列,都能在 0.5s 內完成渲染:
上圖表格中的大量數據,只生成了少量的可視元素,最終生成的可視樹結構如下:
由於每一列的單元格都是隨着拖動橫向滾動條而生成的,所以在拖動時有一定的延遲,沒有原來感覺流暢。所以當列數較少時,則沒有必要打開列虛擬化。目前暫時設定為,當列數超過 50 的時候,該表格會自動打開列虛擬化功能,提升渲染性能。
未來的改進
其實,TreeGrid 作為 OEA 框架界面層的核心控件,主要是在提供 WPF 中的樹型表格及一般表格功能。一般表格狀態下的性能保障由虛擬化技術來實現。而在樹型狀態下,則主要是支持樹節點的懶加載,只實例化已經開展的行,即只有展開樹中的父行時,才會生成其對應的子行。如下圖所示:
樹型表格狀態下,暫時沒有實現虛擬化。
VirtualizingStackPanel 為了提高性能,它是根據 Item (項數)而不是 Pixel (象素)來計算滾動條信息。這導致了當每一行的高不統一時,豎向滾動條會計算出錯,造成很差的用戶體驗。這也是為什么 ListBox 等控件在分組狀態下,虛擬化會被關閉的原因:分組后每一項其實是 GroupItem 類型,而每個組的高度並不一致。
而 TreeGrid 中,支持行虛擬化的 TreeGridRowsPanel 是繼承自 VirtualizingStackPanel 來實現的。而表格行 TreeGridRow 類則繼承自 HeaderedItemsControl 類型,它的總行高應該是本行的高度加上所有子行的高度,也不是一個定值,所以現在虛擬化功能也被關閉。而當行虛擬化關閉后,由於列虛擬化實現的機制依賴最外層的 ScrollViewer,所以也被關閉。也就是說,暫時不能只打開列虛擬化,而不打開行虛擬化。
這些功能其實都是可以打開的,但是前提是必須讓 TreeGridRowsPanel 繼承自 VirtualizingPanel 而不是 VirtualizingStackPanel,並實現自定義行高的計算邏輯,相對復雜。考慮到目前樹型表格狀態下,使用懶加載在性能上已經沒有什么問題,暫時就不實現虛擬化了。
(另外,就算重寫了行的虛擬化面板,來通過 TreeGridRow 計算出它所有子的高度,最后對需要顯示的行進行實例化。也只能打開最外層 TreeGridRow 的虛擬化功能,而樹可能有第二層、第三層……,這些層都無法實現虛擬化。如果要實現這些層的虛擬化,那就更復雜了…… :( )
其實,懶加載和虛擬化技術,本質上是一樣的,都是把不需要顯示的元素延后實例化。 :)
后話
由於 TreeGrid 虛擬化技術的相關設計思路主要來自 DataGrid,有些代碼甚至是直接拷貝自 DataGrid,所以代碼就不貼在這了。下次更新 OEA 的時候,大家就可以在開源地址中下載到了。
TreeGrid 表格實現虛擬化技術,涉及到重構整個控件內部的組織結構,是本階段 TreeGrid 重構的一個首要內容。而下一篇文章,會說一下 TreeGrid 控件其它方面的相關重構。