天天看點

iOS核心動畫進階技術(十四) 圖像IO

The idea of latency is worth thinking about. 潛伏期值得思考 - 凱文 帕薩特

在第13章“高效繪圖”中,我們研究了和Core Graphics繪圖相關的性能問題,以及如何修複。和繪圖性能相關緊密相關的是圖像性能。在這一章中,我們将研究如何優化從閃存驅動器或者網絡中加載和顯示圖檔。 #加載和潛伏 繪圖實際消耗的時間通常并不是影響性能的因素。圖檔消耗很大一部分記憶體,而且不太可能把需要顯示的圖檔都保留在記憶體中,是以需要在應用運作的時候周期性地加載和解除安裝圖檔。

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

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

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

有時候圖檔也需要從遠端網絡連接配接中下載下傳,這将會比從磁盤加載要消耗更多的時間,甚至可能由于連接配接問題而加載失敗(在幾秒鐘嘗試之後)。你不能夠在主線程中加載網絡造成等待,是以需要背景線程。 #線程加載 在第12章“性能調優”我們的聯系人清單例子中,圖檔都非常小,是以可以在主線程同步加載。但是對于大圖來說,這樣做就不太合适了,因為加載會消耗很長時間,造成滑動的不流暢。滑動動畫會在主線程的

run loop

中更新,是以會有更多運作在渲染服務程序中CPU相關的性能問題。

清單14.1顯示了一個通過

UICollectionView

實作的基礎的圖檔傳送器。圖檔在主線程中

-collectionView:cellForItemAtIndexPath:

方法中同步加載(見圖14.1)。

清單14.1 使用 UICollectionView 實作的圖檔傳送器

#import "ViewController.h"
@interface ViewController() <UICollectionViewDataSource> 
@property (nonatomic, copy) NSArray *imagePaths;
@property (nonatomic, weak) IBOutlet UICollectionView *collectionView; 
@end
@implementation ViewController
- (void)viewDidLoad {
  //set up data
  self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"];
  //register cell class
  [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
  return [self.imagePaths count];
}
- (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]; 
  }
  //set image
  NSString *imagePath = self.imagePaths[indexPath.row]; 
  imageView.image = [UIImage imageWithContentsOfFile:imagePath]; 
  return cell;
}
@end

複制代碼
           

傳送器中的圖檔尺寸為800x600像素的PNG,對iPhone5來說,1/60秒要加載大概700KB左右的圖檔。當傳送器滾動的時候,圖檔也在實時加載,于是(預期中 的)卡動就發生了。時間分析工具(圖14.2)顯示了很多時間都消耗在了

UIImage

+imageWithContentsOfFile:

方法中了。很明顯,圖檔加載造成了瓶頸。 這裡提升性能唯一的方式就是在另一個線程中加載圖檔。這并不能夠降低實際的加載時間(可能情況會更糟,因為系統可能要消耗CPU時間來處理加載的圖檔數 據),但是主線程能夠有時間做一些别的事情,比如響應使用者輸入,以及滑動動畫。

為了在背景線程加載圖檔,我們可以使用GCD或者 NSOperationQueue 建立自定義線程,或者使用 。為了從遠端網絡加載圖檔,我們可以使用異步的 ,但是對本地存儲的圖檔,并不十分有效。 #GCD和 NSOperationQueue GCD(Grand Central Dispatch)和

NSOperationQueue

很類似,都給我們提供了隊列閉包塊來線上程中按一定順序來執行。

NSOperationQueue

有一個 Objecive-C接口(而不是使用GCD的全局C函數),同樣在操作優先級和依賴關系上提供了很好的粒度控制,但是需要更多地設定代碼。

清單14.2顯示了在低優先級的背景隊列而不是主線程使用GCD加載圖檔的

-collectionView:cellForItemAtIndexPath:

方法,然後當需要加載圖檔到視圖 的時候切換到主線程,因為在背景線程通路視圖會有安全隐患。

由于視圖在

UICollectionView

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

清單14.2 使用GCD加載傳送圖檔

- (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;
 }
複制代碼
           

當運作更新後的版本,性能比之前不用線程的版本好多了,但仍然并不完美(圖 14.3)。

我們可以看到

+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:

方法來重繪圖檔 (清單14.3),你會發現滑動更加平滑。

清單14.3 強制圖檔解壓顯示

- (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 == imageView.tag) {
        imageView.image = image; 
      }
    }); 
  });
  return cell;
}
複制代碼
           

#CATiledLayer 如第6章“專用圖層”中的例子所示,

CATiledLayer

可以用來異步加載和顯示大型圖檔,而不阻塞使用者輸入。但是我們同樣可以使用

CATiledLayer

UICollectionView

中為每個表格建立分離的

CATiledLayer

執行個體加載傳動器圖檔,每個表格僅使用一個圖層。

這樣使用

CATiledLayer

有幾個潛在的弊端:

  • CATiledLayer 的隊列和緩存算法沒有暴露出來,是以我們隻能祈禱它能比對我們的需求
  • CATiledLayer 需要我們每次重繪圖檔到

    CGContext

    中,即使它已經解壓縮,而且和我們單元格尺寸一樣(是以可以直接用作圖層内容,而不需要重繪)。

我們來看看這些弊端有沒有造成不同:清單14.4顯示了使用

CATiledLayer

對圖檔傳送器的重新實作。

清單14.4 使用 CATiledLayer 的圖檔傳送器

#import "ViewController.h"
#import <QuartzCore/QuartzCore.h>
@interface ViewController() <UICollectionViewDataSource>
@property (nonatomic, copy) NSArray *imagePaths;
@property (nonatomic, weak) IBOutlet UICollectionView *collectionView;
@end
@implementation ViewController
- (void)viewDidLoad {
  //set up data
  self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"jpg" inDirectory:@"Vacation Photos"]; 
  [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
  return [self.imagePaths count];
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
{
  //register cell class
  cellForItemAtIndexPath:(NSIndexPath *)indexPath
  //dequeue cell
  UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
  //add the tiled layer
  CATiledLayer *tileLayer = [cell.contentView.layer.sublayers lastObject]; 
  if (!tileLayer)
  {
    tileLayer = [CATiledLayer layer];
    tileLayer.frame = cell.bounds; 
    tileLayer.contentsScale = [UIScreen mainScreen].scale; 
    tileLayer.tileSize = CGSizeMake(
    cell.bounds.size.width * [UIScreen mainScreen].scale,
    cell.bounds.size.height * [UIScreen mainScreen].scale); 
    tileLayer.delegate = self;
    [tileLayer setValue:@(indexPath.row) forKey:@"index"]; 
    [cell.contentView.layer addSublayer:tileLayer];
  }
  //tag the layer with the correct index and reload
  tileLayer.contents = nil;
  [tileLayer setValue:@(indexPath.row) forKey:@"index"]; 
  [tileLayer setNeedsDisplay];
  return cell;
}
- (void)drawLayer:(CATiledLayer *)layer inContext:(CGContextRef)ctx {
  //get image index
  NSInteger index = [[layer valueForKey:@"index"] integerValue];
  //load tile image
  NSString *imagePath = self.imagePaths[index];
  UIImage *tileImage = [UIImage imageWithContentsOfFile:imagePath];
  //calculate image rect
  CGFloat aspectRatio = tileImage.size.height / tileImage.size.width; 
  CGRect imageRect = CGRectZero;
  imageRect.size.width = layer.bounds.size.width;
  imageRect.size.height = layer.bounds.size.height * aspectRatio; 
  imageRect.origin.y = (layer.bounds.size.height - imageRect.size.height)/2;
  //draw tile
  UIGraphicsPushContext(ctx); 
  [tileImage drawInRect:imageRect]; 
  UIGraphicsPopContext();
}
@end
複制代碼
           

需要解釋幾點:

  • CATiledLayer

    tileSize

    屬性機關是像素,而不是點,是以為了保證瓦 片和表格尺寸一緻,需要乘以螢幕比例因子。
  • -drawLayer:inContext:

    方法中,我們需要知道圖層屬于哪一個

    indexPath

    以加載正确的圖檔。這裡我們利用了

    CALayer

    的KVC來存儲和檢索任意的值,将圖層和索引打标簽。

結果

CATiledLayer

工作的很好,性能問題解決了,而且和用GCD實作的代碼量差不多。僅有一個問題在于圖檔加載到螢幕上後有一個明顯的淡入(圖14.4)。

我們可以調整

CATiledLayer

fadeDuration

屬性來調整淡入的速度,或者直接将整個漸變移除,但是這并沒有根本性地去除問題:在圖檔加載到準備繪制的時候總會有一個延遲,這将會導緻滑動時候新圖檔的跳入。這并不是

CATiledLayer

的問題,使用GCD的版本也有這個問題。

即使使用上述我們讨論的所有加載圖檔和緩存的技術,有時候仍然會發現實時加載大圖還是有問題。就和13章中提到的那樣,iPad上一整個視網膜屏圖檔分辨率達到了2048x1536,而且會消耗12MB的RAM(未壓縮)。第三代iPad的硬體并不能支援1/60秒的幀率加載,解壓和顯示這種圖檔。即使用背景線程加載來避免動畫卡頓,仍然解決不了問題。

我們可以在加載的同時顯示一個占位圖檔,但這并沒有根本解決問題,我們可以做到更好。 #分辨率交換 視網膜分辨率(根據蘋果市場定義)代表了人的肉眼在正常視角距離能夠分辨的最小像素尺寸。但是這隻能應用于靜态像素。當觀察一個移動圖檔時,你的眼睛就會對細節不敏感,于是一個低分辨率的圖檔和視網膜品質的圖檔沒什麼差別了。

如果需要快速加載和顯示移動大圖,簡單的辦法就是欺騙人眼,在移動傳送器的時候顯示一個小圖(或者低分辨率),然後當停止的時候再換成大圖。這意味着我們需要對每張圖檔存儲兩份不同分辨率的副本,但是幸運的是,由于需要同時支援Retina和非Retina裝置,本來這就是普遍要做到的。

如果從遠端源或者使用者的相冊加載沒有可用的低分辨率版本圖檔,那就可以動态将大圖繪制到較小的

CGContext

,然後存儲到某處以備複用。

為了做到圖檔交換,我們需要利用

UIScrollView

的一些實作

UIScrollViewDelegate

協定的委托方法(和其他類似于

UITableView

UICollectionView

基于滾動視圖的控件一樣):

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;
複制代碼
           

你可以使用這幾個方法來檢測傳送器是否停止滾動,然後加載高分辨率的圖檔。隻要高分辨率圖檔和低分辨率圖檔尺寸顔色保持一緻,你會很難察覺到替換的過程(確定在同一台機器使用相同的圖像程式或者腳本生成這些圖檔)。 #緩存 如果有很多張圖檔要顯示,最好不要提前把所有都加載進來,而是應該當移出螢幕之後立刻銷毀。通過選擇性的緩存,你就可以避免來復原動時圖檔重複性的加載了。

緩存其實很簡單:就是存儲昂貴計算後的結果(或者是從閃存或者網絡加載的檔案)在記憶體中,以便後續使用,這樣通路起來很快。問題在于緩存本質上是一個權衡過程 --- 為了提升性能而消耗了記憶體,但是由于記憶體是一個非常寶貴的資源,是以不能把所有東西都做緩存。

何時将何物做緩存(做多久)并不總是很明顯。幸運的是,大多情況下,iOS都為我們做好了圖檔的緩存。

#+imageNamed: 方法 之前我們提到使用

[UIImage imageNamed:]

加載圖檔有個好處在于可以立刻解壓圖檔而不用等到繪制的時候。但是[UIImage imageNamed:]方法有另一個非常顯著的好處:它在記憶體中自動緩存了解壓後的圖檔,即使你自己沒有保留對它的任 何引用。

對于iOS應用那些主要的圖檔(例如圖示,按鈕和背景圖檔),使用

[UIImage imageNamed:]

加載圖檔是最簡單最有效的方式。在nib檔案中引用的圖檔同樣也是 這個機制,是以你很多時候都在隐式的使用它。

但是

[UIImage imageNamed:]

并不适用任何情況。它為使用者界面做了優化,但是并不是對應用程式需要顯示的所有類型的圖檔都适用。有些時候你還是要實作自己的緩存機制,原因如下:

  • [UIImage imageNamed:]

    方法僅僅适用于在應用程式資源束目錄下的圖檔, 但是大多數應用的許多圖檔都要從網絡或者是使用者的相機中擷取,是以

    [UIImage imageNamed:]

    就沒法用了。
  • [UIImage imageNamed:]

    緩存用來存儲應用界面的圖檔(按鈕,背景等 等)。如果對照片這種大圖也用這種緩存,那麼iOS系統就很可能會移除這些圖檔來節省記憶體。那麼在切換頁面時性能就會下降,因為這些圖檔都需要重新加載。對傳送器的圖檔使用一個單獨的緩存機制就可以把它和應用圖檔的生命周期解耦。
  • [UIImage imageNamed:]

    緩存機制并不是公開的,是以你不能很好地控制它。例如,你沒法做到檢測圖檔是否在加載之前就做了緩存,不能夠設定緩存大小,當圖檔沒用的時候也不能把它從緩存中移除。

#自定義緩存 建構一個所謂的緩存系統非常困難。菲爾卡爾頓曾經說過:“在計算機科學中隻有兩件難事:緩存和命名”。

如果要寫自己的圖檔緩存的話,那該如何實作呢?讓我們來看看要涉及哪些方面:

  • 選擇一個合适的緩存鍵 - 緩存鍵用來做圖檔的唯一辨別。如果實時建立圖檔, 通常不太好生成一個字元串來區分别的圖檔。在我們的圖檔傳送帶例子中就很 簡單,我們可以用圖檔的檔案名或者表格索引。
  • 提前緩存 - 如果生成和加載資料的代價很大,你可能想當第一次需要用到的時候再去加載和緩存。提前加載的邏輯是應用内在就有的,但是在我們的例子中,這也非常好實作,因為對于一個給定的位置和滾動方向,我們就可以精确地判斷出哪一張圖檔将會出現。
  • 緩存失效 - 如果圖檔檔案發生了變化,怎樣才能通知到緩存更新呢?這是個非常困難的問題(就像菲爾 卡爾頓提到的),但是幸運的是當從程式資源加載靜态圖檔的時候并不需要考慮這些。對使用者提供的圖檔來說(可能會被修改或者 覆寫),一個比較好的方式就是當圖檔緩存的時候打上一個時間戳以便當檔案更新的時候作比較。
  • 緩存回收 - 當記憶體不夠的時候,如何判斷哪些緩存需要清空呢?這就需要到你寫一個合适的算法了。幸運的是,對緩存回收的問題,蘋果提供了一個叫做

    NSCache

    通用的解決方案 #NSCache

    NSCache

    NSDictionary

    類似。你可以通過

    -setObject:forKey:

    -object:forKey:

    方法分别來插入,檢索。和字典不同的是,

    NSCache

    在系統低記憶體的時候自動丢棄存儲的對象。

NSCache

用來判斷何時丢棄對象的算法并沒有在文檔中給出,但是你可以使用

-setCountLimit:

方法設定緩存大小,以及

-setObject:forKey:cost:

來對每個存儲的對象指定消耗的值來提供一些暗示。

指定消耗數值可以用來指定相對的重建成本。如果對大圖指定一個大的消耗值, 那麼緩存就知道這些物體的存儲更加昂貴,于是當有大的性能問題的時候才會丢棄這些物體。你也可以用

-setTotalCostLimit:

方法來指定全體緩存的尺寸。

NSCache

是一個普遍的緩存解決方案,我們建立一個比傳送器案例更好的自定義的緩存類。(例如,我們可以基于不同的緩存圖檔索引和目前中間索引來判斷哪些圖檔需要首先被釋放)。但是

NSCache

對我們目前的緩存需求來說已經足夠了;沒必要過早做優化。

使用圖檔緩存和提前加載的實作來擴充之前的傳送器案例,然後來看看是否效果 更好(見清單14.5)。

清單14.5 添加緩存

#import "ViewController.h"
@interface ViewController() <UICollectionViewDataSource>
@property (nonatomic, copy) NSArray *imagePaths;
@property (nonatomic, weak) IBOutlet UICollectionView *collectionView;
@end
@implementation ViewController
- (void)viewDidLoad {
  //set up data
  self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"]; 
  [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
  return [self.imagePaths count]; 
}
- (UIImage *)loadImageAtIndex:(NSUInteger)index {
  //set up cache
  static NSCache *cache = nil; if (!cache)
  {
    cache = [[NSCache alloc] init]; 
  }
  //if already cached, return immediately
  UIImage *image = [cache objectForKey:@(index)]; 
  if (image)
  {
    return [image isKindOfClass:[NSNull class]]? nil: image; 
  }
  //set placeholder to avoid reloading image multiple times
  [cache setObject:[NSNull null] forKey:@(index)];
  //switch to background thread
  dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
    //load image
    NSString *imagePath = self.imagePaths[index];
    UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
    //redraw image using device context
    UIGraphicsBeginImageContextWithOptions(image.size, YES, 0); 
    [image drawAtPoint:CGPointZero];
    image = UIGraphicsGetImageFromCurrentImageContext(); 
    UIGraphicsEndImageContext();
    //set image for correct image view
    dispatch_async(dispatch_get_main_queue(), ^{ //cache the image
      [cache setObject:image forKey:@(index)];
      //display the image
      NSIndexPath *indexPath = [NSIndexPath indexPathForItem: index inSection:0]; 
      UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:indexPath]; 
      UIImageView *imageView = [cell.contentView.subviews lastObject]; 
      imageView.image = image;
    });
  });
  //not loaded yet
  return nil; 
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
  //dequeue cell
  UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
  //add image view
  UIImageView *imageView = [cell.contentView.subviews lastObject]; 
  if (!imageView)
  {
    imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
    imageView.contentMode = UIViewContentModeScaleAspectFit;
    [cell.contentView addSubview:imageView]; 
  }
  //set or load image for this index
  imageView.image = [self loadImageAtIndex:indexPath.item];
  //preload image for previous and next index
  if (indexPath.item < [self.imagePaths count] - 1) {
    [self loadImageAtIndex:indexPath.item + 1]; 
  }
  if (indexPath.item > 0) {
    [self loadImageAtIndex:indexPath.item - 1];
  }
  return cell; 
}
@end
複制代碼
           

果然效果更好了!當滾動的時候雖然還有一些圖檔進入的延遲,但是已經非常罕見了。緩存意味着我們做了更少的加載。這裡提前加載邏輯非常粗暴,其實可以把滑動速度和方向也考慮進來,但這已經比之前沒做緩存的版本好很多了。 #檔案格式 圖檔加載性能取決于加載大圖的時間和解壓小圖時間的權衡。很多蘋果的文檔都說PNG是iOS所有圖檔加載的最好格式。但這是極度誤導的過時資訊了。

PNG圖檔使用的無損壓縮算法可以比使用JPEG的圖檔做到更快地解壓,但是由于閃存通路的原因,這些加載的時間并沒有什麼差別。

清單14.6展示了标準的應用程式加載不同尺寸圖檔所需要時間的一些代碼。為了保證明驗的準确性,我們會測量每張圖檔的加載和繪制時間來確定考慮到解壓性能的因素。另外每隔一秒重複加載和繪制圖檔,這樣就可以取到平均時間,使得結果更加準确。

清單14.6

#import "ViewController.h"
static NSString *const ImageFolder = @"Coast Photos"; 
@interface ViewController () <UITableViewDataSource>
@property (nonatomic, copy) NSArray *items;
@property (nonatomic, weak) IBOutlet UITableView *tableView;
@end
@implementation ViewController
- (void)viewDidLoad {
  [super viewDidLoad];
  //set up image names
  self.items = @[@"2048x1536", @"1024x768", @"512x384", @"256x192", @"128x96", @"64x48", @"32x24"];
}
- (CFTimeInterval)loadImageForOneSec:(NSString *)path {
  //create drawing context to use for decompression
  UIGraphicsBeginImageContext(CGSizeMake(1, 1));
  //start timing
  NSInteger imagesLoaded = 0;
  CFTimeInterval endTime = 0;
  CFTimeInterval startTime = CFAbsoluteTimeGetCurrent(); 
  while (endTime - startTime < 1)
  {
    //load image
    UIImage *image = [UIImage imageWithContentsOfFile:path]; 
    //decompress image by drawing it
    [image drawAtPoint:CGPointZero];
    //update totals
    imagesLoaded ++;
    endTime = CFAbsoluteTimeGetCurrent(); 
  }
  //close context
  UIGraphicsEndImageContext();
  //calculate time per image
  return (endTime - startTime) / imagesLoaded; 
}
- (void)loadImageAtIndex:(NSUInteger)index {
  //load on background thread so as not to 
  //prevent the UI from updating between runs dispatch_async(
  dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
    //setup
    NSString *fileName = self.items[index];
    NSString *pngPath = [[NSBundle mainBundle] pathForResource:filename ofType:@"png" inDirectory:ImageFolder]; 
    NSString *jpgPath = [[NSBundle mainBundle] pathForResource:filename ofType:@"jpg" inDirectory:ImageFolder]; 
    NSInteger pngTime = [self loadImageForOneSec:pngPath] * 1000;
    NSInteger jpgTime = [self loadImageForOneSec:jpgPath] * 1000; 
    //updated UI on main thread
    dispatch_async(dispatch_get_main_queue(), ^{
      //find table cell and update
      NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0];
      UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
      cell.detailTextLabel.text =[NSString stringWithFormat:@"PNG: %03ims JPG: %03ims",pngTime, jpgTime];
    }); 
  });
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
  return [self.items count];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
  //dequeue cell
  UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"Cell"];
  if (!cell) {
    cell = [[UITableViewCell alloc] initWithStyle: UITableViewCellStyleValue1 reuseIdentifier:@"Cell"];
  }
  //set up cell
  NSString *imageName = self.items[indexPath.row]; 
  cell.textLabel.text = imageName;   
  cell.detailTextLabel.text = @"Loading...";
  //load image
  [self loadImageAtIndex:indexPath.row]; 
  return cell;
}
@end
複制代碼
           

PNG和JPEG壓縮算法作用于兩種不同的圖檔類型:JPEG對于噪點大的圖檔效果很好;但是PNG更适合于扁平顔色,鋒利的線條或者一些漸變色的圖檔。為了讓測評的基準更加公平,我們用一些不同的圖檔來做實驗:一張照片和一張彩虹色的漸變。JPEG版本的圖檔都用預設的Photoshop60%“高品質”設定編碼。結果見圖檔 14.5。

如結果所示,相對于不友好的PNG圖檔,相同像素的JPEG圖檔總是比PNG加載 更快,除非一些非常小的圖檔、但對于友好的PNG圖檔,一些中大尺寸的圖效果還是很好的。

是以對于之前的圖檔傳送器程式來說,JPEG會是個不錯的選擇。如果用JPEG的話,一些多線程和緩存政策都沒必要了。

但JPEG圖檔并不是所有情況都适用。如果圖檔需要一些透明效果,或者壓縮之後細節損耗很多,那就該考慮用别的格式了。蘋果在iOS系統中對PNG和JPEG都 做了一些優化,是以普通情況下都應該用這種格式。也就是說在一些特殊的情況下 才應該使用别的格式。 #混合圖檔 對于包含透明的圖檔來說,最好是使用壓縮透明通道的PNG圖檔和壓縮RGB部分的JPEG圖檔混合起來加載。這就對任何格式都适用了,而且無論從品質還是檔案尺寸還是加載性能來說都和PNG和JPEG的圖檔相近。相關分别加載顔色和遮罩圖檔并在運作時合成的代碼見14.7。

清單14.7 從PNG遮罩和JPEG建立的混合圖檔

#import "ViewController.h"
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIImageView *imageView; 
@end
@implementation ViewController
- (void)viewDidLoad {
  [super viewDidLoad]; 
  //load color image
  UIImage *image = [UIImage imageNamed:@"Snowman.jpg"]; 
  //load mask image
  UIImage *mask = [UIImage imageNamed:@"SnowmanMask.png"];
  //convert mask to correct format
  CGColorSpaceRef graySpace = CGColorSpaceCreateDeviceGray(); 
  CGImageRef maskRef = CGImageCreateCopyWithColorSpace(mask.CGImage, graySpace);   
  CGColorSpaceRelease(graySpace);
  //combine images
  CGImageRef resultRef = CGImageCreateWithMask(image.CGImage, maskRef); 
  UIImage *result = [UIImage imageWithCGImage:resultRef]; 
  CGImageRelease(resultRef);
  CGImageRelease(maskRef);
  //display result
  self.imageView.image = result; 
}
@end
複制代碼
           

對每張圖檔都使用兩個獨立的檔案确實有些累贅。JPNG的庫 (https://github.com/nicklockwood/JPNG) 對這個技術提供了一個開源的可以複用的實作,并且添加了直接使用

+imageNamed:

+imageWithContentsOfFile:

方法的支援。 #JPEG 2000 除了JPEG和PNG之外iOS還支援别的一些格式,例如TIFF和GIF,但是由于他們品質壓縮得更厲害,性能比JPEG和PNG糟糕的多,是以大多數情況并不用考慮。

但是iOS之後,蘋果低調添加了對JPEG 2000圖檔格式的支援,是以大多數人并不知道。它甚至并不被Xcode很好的支援 - JPEG 2000圖檔都沒在Interface Builder 中顯示。

但是JPEG 2000圖檔在(裝置和模拟器)運作時會有效,而且比JPEG品質更好,同樣也對透明通道有很好的支援。但是JPEG 2000圖檔在加載和顯示圖檔方面明顯要比PNG和JPEG慢得多,是以對圖檔大小比運作效率更敏感的時候,使用它是一個不錯的選擇。

但仍然要對JPEG 2000保持關注,因為在後續iOS版本說不定就對它的性能做提升,但是在現階段,混合圖檔對更小尺寸和品質的檔案性能會更好。 #PVRTC 目前市場的每個iOS裝置都使用了Imagination Technologies PowerVR圖像晶片作為GPU。PowerVR晶片支援一種叫做PVRTC(PowerVR Texture Compression)的标準圖檔壓縮。

和iOS上可用的大多數圖檔格式不同,PVRTC不用提前解壓就可以被直接繪制到螢幕上。這意味着在加載圖檔之後不需要有解壓操作,是以記憶體中的圖檔比其他圖檔格式大大減少了(這取決于壓縮設定,大概隻有1/60那麼大)。

但是PVRTC仍然有一些弊端:

  • 盡管加載的時候消耗了更少的RAM,PVRTC檔案比JPEG要大,有時候甚至比PNG還要大(這取決于具體内容),因為壓縮算法是針對于性能,而不是檔案尺寸。
  • PVRTC必須要是二維正方形,如果源圖檔不滿足這些要求,那必須要在轉換成PVRTC的時候強制拉伸或者填充空白空間。
  • 品質并不是很好,尤其是透明圖檔。通常看起來更像嚴重壓縮的JPEG檔案。
  • PVRTC不能用Core Graphics繪制,也不能在普通的UIImageView 顯示,也不能直接用作圖層的内容。你必須要用作OpenGL紋理加載PVRTC圖檔,然後映射到一對三角闆來在

    CAEAGLLayer

    或者

    GLKView

    中顯示。
  • 建立一個OpenGL紋理來繪制PVRTC圖檔的開銷相當昂貴。除非你想把所有圖檔繪制到一個相同的上下文,不然這完全不能發揮PVRTC的優勢。
  • PVRTC使用了一個不對稱的壓縮算法。盡管它幾乎立即解壓,但是壓縮過程相當漫長。在一個現代快速的桌面Mac電腦上,它甚至要消耗一分鐘甚至更多來生成一個PVRTC大圖。是以在iOS裝置上最好不要實時生成。

如果你願意使用OpehGL,而且即使提前生成圖檔也能忍受得了,那麼PVRTC将會提供相對于别的可用格式來說非常高效的加載性能。比如,可以在主線程1/60秒之内加載并顯示一張2048×2048的PVRTC圖檔(這已經足夠大來填充一個視網膜螢幕的iPad了),這就避免了很多使用線程或者緩存等等複雜的技術難度。

Xcode包含了一些指令行工具例如texturetool來生成PVRTC圖檔,但是用起來很不友善(它存在于Xcode應用程式束中),而且很受限制。一個更好的方案就是使 用Imagination Technologies PVRTexTool,可以從http://www.imgtec.com/powervr/insider/sdkdownloads 免費獲得。

安裝了PVRTexTool之後,就可以使用如下指令在終端中把一個合适大小的PNG 圖檔轉換成PVRTC檔案:

`/Applications/Imagination/PowerVR/GraphicsSDK/PVRTexTool/CL/OSX_x86/PVRTexToolCL -i {input_file_name}.png -o {output_file_name}.pvr -legacypvr -p -f PVRTC1_4 -q pvrtcbest`
複制代碼
           

清單14.8的代碼展示了加載和顯示PVRTC圖檔的步驟(第6章 CAEAGLLayer 例 子代碼改動而來)。

清單14.8 加載和顯示PVRTC圖檔

#import "ViewController.h" 
#import <QuartzCore/QuartzCore.h> 
#import <GLKit/GLKit.h>
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *glView; 
@property (nonatomic, strong) EAGLContext *glContext; 
@property (nonatomic, strong) CAEAGLLayer *glLayer; 
@property (nonatomic, assign) GLuint framebuffer; 
@property (nonatomic, assign) GLuint colorRenderbuffer; 
@property (nonatomic, assign) GLint framebufferWidth; 
@property (nonatomic, assign) GLint framebufferHeight; 
@property (nonatomic, strong) GLKBaseEffect *effect; 
@property (nonatomic, strong) GLKTextureInfo *textureInfo;
@end
@implementation ViewController
- (void)setUpBuffers {
  //set up frame buffer
  glGenFramebuffers(1, &_framebuffer); 
  glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer);
  //set up color render buffer
  glGenRenderbuffers(1, &_colorRenderbuffer); 
  glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer); 
  glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorRenderbuffer); 
  [self.glContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:self.glLayer]; 
  glGetRenderbufferParameteriv( GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &_framebufferWidth); 
  glGetRenderbufferParameteriv( GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &_framebufferHeight);
  //check success
  if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
    NSLog(@"Failed to make complete framebuffer object: %i", glCheckFramebufferStatus(GL_FRAMEBUFFER));
  } 
}
- (void)tearDownBuffers {
  if (_framebuffer) {
    //delete framebuffer
    glDeleteFramebuffers(1, &_framebuffer);
    _framebuffer = 0; 
  }
  if (_colorRenderbuffer) {
    //delete color render buffer
    glDeleteRenderbuffers(1, &_colorRenderbuffer);
    _colorRenderbuffer = 0; 
  }
}
- (void)drawFrame {
  //bind framebuffer & set viewport
  glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer); 
  glViewport(0, 0, _framebufferWidth, _framebufferHeight);
  //bind shader program
  [self.effect prepareToDraw];
  //clear the screen
  glClear(GL_COLOR_BUFFER_BIT); 
  glClearColor(0.0, 0.0, 0.0, 0.0);
  //set up vertices
  GLfloat vertices[] = {
    -1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f
  };
  //set up colors
  GLfloat texCoords[] = {
    0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f
  };
  //draw triangle
  glEnableVertexAttribArray(GLKVertexAttribPosition); 
  glEnableVertexAttribArray(GLKVertexAttribTexCoord0);
  glVertexAttribPointer(GLKVertexAttribPosition, 2, GL_FLOAT, GL_FALSE, 0, vertices); 
  glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_FLOAT, GL_FALSE, 0, texCoords); 
  glDrawArrays(GL_TRIANGLE_FAN, 0, 4);
  //present render buffer
  glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer);
  [self.glContext presentRenderbuffer:GL_RENDERBUFFER]; 
}
- (void)viewDidLoad {
  [super viewDidLoad];
  //set up context
  self.glContext =[[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
  [EAGLContext setCurrentContext:self.glContext];
  //set up layer
  self.glLayer = [CAEAGLLayer layer]; 
  self.glLayer.frame = self.glView.bounds; 
  self.glLayer.opaque = NO;
  [self.glView.layer addSublayer:self.glLayer]; 
  self.glLayer.drawableProperties = @{kEAGLDrawablePropertyRetainedBacking: @NO, kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8};
  //load texture
  glActiveTexture(GL_TEXTURE0);
  NSString *imageFile = [[NSBundle mainBundle] pathForResource:@"Snowman" ofType:@"pvr"];
  self.textureInfo = [GLKTextureLoader textureWithContentsOfFile:imageFile options:nil error:NULL];
  //create texture
  GLKEffectPropertyTexture *texture = [[GLKEffectPropertyTexture alloc] init];
  texture.enabled = YES;
  texture.envMode = GLKTextureEnvModeDecal; texture.name = self.textureInfo.name;
  //set up base effect
  self.effect = [[GLKBaseEffect alloc] init]; self.effect.texture2d0.name = texture.name;
  //set up buffers
  [self setUpBuffers];
  //draw frame
  [self drawFrame]; 
}
- (void)viewDidUnload {
  [self tearDownBuffers];
  [super viewDidUnload]; 
}
- (void)dealloc {
  [self tearDownBuffers];
  [EAGLContext setCurrentContext:nil]; 
}
@end
複制代碼
           

如你所見,非常不容易,如果你對在正常應用中使用PVRTC圖檔很感興趣的話 (例如基于OpenGL的遊戲),可以參考一下

GLView

的庫 (https://github.com/nicklockwood/GLView ),它提供了一個簡單 的

GLImageView

類,重新實作了

UIImageView

的各種功能,但同時提供了 PVRTC圖檔,而不需要你寫任何OpenGL代碼。 #總結 在這章中,我們研究了和圖檔加載解壓相關的性能問題,并延展了一系列解決方案。 在第15章“圖層性能”中,我們将讨論和圖層渲染群組合相關的性能問題。