有关App运行速度与响应速度优化的好文,按个人理解意译,受限于水平而不够严谨,附原文地址
PS,觉得鄙人干翻译好过干编码的兄弟们顶一下哦!
第一部分是说理念,太啰嗦,可以直接跳第二部分。
第二部分是一些实用的优化技术总结(高潮部分)。
iPad的出现对行业软件质量提升有着巨大的冲击。苹果公司多次提升了其准入标准,最明显的是要求软件运行更快更平滑。iPad能被迅速开启和唤醒,iPad应用能被迅速打开,点按Home键应用能迅速切到后台。桌面用户往往更具耐心,但是iPad用户期望任何操作能瞬间完成。讽刺的是iPad的计算能力很大程度落后于桌面电脑,不过用户才不管呢。相比开发iPad应用时对性能优化的充分关注,只有少数桌面应用开发才会如此。即使拥有伟大的概念和良好的设计,当App让人感觉很慢时,体验是毁灭性的。
不同的App,有的响应迅捷有的响应延缓,为什么会有这样的差异呢?我们怎样让它们变得更灵敏快捷呢?整篇文章分为两部分,前部分我们讨论App优化的理念,后部分将提及各种优化技术。我的目的不是详尽的展现所有的优化技术,而是为你提供一个关键技术的指引,以及为什么你应该去使用。
---------- PART 1 ------------
响应速度vs运行速度,哪个更重要?
响应速度和运行速度之间有着微妙的区别,响应速度是指监听用户输入到反馈用户的速度,而运行速度是指处理任务的速度。
用户都讨厌等待,所以你会说让App运行的更快非常重要。确实如此,但是运行速度的提升有一个边界,假如数据要通过网络下载,或者要进行复杂的计算和渲染,那么App不可能立即显示这些内容。这种情况下用户实际上还是愿意等待的,但是你要针对他们的操作给出即时的响应,这种响应可以是简单的按钮状态的改变也可以是复杂的动画效果。让App运行更快很重要,让其迅速响应同样重要。
大部分的App是下图这样的,而我们的目标是让feedback在heavy computing之前执行。
<ignore_js_op>
为什么响应速度如此重要?
使用现实中真实的按钮和开关时会让人感觉靠谱,当按下按钮或者打开开关时你可以百分百确定你进行了操作。但是在触摸屏上你无法感知,所以视觉响应非常重要。如果一个App不能提供这种即时的响应那体验将变得非常糟糕,更具体点说就是响应时间不要超过三分之一秒。当你点击了某个位置但是没有任何事情发生,你会自然而然的认为点击有可能没有被接受。绝大多数人在这时会再点击一次,这可能造成重复的操作。
iPad的一个巨大的成功在于大部分软件让人感觉真实。App通常使用仿现实世界的方式来让用户忘记他们是在使用软件(例如Paper和iBooks),这正是轻松愉快的使用软件的方式。但是当App经常花费过多时间来响应你的触摸时这种软件的美好使用体验将消失。大多用户无法区分一款App是否具有良好的响应能力,但他们能知道到哪款App爽哪款App不爽。
三条原则
当你的App感觉有点慢了的时候,该做些什么呢?我这里给出三条简单的原则来帮助你聚焦问题。
立即响应
迅速回馈用户他的操作已经被接受,然后迅速执行。例如点击按钮时提供一个touch-down状态呈现给用户。
允许用户任意时刻中断
当耗时操作进行时,反馈用户一个进度
----------- PART 2 -----------
在这个部分我将讲到具体的优化技术。
运行速度
前一个部分讲到了响应能力比运行速度更重要,但是我们还是要从运行速度优化讲起。
理论上的方法(不要照做)
我从计算机科学课程上学到的一件事就是从理论上寻找解决性能问题的方法。看下面的代码:
//a very large array with N elements
NSArray * array = [self createArray];
for(id object in array) {
[object performSomeAction];
}for(id object in array) {
[object performAnotherAction];
}
对比下面的
//a very large array with N elements
NSArray * array = [self createArray];
for(id object in array) {
[object performSomeAction];
[object performAnotherAction];}
第一段代码里面你的代码将对大数组进行两次遍历,这将比第二段代码中一次遍历完成所有的任务要低效。从这样的层面来关注你的代码能保证算法更具效率,但是我认为你没必要这样去做。现代的编译器能很智能的优化出高效的最终代码,我们将第一段代码刻意的写成第二段代码那样对整体的性能提升几乎是没有任何意义的。有时候这种刻意而为的代码层面优化会导致逻辑不清晰且难以维护的代码(这也是译者万分赞同的,写出优雅的代码而不是机器友好的)。
性能的测量
苹果为我们提供了强大的工具来测量App性能,所以我们没必要绞尽脑汁评估写出的代码将占用多少时间,我们可以直接测量它们。
第一条软件优化的准则就是瞄准能带来巨大收益的改进,不要一上来就在代码细节优化上浪费时间。不过我们可以从代码的角度去分析下哪一块的改进能带来较大收益。
在Xcode的菜单中选择“Product”,然后执行“profile”。成功编译之后Xcode将启动Instruments,稍后你可以看到如下的弹出框:
<ignore_js_op>
这里有许多的“仪器”可以帮你分析你的App。当聚焦运行速度时,上面红圈标记的两个(Time Profiler工具和Core Animation工具)是最有效的。
Time Profiler
当我们讨论运行速度时离不开Time Profiler工具。当你的程序运行缓慢时第一件事情就是调查时什么占用了大量的时间。总是可以找出一些可以写得更高效的代码块,但是在重写这些代码前请列出一个最耗时代码块列表。
当运行Time Profiler时你将得到一个方法名列表。下面的截图展示了一个按照时间消耗排序的方法名列表,你可以在时间轴添加一个起点位置和终点位置来关注你程序的不同阶段。
勾选“Invert call tree”和“Hide system libraries”对列表进行过滤,只留下你自己所编写的方法。如果不勾选“Invert call tree”的话你需要深入到调用堆栈的最里层才能看到耗时方法。可以尝试玩弄下这些设置项来获取对你有用的结果。
<ignore_js_op>
双击一个方法名可以查看到具体哪一行代码花费了如此多的时间:
<ignore_js_op>
这也是Time Profiler最强大的功能之一,你可以轻松找出哪些代码需要优化。
在我们的一个项目里面使用了一些NSDataFormatter和NSNumberFormatter来显示不同格式和时区的时间与日期。当我运行Time Profiler时它指出大部分的时间都是被下面的代码消耗的:
NSDateFormatter * df = [[NSDateFormatter alloc] init];
如果不使用Time Profiler我很难注意到这个问题,我们完全没有必要每次都创建NSDateFormatter,所以我们重写了这块代码:
NSDateFormatter * df = [self sharedDateFormatter];
通过下面的方法来防止每次都创建一个新的dateFormatter:
static NSDateFormatter * sharedDateFormatterInstance;- (NSDateFormatter)sharedDateFormatter {
if(sharedDateFormatterInstance == nil)
sharedDateFormatterInstance = [[NSDateFormatter alloc] init];
return sharedDateFormatterInstance;
}
通过上面的简单改写,我们仅仅调用了一次NSDateFormatter那如此耗时的init方法,节省了较多的时间开销。
上面就是有关使用Time Profiler进行优化的经验,建议你把Time Profiler当作日常工作流程的一部分。在写代码时持续优化保持高效,比事后再回过头去做所谓的优化专项工作要轻松得多。
Stuttering / flickering
不幸的是Time Profiler不能找出所有的性能问题。当你的App的帧率掉到60(帧/秒)以下你的App就感觉运行得不是那么平滑了。低帧率导致滚动视图和动画卡顿。
帧率下降通常意味着iPad的渲染速度跟不上。视觉上较为理想的帧率不低于60(帧/秒),意味着每一帧应该在六十分之一秒内渲染完。
所以这是该Core Animation工具闪亮登场的时候了。Core Animation可以用来测量App的帧率。在左侧有一些勾选项用于帮助你记录下什么导致低帧率。我们将重温下那些比较重要的项。
<ignore_js_op>
Offscreen rendering(离屏渲染)
第一个关注项是off-screen rendering。离屏渲染意味着你App的部分区域每一帧渲染了两次。大部分的离屏渲染是阴影和遮罩导致。iOS首先为目标层渲染阴影,然后再渲染目标层,同样遮罩也需要如此一个过程,首先渲染目标层,然后为其渲染遮罩。
当你的App被强制进行离屏渲染时帧率将会大打折扣。勾选Color Offscreen-Rendered Yellow将高亮进行离屏渲染的所有区域。
<ignore_js_op>
当离屏渲染是阴影导致的话,常常能够比较轻松的解决。阴影的耗时计算发生在计算阴影的精确形状上,目标层将不得不递归遍历其子层来计算阴影的形状,所以当你确知目标层的形状时你可以为其指定阴影路径。阴影路径决定了阴影的形状。
//we now assume that thumbView is rectangular. With the bezier path we create nice round borders.
yourView.layer.shadowPath = [[UIBezierPath bezierPathWithRect:yourView.bounds] CGPath];
现在请启用Core Animation工具并重新运行你的App,当那些离屏渲染减少甚至消灭之后,你的App将会变得流畅很多。
Blended layers(混合层)
iPad再渲染每一帧的时候,都将计算每一个像素点的颜色。当最上面有一个不透明层的时候,计算每一个像素点的最终颜色非常简单,只需要拷贝该最上面的层的对应点颜色即可。而混合层(非不透明层)则需要对对应的画面区域进行颜色混合。
在视图的层次结构中,混合层越多,渲染的计算量就越大。如果最上面的层是混合层,渲染引擎需要处理其下面覆盖的层,计算每一个点的颜色,如果该下面覆盖的层也是混合层,引擎将继续检查其下面的层,如此递归下去。
<ignore_js_op>
你可以通过选中“Blender layers”来检查混合曾的数量。深红色区域表示这个区域的渲染非常费劲,可能有多个混合层重叠。如果你的App有过多的红色区域,应该考虑将视图的层次结构调整得更加扁平。同时应该将完全不需要透明效果的层添加背景色并设置其“Opaque”属性为YES,这样相当于告诉渲染引擎其下面的层不需要进行处理。
Rasterization(光栅化)
某些情况下层会比较难以渲染(使用了阴影、遮罩、复杂形状、渐变等),为了优化对这种层的处理,iOS提供了一个叫做“光栅化”的API来对其进行缓存,这将隐式的创建一个位图,从而减少渲染的频度。
[layer setShouldRasterize:YES];
开启光栅化的优势是该特定的层基本不会影响你的整体帧率,劣势是光栅化将占用更多的内存,同时初始化时将占用更多的时间,在对该层进行缩放操作时它将表现为像素化(不是矢量图形了而是位图)。
当你使用光栅化时,你可以开启“Color Hits Green and Misses Red”来检查该场景下光栅化操作是否是一个好的选择。如果光栅化的层变红得太频繁那么光栅化对优化可能没有多少用处。位图缓存从内存中删除又重新创建得太过频繁,红色表明缓存重建得太迟。可以针对性的选择某个较小而较深的层结构进行光栅化,来尝试减少渲染时间。
响应速度
上面所讲的都是关于性能方面的,提升App的性能通常也能带来响应速度的提升,但不总是这样。因此我要总结一些保持你的界面快速响应的技术。
Threads
一个线程可以被视为CPU按照顺序执行的指令队列。当方法A调用方法B,然后方法B调用方法C,所有的代码是按顺序执行的。这种特性带来的好处明显,想象下当你写代码时你不能确定哪行代码先执行哪行代码后执行,这会多么的恐怖。一个应用可以有多个并发执行的线程。CPU不断把执行时间分配到各线程,各线程在分配到的较短的时间内完成一些工作。
主线程
应用程序的主线程是很有必要去深入理解的。所有的用户输入和UIKit的渲染是在主线程执行。如果你没有考虑过在你的应用中使用多线程,你可以假定你的应用是运行在主线程,当然实际情况不完全是这样,iOS在组件封装时做了些优化,会把一些任务自动的排入其它线程中。
有的应用只需要使用主线程就足够了,所有的代码都线性执行的(严格的按照你期望的顺序),如果该应用体验还行那你也不用在线程问题上纠结太多啦。主线程的一个重要职责是响应用户输入(点击、触摸、手势等),因为线程一次只能做一件事,过分依赖主线程可能让你遇到麻烦(例如要在主线程执行一个耗时多达几百毫秒的运算)。你的App需要能够在处理耗时计算的同时迅速响应用户输入。
所以下面的代码是非常傻叉的:
NSURLConnection * conn = [NSURLConnection sendSynchronousRequest:req returningResponse:res error:&error];
或者采用不太容易看出来但仍然傻叉的方式:
NSData * data = [[NSData alloc] initWithContentsOfURL: someExternalURL];
在主线程做这种操作很危险,你完全不知道你的用户界面将失去响应多久。
概括起来:
大多数代码在主线程执行,包括所有的UIKit代码和所有的事件处理。
主线程(其它线程也一样)同一时刻只能做一件事情。
如果你的App在主线程执行一个持续3秒的任务,那么它将失去响应3秒钟。
你可以创建和使用一个独立的线程来执行耗时操作以免用户界面阻塞。
Grand Central Dispatch
那么怎样来使用线程呢?一般建议使用GCD。GCD和线程是有区别的,我们这里不深入研究了,但你会发现GCD是在线程基础上建立起来的一套强大机制。GCD是为了并行运行代码(注意不是并发)设计的,在多核系统上尤其强大。
要使用GCD首先要创建一个dispatch queue。你可以这样做:
//blocks added to this queue are executed serially
dispatch_queue_t backgroundQueue = dispatch_queue_create("yourqueue", 0);
或者你可以使用一个系统的background queue,系统的background queue是配置为block并行运行的。如果你需要block有序的执行时,可以使用dispatch_queue_create来创建一个串行queue。
//blocks added to this queu can be executed concurrently
dispatch_queue_t existingQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
你可以从其它线程为queue指派任务。dispatch_async将异步执行,也就是说该方法能够立即返回,不需要等block执行完。在该queue执行block时你又可以为其它queue指派任务。
dispatch_async(backgroundQueue, ^{
//your code that should run in the background
//do some heavy work here.....
dispatch_sync(dispatch_get_main_queue(), ^{
//notify the main thread here
});
});
允许用户在任意时刻中断
虽然线程是很强大的,但是不能解决你的所有问题:
UIKit是非线程安全的。这意味着所有具有UI前缀的类和方法不能在后台线程调用。
当线程开始执行一段代码时,是很难停止下来的(如果你不能理解这句话说明你还太年轻,译者按)
多线程让你的代码变得复杂,线程共享变量共享内存的操作将困扰你。
上面的问题给我们带来了最后一个话题:runloops。Runloops提供了一套机制让你在主线程执行代码时App依然能够响应输入。主runloop是一个在主线程上运行的持续的循环,在每个循环过程它都将监听用户输入,更新屏幕,执行计划好的任务(例如定时器)。下面的来自苹果官方的图讲解了在每次循环中要做的事情,你的应用停止响应是因为这个runloop被耗时的代码段给阻塞。如果你能够将这耗时代码段打散成更轻量的段落,并将它们分散到多次循环中来运行,那问题将得到解决,因为在每次循环中App都将在执行轻量的段落之前监听用户输入。
<ignore_js_op>
要这样做的话你必须将大段代码分解成小的单元,并排入分开的运行循环中。有很多方法来做:
- (void)someHeavyTaskPartA {
//first part of heavy stuff
//now call the next piece of code on the next runloop with performSelector: afterDelay:
[self performSelector:@selector(someHeavyTaskPartB) withObject:nil afterDelay:0.0];
}
- (void)someHeavyTaskPartB {
//second part of heavy stuff
//now call the next piece of code on the next runloop with performSelector: afterDelay:
[self performSelector:@selector(someHeavyTaskPartC) withObject:nil afterDelay:0.0];
}
- (void)someHeavyTaskPartC {
.......
但是按上面这种方式写代码明显不够方便,你可以按下面这种方式来做:
//because this code must run on the main thread we use the mainQueue.
//if your code can safely be executed on a background thread you can
//also create your own operation queue that runs in the background like this:
//NSOperationQueue * yourQueue = [[NSOperationQueue alloc] init];
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
//first part of heavy stuff
}];
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
//second part of heavy stuff
}];
//etc...
取消执行
如果你允许用户在一个任务执行完成前与App进行交互,那么你的必须要考虑任务的取消。你可以使用一个简单的布尔或整形变量来取消一个已经安排执行的block。
static int requestNumber = 0;
- (void)cancel {
requestNumber++;
}
- (void)scheduleSomeCode {
int currentRequestNumber = requestNumber;
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
if(requestNumber == currentRequestNumber) {
//part of heavy stuff that will not be executed if you have called cancel
}
}];
}
好了说到这里终于完了。我们提到了一大堆关于优化的技术,希望大家能够喜欢。