性能优化实践

性能优化实践

性能是考量一个控件产品好坏的重要指标,与产品的功能有着同等重要的地位。用户在选择一款控件产品的时候基本都会亲身试验比较同类产品的性能。作为选购那个控件重要因素之一。

控件的性能指什么。

1.
降低内存消耗


在控件开发中,内存消耗一般作为次要的考虑,因为现在的计算机一般都拥有比较大的内存,很多情况下,性能优化的手段就是空间换取时间。但是,并不是说,我们可以肆无忌惮的挥霍内存。如果需要支持在大数据量的用例时,如果内存被耗尽,操作系统会发生频繁的内外存交换。导致执行速度急剧下降。

2. 提升执行速度
a.

加载速度。

b.
特定操作的响应速度。包括,点击,键盘输入,滚动,排序过滤等。


性能优化的原则

1.
理解需求



MultiRow
产品为例,
MultiRow
的一个性能需求是:

百万行数据绑定下平滑滚动。

整个
MultiRow
项目的开发过程一直要考虑这个目标。

2.
理解瓶颈


根据经验,
99%
的性能消耗是由于
1%
的代码造成的。所以,大部分性能优化都是针对这
1%
的瓶颈代码进行的。具体实施也就分为两步。首先,确定瓶颈,其次消除瓶颈。

3.
切忌过度


首先必须要认识到,性能优化本身是有成本的。这个成本不单单体现在做性能优化所付出的工作量。还包括为性能优化而写出的复杂代码,额外的维护成本,会引入新的
Bug
,额外的内存开销等。

一个常见问题是,一些刚接触控件开发的同学会对一些不必要的点生搬硬套性能优化技巧或者设计模式,带来不必要的复杂度。性能优化常常需要对收益和成本之间做出权衡。

如何发现性能瓶颈。

上一节提到,性能优化的第一步就是发现性能瓶颈,这一节主要介绍定位性能瓶颈的一些实践。

1.
如何获取内存消耗


以下代码可以获取某个操作的内存消耗。

复制代码

  1. long start = GC.GetTotalMemory(true);// 在这里写一些可能消耗内存的代码,例如,如果想了解创建一个GcMultiRow控件需要多少内存可以执行以下代码
  2. var gcMulitRow1 = new GcMultiRow();
  3. GC.Collect();
  4. GC.WaitForFullGCComplete(); // 确保所有内存都被GC回收
  5. long end = GC.GetTotalMemory(true);
  6. long useMemory = end – start;

2.
如何获取时间消耗


以下代码可以获取某个操作时间消耗。

复制代码

  1. System.Diagnostics.Stopwatch watch = new System.Diagnostics.Stopwatch();
  2. watch.Start();
  3. for (int i = 0; i < 1000; i++)
  4. {
  5.      gcMultiRow1.Sort();
  6. }
  7. watch.Stop();
  8. var useTime = (double)watch.ElapsedMilliseconds / 1000;

这里把一个操作循环执行了
1000
次,最后再把消耗的时间除以
1000
来确定最终消耗的时间。可以是结果更准确稳定,排除意外数据。

3.
通过
CodeReview
发现性能问题。


很多情况下,可以通过
CodeReview
发现性能问题。对于大数据量的循环,要格外关注。循环内的逻辑应该执行的尽可能的快。

4.
Auts Performance Profiler

Auts Profiler

是款功能强大的性能检测软件。可以很好的帮助我们发现性能瓶颈。使用这款软件定位性能瓶颈可以起到事半功倍的效果。熟练使用这个工具,我们可以快速准确的定位到有性能问题的代码。

这个工具很强大,但是也并不是完美无缺的。首先,这是一款收费软件,部门只有几个许可号。其次,这个软件的工作原理是在
IL
中加入一些钩子,用来记录时间。所以在分析时,软件的执行速度会比实际运行慢一些获得的数据也因此并不是百分之百的准确,应该把软件分析的数据作为参考,帮助快速定位问题,但是不要完全依赖,还要结合其他技巧来分析程序的性能。

性能优化的方法和技巧。

定位了性能问题后,解决的办法有很多。这个章节会介绍一些性能优化的技巧和实践。

1.
优化程序结构


对于程序结构,在设计时就应该考虑,评估是否可以达到性能需求。如果后期发现了性能问题需要考虑调整结构会带来非常大的开销。举例:

a.
GcMultiRowGcMultiRow
要支持
100
万行数据,假设每行有
10
列的话,就需要有
1000
万个单元格,每个单元格上又有很多的属性。如果不做任何优化的话,大数据量时,一个
GcMultiRow
控件的内存开销会相当的大。
GcMultiRow
采用的方案是使用哈希表来存储行数据。只有用户改过的行放到哈希表里,而对于大部分没有改过的行都直接使用模板代替。就达到了节省内存的目的。

b. Spread for WPF/Silverlight

SSL

WPF
的画法和
Winform
不同,是通过组合
View
元素的方法实现的。
SSL
同样支持百万级的数据量,但是又不能给每个单元格都分配一个
View
。所以
SSL
使用了
VirtualizePanel
来实现画法。思路是每一个
View
是一个
Cell
的展示模块。可以和
Cell
的数据模块分离。这样。只需要为显示出来的
Cell
创建
View
。当发生滚动时会有一部分
Cell
滚出屏幕,有一部分
Cell
滚入屏幕。这时,让滚出屏幕的
Cell

View
分离。然后再复用这部分
View
给新进入屏幕的
Cell
。如此循环。这样只需要几百个
View
就可以支持很多的
Cell

2.
缓存


缓存
(Cache)
是性能优化中最常用的优化手段
.
适用的情况是频繁的获取一些数据,而每次获取这些数据需要的时间比较长。这时,第一次获取的时候会用正常的方法,并且在获取之后把数据缓存下来。之后就使用缓存的数据。

如果使用了缓存的优化方法,需要特别注意缓存数据的同步,就是说,如果真实的数据发生了变化,应该及时的清除缓存数据,确保不会因为缓存而使用了错误的数据。

举例:

a. 使用缓存的情况比较多。最简单的情况就是缓存到一个

Field
或临时变量里。

复制代码

  1. for(int i = 0; i < gcMultiRow.RowCount; i++)
  2. {
  3.     // Do something;
  4. }

以上代码一般情况下是没有问题的,但是,如果
GcMultiRow
的行数比较大。而
RowCount
属性的取值又比较慢的时候就需要使用缓存来做性能优化。

复制代码

  1. int rowCount = gcMultiRow.RowCount;
  2. for (int i = 0; i < rowCount; i++)
  3. {
  4.    // Do something;
  5. }

b. 使用对象池也是一个常见的缓存方案,比使用
Field
或临时变量稍微复杂一点。

例如,在
MultiRow
中,画边线,画背景,需要用到大量的
Brush

Pen
。这些
GDI
对象每次用之前要创建,用完后要销毁。创建和销毁的过程是比较慢的。
GcMultiRow
使用的方案是创建一个
GDIPool
。本质上是一些
Dictionary
,使用颜色做
Key
。所以只有第一次取的时候需要创建,以后就直接使用以前创建好的。以下是
GDIPool
的代码:

复制代码

  1. public static class GDIPool
  2. {
  3.     Dictionary<Color, Brush > _cacheBrush = new Dictionary<Color, Brush>();
  4.     Dictionary<Color, Pen> _cachePen = new Dictionary<Color, Pen>();
  5.     public static Pen GetPen(Color color)
  6.    {
  7.        Pen pen;
  8.        if_cachePen.TryGetValue(color, out pen))
  9.        {
  10.            return pen;
  11.        }
  12.        pen = new Pen(color);
  13.         _cachePen.Add(color, pen);
  14.        return pen;
  15.    }
  16. }

3.
懒构造


有时候,有的对象创建需要花费较长时间。而这个对象可能并不是所有的场景下都需要使用。这时,使用赖构造的方法可以有效提高性能。

举例:对象
A
需要内部创建对象
B
。对象
B
的构造时间比较长。

一般做法:

复制代码

  1. public class A
  2. {
  3.    public B _b = new B();
  4. }

一般做法下由于构造对象
A
的同时要构造对象
B
导致了
A
的构造速度也变慢了。优化做法:

复制代码

  1. public class A
  2. {
  3.    private B _b;
  4.    public B BProperty
  5.    {
  6.        get
  7.       {
  8.          if(_b == null)
  9.          {
  10.              _b = new B();
  11.          }
  12.          return _b;
  13.       }
  14.    }
  15. }

优化后,构造
A
的时候就不需要创建
B
对象,只有需要使用的时候才需要构造
B
对象。

4.
优化算法


优化算法可以有效的提高特定操作的性能,使用一种算法时应该了解算法的适用情况,最好情况和最坏情况。


GcMultiRow
为例,最初
MultiRow
的排序算法使用了经典的快速排序算法。这看起来是没有问题的,但是,对于表格控件,用户经常的操作是对有序表进行排序,如顺序和倒序之间切换。而经典的快速排序算法的最差情况就是基本有序的情况。所以经典快速排序算法不适合
MultiRow
。最后通过改的排序算法解决了这个问题。改进的快速排序算法使用了
3
个中点来代替经典快排的一个中点的算法。每次交换都是从
3
个中点中选择一个。这样,乱序和基本有序的情况都不是这个算法的最坏情况,从而优化了性能。
5.

了解
Framework
提供的数据结构


我们现在工作的.net framework平台平台,有很多现成的数据数据结构。我们应该了解这些数据结构,提升我们程序的性能。

举例:
a.

string
的加运算符
VS StringBuilder


字符串的操作是我们经常遇到的基本操作之一。

我们经常会写这样的代码
string str = str1 + str2
。当操作的字符串很少的时候,这样的操作没有问题。但是如果大量操作的时候(例如文本文件的
Save/Load

Asp.net

Render
),这样做就会带来严重的性能问题。这时,我们就应该用
StringBuilder
来代替
string
的加操作。

b.
Dictionary VS List Dictionary

List
是最常用的两种集合类。选择正确的集合类可以很大的提升程序的性能。为了做出正确的选择,我们应该对
Dictionary

List
的各种操作的性能比较了解。

下表中粗略的列出了两种数据结构的性能比较。

操作ListDictionary
索引
Find(Contains)
Add
Insert
Remove

c. TryGetValue
对于
Dictionary
的取值,比较直接的方法是如下代码:

复制代码

  1. if(_dic.ContainKey(“Key”)
  2. {
  3.     return _dic\[“Key”\];
  4. }

当需要大量取值的时候,这样的取法会带来性能问题。优化方法如下:

复制代码

  1. object value;
  2. if(_dic.TryGetValue(“Key”, out value))
  3. {
  4.     return value;
  5. }

使用
TryGetValue
可以比先
Contain
再取值提高一倍的性能。

d. 为

Dictionary
选择合适的
Key

Dictionary
的取值性能很大情况下取决于做
Key
的对象的
Equals

GetHashCode
两个方法的性能。如果可以的话使用
Int

Key
性能最好。如果是一个自定义的
Class

Key
的话,最好保证以下两点:
i

.
不同对象的
GetHashCode
重复率低。

ii. GetHashCode


Equals
方法立即简单,效率高。

e.
List

Sort

BinarySearch
性能很好,如果能满足功能需求的话推荐直接使用,而不是自己重写。

复制代码

  1. List<int> list = new List<int>{3, 10, 15};
  2. list.BinarySearch(10); // 对于存在的值,结果是1
  3. list.BinarySearch(8); // 对于不存在的值,会使用负数表示位置,如查找8时,结果是-2, 查找0结果是-1,查找100结果是-4.

6. 通过异步提升响应时间
a.
多线程


有些操作确实需要花费比较长的时间,如果用户的操作在这段时间卡死会带来很差的用户体验。有时候,使用多线程技术可以解决这个问题

举例:
CalculatorEngine
在构造的时候要初始化所有的
Function
。由于
Function
比较多,初始化时间会比较长。这是就用到了多线程技术,在工作线程中做
Function
的初始化工作,就不影响主线程快速响应用户的其他操作了。代码如下:

复制代码

  1. public CalcParser()
  2. {
  3.    if (_functions == null)
  4.    {
  5.        lock (_obtainFunctionLocker)
  6.        {
  7.            if (_functions == null)
  8.            {
  9.                System.Threading.ThreadPool.QueueUserWorkItem((s) =>
  10.                {
  11.                    if (_functions == null)
  12.                    {
  13.                        lock (_obtainFunctionLocker)
  14.                        {
  15.                            if (_functions == null)
  16.                            {
  17.                                _functions = EnsureFunctions();
  18.                            }
  19.                        }
  20.                    }
  21.                });
  22.            }
  23.        }
  24.    }
  25. }

这里比较慢的操作就是
EnsureFunctions
函数,是在另一个线程里执行的,不会影响主线程的响应。当然,使用多线程是一个比较有难度的方案,需要充分考虑跨线程访问和死锁的问题。

b.
加延迟时间



GcMultiRow
实现
AutoFilter
功能的时候使用了一个类似于延迟执行的方案来提升响应速度。
AutoFilter
的功能是用户在输入的过程中根据用户的输入更新筛选的结果。数据量大的时候一次筛选需要较长时间,会影响用户的连续输入。使用多线可能是个好的方案,但是使用多线程会增加程序的复杂度。
MultiRow
的解决方案是当接收到用户的键盘输入消息的时候,并不立即出发
Filter
,而是等待
0.3
秒。如果用户在连续输入,会在这
0.3
秒内再次收到键盘消息,就再等
0.3
秒。直到连续
0.3
秒内没有新的键盘消息时再触发
Filter
。保证了快速响应用户输入的目的。

c. Application.Idle

事件



GcMultiRow

Designer
里,经常要根据当前的状态刷新
ToolBar
上按钮的
Disable/Enable
状态。一次刷新需要较长的时间。如果用户连续输入会有卡顿的感觉,影响用户体验。
GcMultiRow
的优化方案是挂系统的
Application.Idle
事件。当系统空闲的时候,系统会触发这个事件。接到这个事件表示此时用户已经完成了连续的输入,这时就可以从容的刷新按钮的状态了。

d.
Invalidate, BeginInvoke. PostEvent
平台本身也提供了一些异步方案。


例如
;

Winform
下,触发一块区域重画的时候,一般不适用
Refresh
而是
Invalidate
,这样会触发异步的刷新。在触发之前可以多次
Invalidate

BeginInvoke

PostMessage
也都可以触发异步的行为。

7.
了解平台特性



WPF

DP DP
相对于
CLR property
来说是很慢的,包括
Get

Set
都很慢,这和一般质感上
Get
比较快
Set
比较慢不一样。如果一个
DP
需要被多次读取的话建议是
CLR property

Cache

8.
进度条,提升用户体验


有时候,以上提到的方案都没有办法快速响应用户操作,进度条,一直转圈圈的图片,提示性文字如

你的操作可能需要较长时间请耐心等待

。都可以提升用户体验。可以作为最后方案来考虑。

点赞