性能優化原則
· 理解需求
MultiRow的一個性能需求是:“百萬行數據綁定下平滑滾動。”整個MultiRow項目的開發過程一直在考慮這個目標。
· 理解瓶頸
99%的性能消耗是由於1%的代碼造成的。大部分性能優化都是針對這1%的瓶頸代碼進行的。具體實施也就分為兩步:“發現瓶頸”和“消除瓶頸”。
· 切忌過度
性能優化本身是有成本的。這個成本不單單體現在做性能優化所付出的工作量,還包括為性能優化而寫出復雜的代碼導致額外的維護成本,比如引入新的Bug,額外的內存開銷等。性能優化常常需要在收益和成本之間做出權衡。
如何發現性能瓶頸
性能優化的第一步是發現性能瓶頸,下面是一些定位性能瓶頸的實踐。
· 如何獲取內存消耗
以下代碼可以獲取某個操作的內存消耗。
long start = GC.GetTotalMemory(true); // 在這裡寫需要被測試內存消耗的代碼,例如,創建一個GcMultiRow var gcMulitRow1 = new GcMultiRow(); GC.Collect(); // 確保所有內存都被GC回收 GC.WaitForFullGCComplete(); long end = GC.GetTotalMemory(true); long useMemory = end - start;
· 如何獲取時間消耗
以下代碼可以獲取某個操作時間消耗。
System.Diagnostics.Stopwatch watch = new System.Diagnostics.Stopwatch(); watch.Start(); for (int i = 0; i < 1000; i++) { gcMultiRow1.Sort(); } watch.Stop(); var useTime = (double)watch.ElapsedMilliseconds / 1000;
為了獲得更加穩定的時間消耗,這裡把一個操作循環執行了1000次,取時間消耗的平均值以排除不穩定數據。
· ANTS Performance Profiler
ANTS Performance Profiler是款功能強大的性能檢測軟件。熟練使用這個工具,我們可以快速准確的定位到有性能問題的代碼。這是一款收費軟件,會在IL中加入一些鉤子用來記錄時間,所以在分析時,軟件的執行速度會比實際運行慢一些,獲得的數據也因此並不是百分之百的准確,還要結合其他技巧來分析程序的性能。
· CodeReview
CodeReview是發現性能問題的最後手段。CodeReview應該對產品的性能瓶頸盡可能多的關注,確保該部分邏輯執行的盡可能的快。
性能優化的方法和技巧
定位了性能問題後,解決的辦法有很多。下面是一些性能優化的技巧和實踐。
· 優化程序結構
在設計時就應該考慮產品結構是否可以達到性能需求。如果後期發現了性能問題,調整結構會帶來非常大的開銷。
例如:
GcMultiRow要支持100萬行數據。假設每行有10列的話,就需要有1000萬個單元格,每個單元格上又有很多的屬性。如果不做任何優化,大數據量時,一個GcMultiRow軟件的內存開銷會相當的大。GcMultiRow采用的方案是使用哈希表來存儲行數據:只有用戶改過的行放到哈希表裡,大部分沒有改過的行都直接使用模板代替。這就達到了節省內存的目的。
WPF平台和Silverlight平台的畫法和Winform平台不同,是通過組合Visual元素的方法實現的。SpreadGrid for WPF產品同樣支持百萬級的數據量,但是又不能給每個單元格都分配一個View。所以SpreadGrid使用了VirtualizingPanel來實現畫法。思路是每一個Visual是一個Cell的展示模塊,可以和Cell的數據模塊分離,這樣就只需要為顯示出來的Cell創建Visual。當發生滾動時會有一部分Cell滾出屏幕,有一部分Cell滾入屏幕。這時,讓滾出屏幕的Cell和Visual分離,然後再復用這部分Visual給新進入屏幕的Cell。如此循環,就只需要幾百個Visual就可以支持很多的Cell。
· 緩存
緩存(Cache)是性能優化中最常用的手段,針對需要頻繁的獲取一些數據,同時每次獲取數據需要的時間比較長的場景。如果使用了緩存的優化方法,需要特別注意緩存數據的同步:如果真實的數據發生了變化,應該及時的清除緩存數據,確保不會因為緩存而使用了錯誤的數據。
使用緩存的情況比較多。最簡單的情況就是緩存到一個Field或臨時變量裡。
for(int i = 0; i < gcMultiRow.RowCount; i++) { // Do something; }
以上代碼一般情況下是沒有問題的,但是,如果GcMultiRow的行數比較大。而RowCount屬性的取值又比較慢的時候,就需要使用緩存來做性能優化。
int rowCount = gcMultiRow.RowCount; for (int i = 0; i < rowCount; i++) { // Do something; }
使用對象池也是一個常見的緩存方案,比使用Field或臨時變量稍微復雜一點。例如,在MultiRow中,畫邊線,畫背景,需要用到大量的Brush和Pen。這些GDI對象每次用之前要創建,用完後要銷毀。創建和銷毀的過程是比較慢的。GcMultiRow使用的方案是創建一個GDIPool。本質上是一些Dictionary,使用顏色做Key。所以只有第一次取的時候需要創建,以後就直接使用以前創建好的。
以下是GDIPool的代碼:
public static class GDIPool { Dictionary<Color, Brush > _cacheBrush = new Dictionary<Color, Brush>(); Dictionary<Color, Pen> _cachePen = new Dictionary<Color, Pen>(); public static Pen GetPen(Color color) { Pen pen; if_cachePen.TryGetValue(color, out pen)) { return pen; } pen = new Pen(color); _cachePen.Add(color, pen); return pen; } }
· 懶構造
大多時候,對於創建需要花費較長時間的對象,往往並不是所有的場景下都需要使用。這時,使用懶構造的方法可以有效提高程序啟動性能。
舉例來說,對象A需要內部創建對象B。對象B的構造時間比較長。 一般做法:
public class A { public B _b = new B(); }
一般做法下,由於構造對象A的同時要構造對象B,導致A的構造速度也變慢了。
優化做法:
public class A { private B _b; public B BProperty { get { if(_b == null) { _b = new B(); } return _b; } } }
優化後,構造A的時候就不需要創建B對象,有效的提高了A的構造性能。
· 優化算法
優化算法可以有效的提高特定操作的性能。使用一種算法時應該了解算法的適用情況、最好情況和最壞情況。 以GcMultiRow為例,最初MultiRow的排序算法使用了經典的快速排序算法。這看起來是沒有問題的。但是,對於表格軟件,用戶經常的操作是對有序表進行排序,如順序和倒序之間切換。而經典的快速排序算法的最差情況就是基本有序的情況。所以經典快速排序算法不適合MultiRow。
改進的快速排序算法使用了3個中點來代替經典快排的一個中點的算法,每次交換都是從3個中點中選擇中間值。這樣,亂序和基本有序的情況都不是這個算法的最壞情況,從而優化了性能。
· 正確的使用既有數據結構
我們現在工作的.NET framework平台有很多現成的數據結構。我們應該了解這些數據結構,提升我們程序的性能。
例如:
1. String的加運算符和StringBuilder: 字符串的操作是我們經常遇到的基本操作之一。 我們經常會寫這樣的代碼 string str = str1 + str2。當操作的字符串很少的時候,這樣的操作沒有問題。但是如果大量操作的時候(例如文本文件的Save/Load, Asp.net的Render),這樣做就會帶來嚴重的性能問題。這時,我們就應該用StringBuilder來代替string的加操作。
2. Dictionary和List: Dictionary和List是最常用的兩種集合類。選擇正確的集合類可以很大的提升程序的性能。為了做出正確的選擇,我們應該對Dictionary和List的各種操作的性能比較了解。 下表中粗略的列出了兩種數據結構的性能比較。
操作
List
Dictionary
索引
快
慢
Find(Contains)
慢
快
Add
快
慢
Insert
慢
快
Remove
慢
快
3. TryGetValue: 對於Dictionary的取值,比較直接的方法是如下代碼:
if(_dic.ContainKey("Key") { return _dic["Key"]; }
當需要大量取值的時候,這樣的取法會帶來性能問題。優化方法如下:
object value; if(_dic.TryGetValue("Key", out value)) { return value; }
後一種用法要比前一種用法取值性能提高一倍。
4. 為Dictionary選擇合適的Key: Dictionary的取值性能很大情況下取決於做Key的對象的Equals和GetHashCode兩個方法的性能。如果可以的話,使用Int做Key性能最好。如果是一個自定義的Class做Key的話,最好保證以下兩點:1. 不同對象的GetHashCode重復率低。2. GetHashCode和Equals方法簡單,效率高。
5. List的Sort和BinarySearch性能很好,如果能滿足功能需求,推薦直接使用。
List<int> list = new List<int>{3, 10, 15};
list.BinarySearch(10); // 對於存在的值,結果是1
list.BinarySearch(8); // 對於不存在的值,會使用負數表示位置,
// 如查找8時,結果是-2, 查找0結果是-1,查找100結果是-4.
· 通過異步提升響應時間
1. 多線程
有些操作確實需要花費比較長的時間。在處理的過程中,如果用戶進行操作時失去響應,這個用戶體驗是很差的。使用多線程技術可以解決這個問題。例如,有一個類似Excel的計算引擎,在構造的時候要初始化所有的函數定義。由於函數比較多,初始化時間會比較長。這是如果用到了多線程,在工作線程中做函數定義進行的初始化,就不會影響到UI線程快速響應用戶的其他操作了。
代碼如下:
public CalcParser() { if (_functions == null) { lock (_obtainFunctionLocker) { if (_functions == null) { System.Threading.ThreadPool.QueueUserWorkItem((s) => { if (_functions == null) { lock (_obtainFunctionLocker) { if (_functions == null) { _functions = EnsureFunctions(); } } } }); } } } }
這裡比較慢的操作就是EnsureFunctions函數,是在另一個線程裡執行的,不會影響主線程的響應。當然,使用多線程是一個比較有難度的方案,需要充分考慮跨線程訪問和死鎖的問題。
2. 加延遲時間
在GcMultiRow實現AutoFilter功能的時候使用了一個類似於延遲執行的方案來提升響應速度。AutoFilter的功能是用戶在輸入的過程中根據用戶的輸入更新篩選的結果。數據量大的時候一次篩選需要較長時間,會導致用戶輸入不流暢,體驗不好。使用多線程雖然是個好方案,但是會增加程序的復雜度。MultiRow的解決方案是當接收到用戶的鍵盤輸入消息的時候,並不立即出發Filter,而是等待0.3秒。如果用戶連續輸入,會在這0.3秒內再次收到鍵盤消息,放棄上一個任務,再等0.3秒,直到連續0.3秒內沒有新的鍵盤消息時再觸發Filter。這樣就實現了比較流暢的用戶體驗。
3. Application.Idle事件
在GcMultiRow的Designer裡,經常要根據當前的狀態刷新ToolBar上按鈕的Disable/Enable狀態,一次刷新需要較長的時間。這個又一次影響了用戶輸入的流暢性。GcMultiRow的優化方案是通過系統的Application.Idle事件,僅當系統空閒的時候處理刷新邏輯。接到這個事件時,一般都是用戶已經完成了連續的輸入,這時就可以從容的刷新按鈕的狀態了。
4. Refresh, BeginInvoke
平台本身也提供了一些異步方案。例如在WinForm下觸發一塊區域重畫的時候,調用Refresh方法不會導致立即重畫,而是設置Invalidate標記,觸發異步的刷新。在控件開發中,這個技巧可以有效的提高產品的性能,同時簡化實現復雜度。
Control.BeginInvoke方法可以被用來觸發異步的自定義行為。
· 進度條,提升用戶體驗
有時候,以上提到的方案都沒有辦法快速響應用戶操作。進度條、一直轉圈圈的圖片、提示性文字(如"你的操作可能需要較長時間,請耐心等待")等,都可以有效的提升用戶體驗,可以作為最後方案來考慮。
胡森,葡萄城公司軟件工程師,PowerTools產品系列項目經理。長期專注於 .NET UI 控件產品的設計和開發。致力於為廣大.NET開發者提供強大,高效,易用的控件產品。
感謝趙劼對本文的審校。