從四分鐘到兩秒——談談客戶端性能優化的一些最佳實踐

從四分鐘到兩秒——談談客戶端性能優化的一些最佳實踐

背景

最近跟售後經理吃飯,他跟我再次談起兩年前爲公司臨時寫的一個客戶端,仍然非常激動的跟我說,這個客戶端完爆了公司其他版本的客戶端,包括最老的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中附加一個字段指示子節點是否已經加載,參考代碼如下:

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),從而能夠在發生告警時馬上定位到指定樹形節點。

緩存,還是緩存

緩存界面

我們系統是組態的界面,這就限制了界面的生成必須是動態的。如果我們採用按需加載的方式,那麼界面的生成就是實時的,怎麼能夠做到快速的進行頁面的切換呢?

var tempPanel = _panelCache.CreatePanel(this, formModel, myNode.AgentBm); //創建Panel

在這裏,我專門寫了一個界面的緩存類,如果沒有緩存,則動態創建,如果有緩存,就直接返回緩存的界面。同時,根據界面的最新的打開時間和點擊次數,對緩存的界面進行管理。我們知道,整個大型系統中,其實用戶關注的界面也是有限的,一般他們只會關注最重要的幾個界面,最常用的也是這幾個界面。通過緩存的管理,不但能夠實現界面之間的快速切換,同時也減少了系統佔用的內存。我整個客戶端程序文件大小壓縮之後在500k之內,而運行期間佔用內存基本維持在50M左右

緩存數據

查看上面改造過後的架構,我們知道現在獲取數據是在打開界面之後再去獲取,直到建立連接並取得數據之後,才能在界面上顯示,這個過程一般會耗時1~2秒,網絡差的情況會更糟。怎樣才能讓用戶更爲快速的確定我們的系統已經運行了呢?這裏我們通過一個簡單的辦法,集中服務端通過定時把當前監控到的數據寫入控件的屬性中,在系統加載控件的同時把這個值顯示出來,這樣可以看起來好像是系統馬上獲取到了數據。而由於緩存的值是定時把最新值寫入進去的,這種做法在很大程度上保證了緩存中的數值是正確的。

異步,還是異步

異步是提高程序響應和用戶體驗的不二法寶。C#中的控件和大部分流操作類等都提供了支持異步操作的方法:BeginXXXEndXXX.它的原理也非常簡單,使用BeginXXX時,把操作加入線程池,執行完成之後調用一個回調函數。

一個用戶體驗良好的系統,應該能夠合理的使用異步操作,確保執行UI更新時以及執行耗時的操作時不會阻塞。大部分人在寫代碼的時候,總是直接進行調用,在控件較少或者完成簡單任務的時候,你一般都感覺不出來,但是在控件數量多的時候,我們很容易就感覺到界面卡,不流暢。

我在新系統開發的時候,就有意識的在控件加載、控件數據刷新、控件告警狀態切換等操作中使用了異步的操作,讓系統在打開界面時完全感覺不到卡的跡象。

不過使用異步要時刻記得,異步可以提高用戶體驗性,但是不會有性能上的實質提升,如果感覺到數據響應有延遲,你還是得花功夫找到根本的原因。

歸併處理

界面數據刷新歸併處理

我們來看看原來界面是怎麼刷新數據的:

打開界面->刷新數據->新建一個線程->定時刷新數據->關閉界面->關閉線程。

對Windows系統有足夠了解的人都知道,新開一個線程都是非常耗費資源的。這種情況,我們是可以在整個系統中,提供一個統一管理的刷新線程,只需對當前需要刷新的界面進行刷新即可:

刷新線程->判斷當前界面是否存在->定時刷新數據

結合上述的異步操作,我們的控件在刷新數據的時候非常的流暢。

告警跳轉歸併處理

上面我們提到了,在系統發生告警時,必須要跳轉到報警的頁面,這個機制在大量告警併發的時候,就會有非常大的問題,很可能我們的系統就會在不同的界面中進行跳轉而卡死。對於系統的用戶來說,在1~3秒內的多個告警,我們其實可以處理爲一個告警,我們只需往最後一個告警發生的頁面跳轉即可,這樣既達到了相應的效果,也減少了系統的壓力。這就是告警併發時的歸併處理。

視覺欺騙

在一些情況中,我們確實短時間沒有辦法對性能進行提升了,花費的時間卻要要這麼多,這種情況下,我們有些什麼好的做法呢?

給出提示信息或者進度條

如果大家經常用手機登陸微博、微信等,肯定對這些app加載圖片有過一些體會,尤其如果你是在網絡較差的情況下,同樣是要等1分鐘才能加載出圖片,如果這個app沒有任何提示,那麼,過了30秒或者20秒,你就有可能受不了把他點掉了,因爲你感覺它似乎已經過了幾分鐘,還有可能遙遙無期;而如果這個app能夠提示當前下載的字節數、當前下載的進度,那麼,1分鐘的等待,你似乎也能接受,這畢竟是網絡引起的問題。這就是一種視覺上的欺騙。

在一個系統的加載過程中,有提示信息和沒提示信息,有進度條和沒進度條,給人感覺的速度是不一樣的,即使從實際的情況來看這兩者沒有任何差別。

偷偷加載

很多時候,我們系統的運行需要從服務器中獲取一些最新的數據,以支撐基本的運行。這部分時間是你必不可少的,很多人都認爲這是沒有任何辦法優化的,其實不然。我們很多程序其實都提供了一個用戶名和密碼的輸入框,其實在用戶輸入的過程中,我們還是可以利用的。在彈出登陸窗到輸入賬號和密碼到登入系統的過程中,一般都會有3~5秒的時間。

我看到很多人寫程序,彈出登錄框就老老實實的彈出,然後在輸入完用戶名和密碼之後在進行數據的獲取和加載,實際上,我們已經浪費了這些時間。如果你能有效利用這3~5秒,那麼,你就已經贏在了起跑線。

簡化數據

視覺欺騙的另外一個重要運用,就是在曲線的渲染中。在機房監控中,我們有些設備的監控比較頻繁,一天產生的數據高達幾萬條,把這麼多的數據繪製到一條24小時的曲線上,我們將會看到很多密密麻麻的點,繪製這些點非常的耗時和耗資源。而我們提供曲線給用戶查看的目的是什麼呢,是想查看一天的趨勢變化,過多密集的點其實是沒有必要的,大家看看下圖,如果數據點更多的話,第二個曲線會更加密集,看起來會像一條粗大的直線:

通過簡單算法對曲線進行壓縮之後,顯示歷史趨勢的速度非常的快,非常的流暢。我們對比上面兩條曲線,其實對用戶來說,或許更喜歡第一條曲線,因爲他反應的趨勢更爲優美,有木有?

使用單元測試輔助開發

在我的博文中,我一直強調使用單元測試,無論是開發還是重構。我覺得這個無論是怎麼強調都不爲過的。

在開發的過程,我們應該有意識的按單元測試的目的來構建我們的函數、類、以及程序集,如果你的函數符合單元測試要求的話,一般都是比較容易重構和維護的。另外,我們開發的過程中,很多時候需要驗證某個功能是否可用,使用單元測試,將會很快速的幫你完成這個驗證操作。我看我們很多程序員開發效率都不高,尤其在開發一個大型系統的時候,喜歡把整個系統開起來調試,或者是在系統裏面加上各種配置或者條件編譯來進行調試,這種習慣非常不好。在程序中加入配置容易讓程序結構出現混亂,代碼的閱讀體驗也不好,很多時候如果我們忘記去掉這個配置,很容易就對發佈的系統產生較大的影響。

使用單元測試另外一個好處是,我們可以隨時針對某個方法進行性能上的測試,發現哪些代碼對我們的系統造成了較大的影響。我習慣連私有的函數也一起加入測試,以下是調用私有函數的一個輔助方法:

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分鐘提升到只需兩秒。當然,我們還有其他很多的方法,比如分佈式……無論是什麼樣的技巧,我覺得有一些基本的原則是要遵循的:

  • 站在用戶的角度思考問題
  • 永遠不要把選擇交給用戶
  • 必須考慮最極端惡劣的情況

回顧一下這篇文章講的內容

  • 加快系統響應的基本手法

    • 按需獲取
    • 延遲加載
    • 化曲爲直
    • 緩存
    • 異步
    • 歸併處理
    • 視覺欺騙

      • 給出提示信息或者進度條
      • 偷偷加載
      • 簡化數據
  • 程序穩定性

    • 使用單元測試
    • 提供完善的日誌信息

引用自:http://www.cnblogs.com/marvin/p/WinformOptimizSkill.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章