讓App的運行速度與響應速度趨於一流(iOS)


  有關App運行速度與響應速度優化的好文,按個人理解意譯,受限於水平而不夠嚴謹,附原文地址
  PS,覺得鄙人幹翻譯好過幹編碼的兄弟們頂一下哦!
  第一部分是說理念,太囉嗦,可以直接跳第二部分。
  第二部分是一些實用的優化技術總結(高潮部分)。
  iPad的出現對行業軟件質量提升有着巨大的衝擊。蘋果公司多次提升了其准入標準,最明顯的是要求軟件運行更快更平滑。iPad能被迅速開啓和喚醒,iPad應用能被迅速打開,點按Home鍵應用能迅速切到後臺。桌面用戶往往更具耐心,但是iPad用戶期望任何操作能瞬間完成。諷刺的是iPad的計算能力很大程度落後於桌面電腦,不過用戶纔不管呢。相比開發iPad應用時對性能優化的充分關注,只有少數桌面應用開發纔會如此。即使擁有偉大的概念和良好的設計,當App讓人感覺很慢時,體驗是毀滅性的。
  不同的App,有的響應迅捷有的響應延緩,爲什麼會有這樣的差異呢?我們怎樣讓它們變得更靈敏快捷呢?整篇文章分爲兩部分,前部分我們討論App優化的理念,後部分將提及各種優化技術。我的目的不是詳盡的展現所有的優化技術,而是爲你提供一個關鍵技術的指引,以及爲什麼你應該去使用。


---------- PART 1 ------------


  響應速度vs運行速度,哪個更重要?
  響應速度和運行速度之間有着微妙的區別,響應速度是指監聽用戶輸入到反饋用戶的速度,而運行速度是指處理任務的速度。
  用戶都討厭等待,所以你會說讓App運行的更快非常重要。確實如此,但是運行速度的提升有一個邊界,假如數據要通過網絡下載,或者要進行復雜的計算和渲染,那麼App不可能立即顯示這些內容。這種情況下用戶實際上還是願意等待的,但是你要針對他們的操作給出即時的響應,這種響應可以是簡單的按鈕狀態的改變也可以是複雜的動畫效果。讓App運行更快很重要,讓其迅速響應同樣重要。
  大部分的App是下圖這樣的,而我們的目標是讓feedback在heavy computing之前執行。

162257ygvnh3kazj38k5k0.jpg


  爲什麼響應速度如此重要?
  使用現實中真實的按鈕和開關時會讓人感覺靠譜,當按下按鈕或者打開開關時你可以百分百確定你進行了操作。但是在觸摸屏上你無法感知,所以視覺響應非常重要。如果一個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,稍後你可以看到如下的彈出框:

162257ubmhuwzrsfcb9v2c.png


  這裏有許多的“儀器”可以幫你分析你的App。當聚焦運行速度時,上面紅圈標記的兩個(Time Profiler工具和Core Animation工具)是最有效的。
Time Profiler
  當我們討論運行速度時離不開Time Profiler工具。當你的程序運行緩慢時第一件事情就是調查時什麼佔用了大量的時間。總是可以找出一些可以寫得更高效的代碼塊,但是在重寫這些代碼前請列出一個最耗時代碼塊列表。
  當運行Time Profiler時你將得到一個方法名列表。下面的截圖展示了一個按照時間消耗排序的方法名列表,你可以在時間軸添加一個起點位置和終點位置來關注你程序的不同階段。
  勾選“Invert call tree”和“Hide system libraries”對列表進行過濾,只留下你自己所編寫的方法。如果不勾選“Invert call tree”的話你需要深入到調用堆棧的最裏層才能看到耗時方法。可以嘗試玩弄下這些設置項來獲取對你有用的結果。

162257zss7oti7or4itss7.png


  雙擊一個方法名可以查看到具體哪一行代碼花費了如此多的時間:

162257zooas66p8rdzdlal.png


  這也是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的幀率。在左側有一些勾選項用於幫助你記錄下什麼導致低幀率。我們將重溫下那些比較重要的項。

162256ptbgzd9qo8orspbs.png


Offscreen rendering(離屏渲染)
  第一個關注項是off-screen rendering。離屏渲染意味着你App的部分區域每一幀渲染了兩次。大部分的離屏渲染是陰影和遮罩導致。iOS首先爲目標層渲染陰影,然後再渲染目標層,同樣遮罩也需要如此一個過程,首先渲染目標層,然後爲其渲染遮罩。
  當你的App被強制進行離屏渲染時幀率將會大打折扣。勾選Color Offscreen-Rendered Yellow將高亮進行離屏渲染的所有區域。

162256bljfdbj3dxlfrdqc.png


  當離屏渲染是陰影導致的話,常常能夠比較輕鬆的解決。陰影的耗時計算髮生在計算陰影的精確形狀上,目標層將不得不遞歸遍歷其子層來計算陰影的形狀,所以當你確知目標層的形狀時你可以爲其指定陰影路徑。陰影路徑決定了陰影的形狀。
//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再渲染每一幀的時候,都將計算每一個像素點的顏色。當最上面有一個不透明層的時候,計算每一個像素點的最終顏色非常簡單,只需要拷貝該最上面的層的對應點顏色即可。而混合層(非不透明層)則需要對對應的畫面區域進行顏色混合。
  在視圖的層次結構中,混合層越多,渲染的計算量就越大。如果最上面的層是混合層,渲染引擎需要處理其下面覆蓋的層,計算每一個點的顏色,如果該下面覆蓋的層也是混合層,引擎將繼續檢查其下面的層,如此遞歸下去。

162256bhyfhwfy7wvmjnjw.png


  你可以通過選中“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都將在執行輕量的段落之前監聽用戶輸入。

162256ylkkzng5k37y6g00.jpg


  要這樣做的話你必須將大段代碼分解成小的單元,並排入分開的運行循環中。有很多方法來做:
- (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
         }
    }];
}
  好了說到這裏終於完了。我們提到了一大堆關於優化的技術,希望大家能夠喜歡


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章