背景
最近跟售後經理吃飯,他跟我再次談起兩年前為公司臨時寫的一個客戶端,仍然非常激動的跟我說,這個客戶端完爆了公司其他版本的客戶端,包括最老的Delphi寫的,Asp.Net寫的,以及最新的Wpf寫的客戶端。無論是多麼大的介面(整合的機房多),這個系統都是瞬間開啟,而且執行非常穩定,一旦成功部署之後基本沒有任何問題。
這個版本的客戶端僅僅只是一個臨時替代的版本:原來的Delphi客戶端實在是太慢了,在大型的資料中心監控中需要4~5分鐘才能進入主監控介面,而asp.net版本的客戶端又經常存在不穩定的情況(IE瀏覽器不支援7*24小時的非同步重新整理),最新的Wpf客戶端又還在設計階段,於是臨危受命決定開發一個臨時過渡版本,當時也只是開發了一個月,沒想到竟然如此成功,至今仍讓我們的售後部門津津樂道。這中間其實沒有太多高深的技術,但是卻有很多的開發技巧以及程式設計的思想。我至今仍然看到很多人都在犯這麼一些簡單的錯誤(例如VS2010工具箱的載入項),導致他們的系統非常緩慢,但是他們卻總是抱怨是程式語言的問題,是windows系統的問題,是機器的效能不行……
我決定把我的一些實踐經驗跟大家分享:不是非得你有多麼牛逼的技術,才能做出一個穩定快速的系統,更多的時候,它取決於你是否有一個產品的意識,是否讓你的軟體真正貼近使用者。
系統介面與功能
先來看看原來的系統介面是怎樣子的:
其功能如下,我新寫的客戶端增加了支援生成OCX控制元件的功能:
整個系統的物理架構是這樣的:
原系統存在的問題
- 載入主頁面慢
隨著介面數量的增加,會需要更多的載入時間
隨著地點和裝置的增加,載入會需要更多的時間
- 頁面之間切換卡
- 資料顯示慢
- 地點的報警狀態顯示不準確且存在延遲
- 報警併發較多時卡頓更嚴重
客戶端效能優化的基本手法
我們來看看通過一些什麼手法能夠解決原來的系統存在的這些問題。
按需獲取
大部分的情況下,我們其實所能看到的東西都是極其有限的,無論系統是多麼龐大,功能多麼的豐富,其實呈現給使用者的都是極其有限的。
監控介面的按需獲取
前面說了,監控主介面裡的介面都是組態的,是由工程師拖拉控制元件上去實現的,大家也看到上面圖形還算豐富,主要是使用了大量的圖片,因此我們系統中在儲存這些組態介面的時候,同時也儲存了介面圖片的位元組流。大型的資料中心由於介面較多,這些介面加起來是可能會超過1G大小的。這麼大的介面,如果都是直接載入到介面中,首先就要費不少的時間,即使是在內網的情況下,假設你網路能夠1s下載20M左右,也要50秒,接近1分鐘,遇上網路高峰,花個1~2分鐘並不奇怪。
我們是否有必要把所有介面都載入進來呢,當然沒有。我們只需載入第一個介面,其他介面在需要的時候(使用者點選或者發生告警需要跳轉的時候)才載入,這樣我們的速度裡面就提升了,這就是按需載入!
當然說的輕巧,實際做的會有很多問題。比如,如何實現不實現頁面又能知道該頁面是否告警(必須解析每個介面上的控制元件,才能知道某個介面包含了哪些控制元件,才知道監控指標告警在哪個介面上)?
我的步驟如下:
- 儲存介面的時候,把介面上的控制元件的Id列表儲存到裝置記錄中
- 載入時只載入所有的裝置記錄(名稱+控制元件Id列表)
- 把對應的資訊附加到樹形節點中
- 根據對應的樹形節點的告警資訊在需要顯示介面時生成介面
按需重新整理介面上的資料
做監控系統,除了告警頁面必須實時通知到客戶外,監控資料介面,其實只需展示當前顯示頁面的資料即可。
怎麼做呢,我們可以提供一個單獨的程式來管理所有接收到的資料,然後再提供一個獲取當前資料的介面給客戶端,具體請看下面更改的架構。
有些人可能會疑問,為什麼不直接在採集器中提供這個介面呢?因為這是組態介面,介面上的控制元件要取哪個採集器的資料是未知的,所以把資料放在一起統一管理會更加方便。而且採集器可以7*24小時工作,而客戶端是經常要開啟關閉的……
VS2010中的反例
如果用過VS2010開發自定義的Winform元件,那麼大家對它的工具箱載入自定義元件這個功能肯定印象深刻,每次選擇新增項,然後選擇自定義控制元件dll的時候,都非常痛苦,尤其我電腦比較忙而又裝了不少外掛的情況下,為了一個非常簡單的功能,我需要花費4分多的時間來開啟那個選擇檔案的介面,這個介面載入了一大堆我絕大多數時候都用不上的COM元件,我實在沒法想象開發這個功能的程式猿是怎麼想的。還好,在VS2013中微軟總算是改進了這個功能,但是做得還不夠。按我的想法,完全可以把COM元件部分非同步載入,給出正在載入的提示即可,可以立即顯示“選擇”按鈕,這樣體驗性立即上升了一個層次。
延遲載入
延遲載入是指用到的時候,再去進行實際的構建。
樹形選單的延遲載入
樹形選單的樹形節點的構建就是一個最適合解釋的例子。大家可以嘗試載入1000個樹形節點然後構建成一棵樹,看看在Winform中需要多長的時間。我們的實際中有沒有必要這麼去做呢?
各位可以思考下自己檢視樹形導航的時候,是不是從根節點到子節點最後到葉子節點這樣一步步看下去的,大部分的時候,其實我們只需首先看到根節點即可。例如下面這個:
對於這種情況,我們完全可以把樹形節點都獲取,但是先只建立只有根節點的一棵樹,在使用者點選之後載入子節點,如果已判斷過,則不執行載入的操作。基本的方法是在Tag中附加一個欄位指示子節點是否已經載入,參考程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private void TreeDevices_BeforeSelect(object sender, TreeViewCancelEventArgs e) { var myNode = e.Node.Tag as NTier.Model.MyTreeNode; if (myNode == null) return; if (myNode.IsSubNodeLoaded == false) { //還沒有載入資料,主要是指機房節點 LoadNodesOfSubMainForm(e.Node, myNode); //載入樹形子節點 } //已載入了資料,則生成相應的介面 LoadFormModel(myNode); } |
這裡延遲載入與按需載入有點類似,區別是,延遲載入必須把所有資料載入進來,但是並不構建成一棵UI樹,而是在用到的時候再去生成。
右鍵延遲初始化
另一個地方就是每個控制元件的右鍵選單。因為每個右鍵選單顯示的內容是需要根據控制元件的型別以及相關的許可權來判斷的,但是我們看到右鍵選單的時候一定是人為進行操作才能顯示出來,因此沒有必要再介面生成的過程中去為每個控制元件生成對應的右鍵選單,而是在彈出右鍵選單時進行相關的判斷,延遲右鍵選單的生成。
化曲為直
我們知道,如果要檢視一棵樹的所有節點,常用的方法就是使用遞迴進行廣度遍歷或者深度遍歷。但是,在樹形節點較多的時候,遍歷其實是非常耗時的。在我們這個系統中,告警是必須要最先處理的,因此,我在系統中使用Dictionary
型別快取了每個屬性節點與它相關聯的資料型別(ID
),從而能夠在發生告警時馬上定位到指定樹形節點。
快取,還是快取
快取介面
我們系統是組態的介面,這就限制了介面的生成必須是動態的。如果我們採用按需載入的方式,那麼介面的生成就是實時的,怎麼能夠做到快速的進行頁面的切換呢?
1 |
var tempPanel = _panelCache.CreatePanel(this, formModel, myNode.AgentBm); //建立Panel |
在這裡,我專門寫了一個介面的快取類,如果沒有快取,則動態建立,如果有快取,就直接返回快取的介面。同時,根據介面的最新的開啟時間和點選次數,對快取的介面進行管理。我們知道,整個大型系統中,其實使用者關注的介面也是有限的,一般他們只會關注最重要的幾個介面,最常用的也是這幾個介面。通過快取的管理,不但能夠實現介面之間的快速切換,同時也減少了系統佔用的記憶體。我整個客戶端程式檔案大小壓縮之後在500k之內,而執行期間佔用記憶體基本維持在50M左右。
快取資料
檢視上面改造過後的架構,我們知道現在獲取資料是在開啟介面之後再去獲取,直到建立連線並取得資料之後,才能在介面上顯示,這個過程一般會耗時1~2秒,網路差的情況會更糟。怎樣才能讓使用者更為快速的確定我們的系統已經執行了呢?這裡我們通過一個簡單的辦法,集中服務端通過定時把當前監控到的資料寫入控制元件的屬性中,在系統載入控制元件的同時把這個值顯示出來,這樣可以看起來好像是系統馬上獲取到了資料。而由於快取的值是定時把最新值寫入進去的,這種做法在很大程度上保證了快取中的數值是正確的。
非同步,還是非同步
非同步是提高程式響應和使用者體驗的不二法寶。C#中的控制元件和大部分流操作類等都提供了支援非同步操作的方法:BeginXXX
和EndXXX
.它的原理也非常簡單,使用BeginXXX
時,把操作加入執行緒池,執行完成之後呼叫一個回撥函式。
一個使用者體驗良好的系統,應該能夠合理的使用非同步操作,確保執行UI更新時以及執行耗時的操作時不會阻塞。大部分人在寫程式碼的時候,總是直接進行呼叫,在控制元件較少或者完成簡單任務的時候,你一般都感覺不出來,但是在控制元件數量多的時候,我們很容易就感覺到介面卡,不流暢。
我在新系統開發的時候,就有意識的在控制元件載入、控制元件資料重新整理、控制元件告警狀態切換等操作中使用了非同步的操作,讓系統在開啟介面時完全感覺不到卡的跡象。
不過使用非同步要時刻記得,非同步可以提高使用者體驗性,但是不會有效能上的實質提升,如果感覺到資料響應有延遲,你還是得花功夫找到根本的原因。
歸併處理
介面資料重新整理歸併處理
我們來看看原來介面是怎麼重新整理資料的:
開啟介面->重新整理資料->新建一個執行緒->定時重新整理資料->關閉介面->關閉執行緒。
對Windows系統有足夠了解的人都知道,新開一個執行緒都是非常耗費資源的。這種情況,我們是可以在整個系統中,提供一個統一管理的重新整理執行緒,只需對當前需要重新整理的介面進行重新整理即可:
重新整理執行緒->判斷當前介面是否存在->定時重新整理資料
結合上述的非同步操作,我們的控制元件在重新整理資料的時候非常的流暢。
告警跳轉歸併處理
上面我們提到了,在系統發生告警時,必須要跳轉到報警的頁面,這個機制在大量告警併發的時候,就會有非常大的問題,很可能我們的系統就會在不同的介面中進行跳轉而卡死。對於系統的使用者來說,在1~3秒內的多個告警,我們其實可以處理為一個告警,我們只需往最後一個告警發生的頁面跳轉即可,這樣既達到了相應的效果,也減少了系統的壓力。這就是告警併發時的歸併處理。
視覺欺騙
在一些情況中,我們確實短時間沒有辦法對效能進行提升了,花費的時間卻要要這麼多,這種情況下,我們有些什麼好的做法呢?
給出提示資訊或者進度條
如果大家經常用手機登陸微博、微信等,肯定對這些app載入圖片有過一些體會,尤其如果你是在網路較差的情況下,同樣是要等1分鐘才能載入出圖片,如果這個app沒有任何提示,那麼,過了30秒或者20秒,你就有可能受不了把他點掉了,因為你感覺它似乎已經過了幾分鐘,還有可能遙遙無期;而如果這個app能夠提示當前下載的位元組數、當前下載的進度,那麼,1分鐘的等待,你似乎也能接受,這畢竟是網路引起的問題。這就是一種視覺上的欺騙。
在一個系統的載入過程中,有提示資訊和沒提示資訊,有進度條和沒進度條,給人感覺的速度是不一樣的,即使從實際的情況來看這兩者沒有任何差別。
偷偷載入
很多時候,我們系統的執行需要從伺服器中獲取一些最新的資料,以支撐基本的執行。這部分時間是你必不可少的,很多人都認為這是沒有任何辦法優化的,其實不然。我們很多程式其實都提供了一個使用者名稱和密碼的輸入框,其實在使用者輸入的過程中,我們還是可以利用的。在彈出登陸窗到輸入賬號和密碼到登入系統的過程中,一般都會有3~5秒的時間。
我看到很多人寫程式,彈出登入框就老老實實的彈出,然後在輸入完使用者名稱和密碼之後在進行資料的獲取和載入,實際上,我們已經浪費了這些時間。如果你能有效利用這3~5秒,那麼,你就已經贏在了起跑線。
簡化資料
視覺欺騙的另外一個重要運用,就是在曲線的渲染中。在機房監控中,我們有些裝置的監控比較頻繁,一天產生的資料高達幾萬條,把這麼多的資料繪製到一條24小時的曲線上,我們將會看到很多密密麻麻的點,繪製這些點非常的耗時和耗資源。而我們提供曲線給使用者檢視的目的是什麼呢,是想檢視一天的趨勢變化,過多密集的點其實是沒有必要的,大家看看下圖,如果資料點更多的話,第二個曲線會更加密集,看起來會像一條粗大的直線:
通過簡單演算法對曲線進行壓縮之後,顯示歷史趨勢的速度非常的快,非常的流暢。我們對比上面兩條曲線,其實對使用者來說,或許更喜歡第一條曲線,因為他反應的趨勢更為優美,有木有?
使用單元測試輔助開發
在我的博文中,我一直強調使用單元測試,無論是開發還是重構。我覺得這個無論是怎麼強調都不為過的。
在開發的過程,我們應該有意識的按單元測試的目的來構建我們的函式、類、以及程式集,如果你的函式符合單元測試要求的話,一般都是比較容易重構和維護的。另外,我們開發的過程中,很多時候需要驗證某個功能是否可用,使用單元測試,將會很快速的幫你完成這個驗證操作。我看我們很多程式設計師開發效率都不高,尤其在開發一個大型系統的時候,喜歡把整個系統開起來除錯,或者是在系統裡面加上各種配置或者條件編譯來進行除錯,這種習慣非常不好。在程式中加入配置容易讓程式結構出現混亂,程式碼的閱讀體驗也不好,很多時候如果我們忘記去掉這個配置,很容易就對釋出的系統產生較大的影響。
使用單元測試另外一個好處是,我們可以隨時針對某個方法進行效能上的測試,發現哪些程式碼對我們的系統造成了較大的影響。我習慣連私有的函式也一起加入測試,以下是呼叫私有函式的一個輔助方法:
1 2 3 4 5 6 7 |
public static object InvokePMethod(Type type, string methodName, object classInstance, object[] @params) { const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; var methodInfo = type.GetMethod(methodName, flags); var result = methodInfo.Invoke(classInstance, @params); return result; } |
提供完善的日誌資訊
在日常的開發中,我一直跟我的同事強調日誌的重要性。相信有一定開發經驗的都知道在系統中寫日誌,但是,怎麼把日誌寫好,很多人都把握不了。在這裡我提幾點建議:
- 按日誌的重要性和詳細程度劃分級別
- 提供除錯級別和執行級別的日誌
- 注意記錄系統資訊和配置資訊
- 在狀態變化時進行記錄
- 把相同的資訊進行合併
- 能夠反應程式執行的業務邏輯
之前我們的系統是自己實現的日誌元件,我用C#重寫時,引入了NLog日誌元件,我覺得這個日誌元件非常好用。另外,我還專門提供了一個UI介面的除錯窗,以便實施工程師在現場除錯的時候能夠快速定位問題。
在實際執行的過程中,因為有良好的日誌資訊,我很快能夠排查很多的問題,而大部分的問題都是因為配置導致的。我一致跟研發的同事強調,儘可能的不要相信現場工程師給你的判斷,應該要現場工程師提供證據給你,而要提供什麼樣的證據,作為一個研發,你才是最清楚的。好的日誌系統應該能夠根據日誌資訊精確的定位到問題,在離線的情況下能夠最大程度的反應當前系統的配置、執行狀態、以及錯誤資訊。
優化的結果
最終用C#重寫的客戶端在各方面變現都非常的好,系統非常穩定,整個系統進入在2s左右,頁面切換在1s左右,最重要的是,客戶端跟系統的大小沒有關係,適應大小的資料中心。我們看看新老系統在載入過程中的一個對比:
很明顯,通過上述手法進行一些優化之後,我們的系統在各個步驟都有了提升,而且通過非同步、快取、欺騙等方式讓一些步驟可以同步進行,大大加快了系統的載入和相應。
總結
我希望通過這篇文章,把客戶端優化的一些方法分享出來,供大家參考。這其中沒有什麼高深的知識,也沒有說要你必須採用怎樣的程式語言,僅僅是通過一些簡單的手法,並綜合應用,就能把一個系統的響應速度從4分鐘提升到只需兩秒。當然,我們還有其他很多的方法,比如分散式……無論是什麼樣的技巧,我覺得有一些基本的原則是要遵循的:
- 站在使用者的角度思考問題
- 永遠不要把選擇交給使用者
- 必須考慮最極端惡劣的情況
回顧一下這篇文章講的內容:
- 加快系統響應的基本手法
按需獲取
延遲載入
化曲為直
快取
非同步
歸併處理
視覺欺騙
給出提示資訊或者進度條
偷偷載入
簡化資料
- 程式穩定性
使用單元測試
提供完善的日誌資訊