天天看點

iOS 性能優化 (二)

主要介紹圖檔加載優化

繪圖實際消耗的時間通常并不是影響性能的因素。圖檔消耗很大一部分記憶體,而且不太可能把需要顯示的圖檔都保留在記憶體中,是以需要在應用運作的時候周期性地加載和解除安裝圖檔。

圖檔檔案加載的速度被CPU和IO(輸入/輸出)同時影響。iOS裝置中的閃存已經比傳統硬碟快很多了,但仍然比RAM慢将近200倍左右,這就需要很小心地管理加載,來避免延遲。

隻要有可能,試着在程式生命周期不易察覺的時候來加載圖檔,例如啟動,或者在螢幕切換的過程中。按下按鈕和按鈕響應事件之間最大的延遲大概是200ms,這比動畫每一幀切換的16ms小得多。你可以在程式首次啟動的時候加載圖檔,但是如果20秒内無法啟動程式的話,iOS檢測計時器就會終止你的應用(而且如果啟動大于2,3秒的話使用者就會抱怨了)。

有些時候,提前加載所有的東西并不明智。比如說包含上千張圖檔的圖檔傳送帶:使用者希望能夠能夠平滑快速翻動圖檔,是以就不可能提前預加載所有圖檔;那樣會消耗太多的時間和記憶體。

有時候圖檔也需要從遠端網絡連接配接中下載下傳,這将會比從磁盤加載要消耗更多的時間,甚至可能由于連接配接問題而加載失敗(在幾秒鐘嘗試之後)。你不能夠在主線程中加載網絡造成等待,是以需要背景線程。

線程加載

我們知道,聯系人的滾動清單中,圖檔都非常小,是以可以在主線程同步加載。但是對于大圖來說,這樣做就不太合适了,因為加載會消耗很長時間,造成滑動的不流暢。滑動動畫會在主線程的run loop中更新,是以會有更多運作在渲染服務程序中CPU相關的性能問題。

當圖檔尺寸為800x600像素的PNG,對iPhone5來說,1/60秒要加載大概700KB左右的圖檔。當頁面滾動的時候,圖檔也在實時加載,于是(預期中的)卡頓就發生了。Xcode–Open Developer Tool – Instruments 點選時間分析工具 Time Profiler(此處用真機測試release情況,而非模拟器),顯示了很多時間都消耗在了UIImage的+imageWithContentsOfFile:方法中了。很明顯,圖檔加載造成了瓶頸。

這裡提升性能唯一的方式就是在另一個線程中加載圖檔。這并不能夠降低實際的加載時間(可能情況會更糟,因為系統可能要消耗CPU時間來處理加載的圖檔資料),但是主線程能夠有時間做一些别的事情,比如響應使用者輸入,以及滑動動畫。為了在背景線程加載圖檔,我們可以使用GCD或者NSOperationQueue建立自定義線程,或者使用CATiledLayer。為了從遠端網絡加載圖檔,我們可以使用異步的NSURLConnection,但是對本地存儲的圖檔,并不十分有效。

GCD和NSOperationQueue

GCD(Grand Central Dispatch)和NSOperationQueue很類似,都給我們提供了隊列閉包塊來線上程中按一定順序來執行。NSOperationQueue有一個Objecive-C接口(而不是使用GCD的全局C函數),同樣在操作優先級和依賴關系上提供了很好的粒度控制,但是需要更多地設定代碼。

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
                    cellForItemAtIndexPath:(NSIndexPath *)indexPath
	{
    //dequeue cell
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell"
                                                                           forIndexPath:indexPath];
    //add image view
    const NSInteger imageTag = 99;
    UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
    if (!imageView) {
        imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
        imageView.tag = imageTag;
        [cell.contentView addSubview:imageView];
    }
    //tag cell with index and clear current image
    cell.tag = indexPath.row;
    imageView.image = nil;
    //switch to background thread
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
        //load image
        NSInteger index = indexPath.row;
        NSString *imagePath = self.imagePaths[index];
        UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
        //set image on main thread, but only if index still matches up
        dispatch_async(dispatch_get_main_queue(), ^{
            if (index == cell.tag) {
                imageView.image = image; }
        });
    });
    return cell;
	}
           

上圖代碼塊顯示了在低優先級的背景隊列而不是主線程使用GCD加載圖檔的-collectionView:cellForItemAtIndexPath:方法,然後當需要加載圖檔到視圖的時候切換到主線程,因為在背景線程通路視圖會有安全隐患。

由于視圖在UICollectionView會被循環利用,我們加載圖檔的時候不能确定是否被不同的索引重新複用。為了避免圖檔加載到錯誤的視圖中,我們在加載前把單元格打上索引的标簽,然後在設定圖檔的時候檢測标簽是否發生了改變。

再次打開時間檢測工具,當運作更新後的版本,性能比之前不用線程的版本好多了,但仍然并不完美。我們可以看到+imageWithContentsOfFile:方法并不在CPU時間軌迹的最頂部,是以我們的确修複了延遲加載的問題。問題在于我們假設傳送器的性能瓶頸在于圖檔檔案的加載,但實際上并不是這樣。加載圖檔資料到記憶體中隻是問題的第一部分。

延遲解壓

一旦圖檔檔案被加載就必須要進行解碼,解碼過程是一個相當複雜的任務,需要消耗非常長的時間。解碼後的圖檔将同樣使用相當大的記憶體。 用于加載的CPU時間相對于解碼來說根據圖檔格式而不同。對于PNG圖檔來說,加載會比JPEG更長,因為檔案可能更大,但是解碼會相對較快,而且Xcode會把PNG圖檔進行解碼優化之後引入工程。JPEG圖檔更小,加載更快,但是解壓的步驟要消耗更長的時間,因為JPEG解壓算法比基于zip的PNG算法更加複雜。 當加載圖檔的時候,iOS通常會延遲解壓圖檔的時間,直到加載到記憶體之後。這就會在準備繪制圖檔的時候影響性能,因為需要在繪制之前進行解壓(通常是消耗時間的問題所在)。

最簡單的方法就是使用UIImage的+imageNamed:方法避免延時加載。不像+imageWithContentsOfFile:(和其他别的UIImage加載方法),這個方法會在加載圖檔之後立刻進行解壓(就和本章之前我們談到的好處一樣)。問題在于+imageNamed:隻對從應用資源束中的圖檔有效,是以對使用者生成的圖檔内容或者是下載下傳的圖檔就沒法使用了。

另一種立刻加載圖檔的方法就是把它設定成圖層内容,或者是UIImageView的image屬性。不幸的是,這又需要在主線程執行,是以不會對性能有所提升。

第三種方式就是繞過UIKit,像下面這樣使用ImageIO架構:

NSInteger index = indexPath.row;
NSURL *imageURL = [NSURL fileURLWithPath:self.imagePaths[index]];
NSDictionary *options = @{(__bridge id)kCGImageSourceShouldCache: @YES}; 
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, NULL);
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0,(__bridge CFDictionaryRef)options);
UIImage *image = [UIImage imageWithCGImage:imageRef]; 
CGImageRelease(imageRef);
CFRelease(source);
           

這樣就可以使用kCGImageSourceShouldCache來建立圖檔,強制圖檔立刻解壓,然後在圖檔的生命周期保留解壓後的版本。

最後一種方式就是使用UIKit加載圖檔,但是立刻會知道CGContext中去。圖檔必須要在繪制之前解壓,是以就強制了解壓的及時性。這樣的好處在于繪制圖檔可以再背景線程(例如加載本身)執行,而不會阻塞UI。+

有兩種方式可以為強制解壓提前渲染圖檔:

  • 将圖檔的一個像素繪制成一個像素大小的CGContext。這樣仍然會解壓整張圖檔,但是繪制本身并沒有消耗任何時間。這樣的好處在于加載的圖檔并不會在特定的裝置上為繪制做優化,是以可以在任何時間點繪制出來。同樣iOS也就可以丢棄解壓後的圖檔來節省記憶體了。
  • 将整張圖檔繪制到CGContext中,丢棄原始的圖檔,并且用一個從上下文内容中新的圖檔來代替。這樣比繪制單一像素那樣需要更加複雜的計算,但是是以産生的圖檔将會為繪制做優化,而且由于原始壓縮圖檔被抛棄了,iOS就不能夠随時丢棄任何解壓後的圖檔來節省記憶體了。

需要注意的是蘋果特别推薦了不要使用這些詭計來繞過标準圖檔解壓邏輯(是以也是他們選擇用預設處理方式的原因),但是如果你使用很多大圖來建構應用,那如果想提升性能,就隻能和系統博弈了。

如果不使用+imageNamed:,那麼把整張圖檔繪制到CGContext可能是最佳的方式了。盡管你可能認為多餘的繪制相較别的解壓技術而言性能不是很高,但是新建立的圖檔(在特定的裝置上做過優化)可能比原始圖檔繪制的更快。

同樣,如果想顯示圖檔到比原始尺寸小的容器中,那麼一次性在背景線程重新繪制到正确的尺寸會比每次顯示的時候都做縮放會更有效(盡管在這個例子中我們加載的圖檔呈現正确的尺寸,是以不需要多餘的優化)。

如果修改了-collectionView:cellForItemAtIndexPath:方法來重繪圖檔(如下代碼片段),你會發現滑動更加平滑。

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
                  cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    //dequeue cell
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
    ...
    //switch to background thread
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
        //load image
        NSInteger index = indexPath.row;
        NSString *imagePath = self.imagePaths[index];
        UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
        //redraw image using device context
        UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, YES, 0);
        [image drawInRect:imageView.bounds];
        image = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        //set image on main thread, but only if index still matches up
        dispatch_async(dispatch_get_main_queue(), ^{
            if (index == cell.tag) {
                imageView.image = image;
            }
        });
    });
    return cell;
}