性能相关问题往往表明作者无法理解问题链中最薄弱的环节。以下是我最喜欢提到的一些有关性能和 AIR 应用程序的糟糕提问:
- 我的 AIR 应用程序会运行如飞吗?
- AIR 是否可以达到执行 X 的速度?
- AIR 执行 Y 是否太慢?
(以下内容还证明了一点,无论幼稚园老师教了您什么,总会出现糟糕提问这类事物。)
AIR 几乎总可以通过应用程序实现良好性能。而另一方面,AIR 无法为您这样做。正如我所说的,这是问题的本性。
幸运的是,标准调试技术像适用于编写桌面软件一样适用于 AIR。
合适的提问
实现良好性能的***步,就如同大多数设计问题,在于理解您尝试解决的问题。以下是一些针对您的应用程序的合适的提问:
- 我的应用程序中哪些操作对于性能比较敏感?
- 我可以使用什么衡量标准来测量这一敏感度?
- 如何优化应用程序以达到这一衡量标准?
大多数应用程序包含大量可以稳定运行的代码。不要在这方面浪费时间,尤其是当益处低于用户可以察觉的阈值时。务必将注意力集中在重要方面。
值得优化的常见操作示例包括:
- 图像、声音和视频处理
- 渲染大型数据集或 3D 模型
- 搜索
- 响应用户输入
定义衡量标准
人们往往将性能和速度划上等号,但千万不要误以为这是唯一重要的衡量标准。您可能发现需要针对内存使用或电池寿命进行调试。将这些降至***的应用程序也可以被认为比那些不降低的应用程序性能更高。有时优化其他衡量标准也可以提高速度,但其他时候需要做出折衷。
无论测量什么,您必须测量一些对象。如果不测量任何对象,您就无法得知更改是有利于还是有害于性能。良好的衡量标准有以下三个特性:
- 它们可以量化。可以测量它们并记录为数字。
- 它们是一致的。您可以反复测量它们,并有效地比较测量结果。
- 它们有意义。测量值中的变化对应于您正在优化的对象。
为了使它更形象,假设您正在编写一个应用程序,它将对一个大型图像集执行一些图像处理任务。在处理过程中,应用程序需要向用户显示进度反馈。它还必须允许用户能取消操作,而不是等待操作完成。这是一个十分简单的应用程序,但即便如此,它至少仍有三个重要的衡量标准可供审视。
示例:吞吐量
***个、最显而易见的衡量标准是吞吐量。它在这个示例中是有意义的,因为我们知道自己必须处理大量图像。吞吐量越高,处理完成得越快。
吞吐量可以轻松量化为每单位时间的处理量。尽管可以测量已处理图像的数量,但当图像大小不一时,测量字节数可以产生一致性更高的值。在这个示例中,直接测量每毫秒字节数作为吞吐量。
示例:内存使用
对于这个应用程序,一个不太显眼的衡量标准是内存使用。对于最终用户,内存使用不像吞吐量那样显而易见。为了监视内存使用,用户必须运行另一个应用程序,如 Activity Monitor。但内存使用可能成为一个限制因素:内存不足,此时应用程序将无法正常运行。
内存使用对于我们的图像处理示例是重要的,因为这些图像本身很大。我们希望能处理大型图像-即便是那些超出可用 RAM 的图像-前提是不出现内存不足的情况。内存使用按字节测量很简单。
示例:响应时间
我们的范例应用程序的***一个衡量标准往往被忽略:用户输入的响应时间。这个衡量标准对于您的所有用户而言都是显而易见的,虽然他们很少停下来测量它。它也十分普遍。用户希望所有操作都能得到快速响应-无论是调整窗口大小、取消某个操作还是键入文本。
用户认为某些衡量标准是线性的,而响应时间却有一个重要的阈值。输入响应只要超过 100 毫秒左右,用户就会有慢的感觉。如果您的应用程序响应速度始终低于这个阈值,就没有进一步优化的必要了。显然,这个衡量标准可以按毫秒轻松量化。
响应时间对于图像处理应用程序是一个重要挑战,因为处理任何一张图像的时间都远远超出 100 毫秒。在某些编程环境中,通过在连续计算线程以外的线程上处理用户输入来解决这个问题。而在内部,这种解决方法需要操作系统快速切换线程环境,确保用户输入线程可以及时响应。但 AIR 不提供明确的线程模型,所以必须直接完成这一切换操作。下一部分将说明这一操作。以下范例说明了设置图像处理的三种不同方式,它们针对不同的衡量标准而优化:
- <?xml version="1.0" encoding="utf-8"?>
- <mx:WindowedApplication xmlns:mx="http://www.adobe.com/2006/mxml" layout="horizontal" frameRate='45'>
- <mx:Script>
- <![CDATA[
- private static const DATASET_SIZE_MB:int = 100;
- private function doThroughput():void {
- var start:Number = new Date().time;
- var data:ByteArray = new ByteArray();
- data.length = DATASET_SIZE_MB * 1024 * 1024;
- filter( data );
- var end:Number = new Date().time;
- _throughputLabel.text = ( data.length / ( end - start )) + " bytes/msec";
- }
- private function doMemory():void {
- var start:Number = new Date().time;
- var data:ByteArray = new ByteArray();
- data.length = 1024 * 1024;
- for( var chunk:int = 0; chunk < DATASET_SIZE_MB; chunk++ ) {
- filter( data );
- }
- var end:Number = new Date().time;
- _memoryLabel.text = ( DATASET_SIZE_MB * data.length / ( end - start )) + " bytes/msec";
- }
- private function doResponse():void {
- _chunkStart = new Date().time;
- _chunkData = new ByteArray();
- _chunkData.length = 100 * 1024;
- _chunksRemaining = DATASET_SIZE_MB * 1024 / 100;
- _chunkTimer = new Timer( 1, 1 );
- _chunkTimer.addEventListener( TimerEvent.TIMER_COMPLETE, doChunk );
- _chunkTimer.start();
- }
- private function doChunk( event:TimerEvent ):void {
- var iterStart:Number = new Date().time;
- while( _chunksRemaining > 0 ) {
- filter( _chunkData );
- _chunksRemaining--;
- var now:Number = new Date().time;
- if( now - iterStart > 90 ) break;
- }
- if( _chunksRemaining > 0 ) {
- _chunkTimer.start();
- } else {
- var end:Number = new Date().time;
- _responseLabel.text = ( DATASET_SIZE_MB * 1024 * 1024 / ( end - _chunkStart )) + " bytes/msec";
- }
- }
- private var _chunkStart:Number;
- private var _chunkData:ByteArray;
- private var _chunksRemaining:int;
- private var _chunkTimer:Timer;
- private function filter( data:ByteArray ):void {
- for( var i:int = 0; i < data.length; i++ ) {
- data[i] = data[i] * data[i] + 2;
- }
- }
- private function onMouseMove( event:MouseEvent ):void {
- var global:Point = new Point( event.stageX, event.stageY );
- var local:Point = _canvas.globalToLocal( global );
- _button.x = local.x;
- _button.y = local.y;
- }
- ]]>
- </mx:Script>
- <mx:HBox width='100%' height='100%'>
- <mx:VBox width='50%' height='100%'>
- <mx:Button label='Measure throughput' click='doThroughput();' />
- <mx:Label id='_throughputLabel' />
- <mx:Button label='Reduce memory use' click='doMemory();' />
- <mx:Label id='_memoryLabel' />
- <mx:Button label='Maintain responsiveness' click='doResponse();' />
- <mx:Label id='_responseLabel' />
- </mx:VBox>
- <mx:Canvas
- width='50%' height='100%'
- id="_canvas"
- horizontalScrollPolicy="off"
- verticalScrollPolicy="off"
- backgroundColor="white"
- mouseMove='onMouseMove( event );'
- >
- <mx:Label text="Move Me" id="_button" />
- </mx:Canvas>
- </mx:HBox>
- </mx:WindowedApplication>
进行测量
当确定并定义衡量标准后,您首先必须能测量它们,随后才能处理它们。只有通过前后两次的测量和跟踪,您才能确定那些变化的影响。尽可能同时跟踪所有衡量标准,这样可以了解为优化一个衡量标准所做的更改对其他衡量标准产生的影响。
测量吞吐量
可以通过程序轻松测量吞吐量。测量吞吐量的基本模式为:
- start_msec = new Date().time
- do_work()
- end_msec = new Date().time
- rate = bytes_processed / ( end_msec - start_msec )
测量内存
内存更复杂一些。包括 AIR 在内的大多数运行时环境不提供可以确定应用程序内存使用的适当 API。***使用外部工具监视内存使用,如 Activity Monitor (Mac OS X)、任务管理器 (Windows)、BigTop (Mac OS X) 等。选择一个监视工具后,您需要决定要跟踪哪个内存衡量标准。
虚拟内存是跟踪工具的头号报告对象。 人如其名,它不会测量进程使用的物理 RAM 量。可以将它想象为进程使用的内存地址空间量。在某个时刻,分配给进程的一部分内存通常会存储在磁盘而不是 RAM 中。人们通常认为 RAM 量以及占用的磁盘空间之和就是进程的虚拟内存,但地址空间的某些部分可能不在这两个地方。具体情况取决于操作系统以及它根据不同目的分配虚拟内存部分的方式。
根据虚拟内存包含的内容,应用程序虚拟内存的绝对大小可能不是一个重要的衡量标准。您的应用程序相对于其他类似应用程序的虚拟内存可能是重要的,但依然很难进行有效比较。虚拟内存最重要的一个方面是它随着时间流逝产生的行为:无限增长表明存在内存泄漏。其他内存衡量标准中可能不显示内存泄漏,因为如果未引用泄漏的内存,它们会调入磁盘并驻留在那里。
可供监视的***内存衡量标准是专用字节,它测量进程单独使用的 RAM 量。这个衡量标准直接表明您的应用程序对整个系统产生的影响,它使用的是共享资源。
专用字节会随着应用程序分配和取消分配内存而波动。它也会随着应用程序活动或空闲而波动,空闲时部分页面会调入磁盘。要跟踪专用字节,我建议在您优化的操作过程中使用监视工具进行定期采样,即每秒一次。
监视工具包含的其他内存衡量标准包括驻留大小和共享字节。驻留大小是您的进程所使用的 RAM 总量,它由专用字节和共享字节组成。共享字节是与其他进程共享的 RAM 部分。这些部分通常包含只读资源,如共享库或系统框架中的代码。虽然您可以跟踪这些衡量指标,应用程序目前对专用字节值的控制度***,问题也最多。
响应时间
响应时间***用秒表测量。当用户执行操作时开始计时,如单击按钮时。当应用程序响应时停止计时,通常更改显示的用户界面即可。将两个计时相减就可以得出测量值。
优化流程
有了目标和衡量标准,就可以进行优化了。流程本身很简单,并且应当很常见。重复以下三个步骤,直至完成:
- 测量
- 分析
- 修改
大致而言,分析可能产生两种更改中的一种:设计或代码。
设计更改
设计更改通常影响***。但是,在游戏后期进行设计更改难度可能更大,所以定义并测量性能目标之前不要耽搁太久。
例如,我们回到图像处理应用程序。一种单纯的实施方法是:将每个图像完整加载到内存中,处理它,然后将结果写回磁盘。这个应用程序的内存使用峰值(专用字节)主要就是已加载图像大小的函数。如果图像超出可用 RAM,应用程序将失败。
图像处理操作很少是全局的;大多数操作每次可以在图像的某个部分上执行。通过将图像分为固定大小的多个块并且逐个处理这些块,您可以将应用程序的内存使用峰值限制为选定的数值。这样,处理超出可用 RAM 的图像也成为可能。
修改设计后,请务必重新评估所有衡量标准。它们之间始终会出现一些相互作用,因为设计发生了变化。那些更改有时可能会出乎想象。当我构建这个范例应用程序的原型时,按固定大小的块处理图像并未大幅改变应用程序的吞吐量,我预计它可能变慢。
代码更改
当不再需要增强设计时,可转向代码调试。这个方面可以尝试许多技术。其中有些是 ActionScript 特有的;有些则不然。
切记不要过早应用代码更改。它们可能会牺牲可读性和性能结构。虽然不一定每次都会很糟,但是如果过早应用代码更改,它们会降低您改进和维护应用程序的能力。正如 Donald Knuth 所说,“过早的优化是一切罪恶的源头。”
特制的测试应用程序
真实的应用程序往往较大、较复杂并且满是快速运行的代码。为了帮助您既爱那个优化精力集中在主要操作上,可考虑创建一个专用测试应用程序。
除了其他优势,测试应用程序提供了一个包含测试的空间(即,用于测量吞吐量),您无需将这个代码包含在最终的应用程序中。
当然,将您的改进移回应用程序时,您需要验证优化结果是否依然有效。
分块
如前所述,AIR 运行时不提供在后台线程上执行应用程序代码的机制。在计算密集型任务过程中尝试保持响应度时,这个问题尤为棘手。
与空间分块可用于优化内存使用一样,时间分块可以将计算分为多个短时运行部分。通过响应各部分之间的用户输入,您可以保持应用程序的响应度。
以下伪代码每次可以执行约 90 毫秒的工作,然后把控制权交给主事件循环。主事件循环确保已处理鼠标单击等操作。根据这一时间安排,可以在 100 毫秒内处理大多数用户输入,从用户角度而言,应用程序的响应速度已足够。
- var timer:Timer = new Timer( 1, 1 )
- timer.addEventListener( TimerEvent.TIMER, doChunk )
- function doChunk( event:Event ):void {
- var start:Number = new Date().time
- while( workRemaining ) {
- doWork()
- var now:Number = new Date().time
- if( now - start > 90 ) {
- // reschedule more work to occur after input
- if( workRemaining )
- timer.start()
- break
- }
- }
- }
在此例中,为了保持响应度,doWork() 的运行时间必须远远小于块持续时间。为了保持在 100 毫秒的最糟情况下,运行时间不能超出 10 毫秒。
再次强调,采用这类方法后,请重新测量所有衡量标准。在我的图像处理应用程序中,采用这种分块方法后吞吐量下降了约 10%。另一方面,我的应用程序在所有用户输入的 100 毫秒内可以做出响应,而不仅仅是在图像之间。我认为这是一个合理的折衷。
包装
创建高性能的应用程序并非易事,但要回应严格的测量、分析和不断改进会遇到问题。AIR 应用程序在这个问题上没有很大区别。
性能同时也是一个不断变化的目标。不仅每一组改进可能影响到其他衡量标准,底层硬件、操作系统和其他更改也会改变快与慢之间的平衡。即便是您在优化的对象也可能随时间发生变化。
凭借充分的实战经验,您可以创建出高性能的 AIR 应用程序并使它们持之以恒。切记不要放松警惕。只要有一个功能变慢,用户就会发问,“您的应用程序是否可以达到执行 X 的速度?”
关于作者
自 Adobe AIR 诞生并发展出新颖的安装技术以来,Oliver 一直致力于这个领域。在转向 AIR 之前,他致力于 Adobe LiveCycle,而在加入 Adobe 之前,他从事的领域很广,包括金融服务、数字信号处理和视频游戏。他有时为 Dr. Dobb's Journal 撰稿,并且是 Kidos Computer 技术咨询委员会的成员。他获得了斯坦福大学的计算机本科及硕士学位。