从四分钟到两秒——谈谈客户端性能优化的一些最佳实践
背景
最近跟售后经理吃饭,他跟我再次谈起两年前为公司临时写的一个客户端,仍然非常激动的跟我说,这个客户端完爆了公司其他版本的客户端,包括最老的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#中的控件和大部分流操作类等都提供了支持异步操作的方法:BeginXXX
和EndXXX
.它的原理也非常简单,使用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分钟提升到只需两秒。当然,我们还有其他很多的方法,比如分布式……无论是什么样的技巧,我觉得有一些基本的原则是要遵循的:
- 站在用户的角度思考问题
- 永远不要把选择交给用户
- 必须考虑最极端恶劣的情况
回顾一下这篇文章讲的内容:
- 加快系统响应的基本手法
- 按需获取
- 延迟加载
- 化曲为直
- 缓存
- 异步
- 归并处理
- 视觉欺骗
- 给出提示信息或者进度条
- 偷偷加载
- 简化数据
- 程序稳定性
- 使用单元测试
- 提供完善的日志信息