關于iOS中圖像顯示的一些優化處理

這篇文章是博主學習了幾位大牛的關于圖片處理和顯示的文章后,結合自己的總結和實踐后的記錄。幾位大牛的文章鏈接會在后面參上。因為水平有限,可能會有錯誤。

圖片的加載

在iOS中從磁盤讀取一張圖片并顯示在屏幕上,大概需要下面幾個步驟

  1. 從磁盤拷貝數據到內核緩沖區
  1. 從內核緩沖區復制數據到用戶空間
  2. 生成UIImageView,把圖像數據賦值給UIImageView
  3. 如果圖像數據為未解碼的PNG/JPG,解碼為位圖數據
  4. CATransaction捕獲到UIImageView layer樹的變化
  5. 主線程Runloop提交CATransaction,開始進行圖像渲染
    6.1. 果數據沒有字節對齊,Core Animation會再拷貝一份數據,進行字節對齊。
    6.2. GPU處理位圖數據,進行渲染。

簡單理解上面幾部的話大概就是把圖片從磁盤讀入內存,然后解碼,然后渲染。其中讀入內存的操作可以由子線程執行,解碼和渲染的過程系統默認是一定要在主線程執行的。這也是很多時候顯示圖片發生卡頓的原因。不過在開發中多數情況下我們都是使用圖片處理庫來處理圖片,這些庫已經解決了主線程解碼的問題(將解碼操作放在子線程)。渲染是一定要在主線程的,這個不需要解釋了。

關于卡頓的產生

說到卡頓的產生就不得不提到圖像的顯示原理,關于這部分內容在ibireme大神寫的文章中有很詳細的講解,這里我只簡單描述下(湊個字)。

在計算機系統中顯示器要想顯示畫面首先需要CPU計算好顯示內容,然后將計算好的結果交給GPU進行渲染,在雙緩存+垂直同步機制下(iOS始終是雙緩存+垂直同步)GPU會等到顯示器發送垂直同步信號(VSync)后將渲染后的結果更新到幀緩沖區等待視頻控制器讀取數據。

解釋了圖像顯示原理后再描述產生卡頓的原因就很好理解了,iOS設備的屏幕大概每秒刷新60次(這個值取決設備硬件,比如 iPhone 真機上通常是 59.97),也就是在這1/60秒內要完成CPU執行計算,GPU執行渲染變換等等然后提交幀緩存這些操作,等待下一次VSync信號到來后把結果顯示在屏幕上。

如果在1/60秒內這些操作沒有執行完成呢?這踏馬就尷尬了。那么這一幀將會被丟棄,等待下一次VSync信號。體現在屏幕上就是界面什么都沒做,還是顯示上一幀的內容。這在肉眼看來就是卡了。

我嘗試著在工程里存入了幾張平均大小在1.5m的png圖片,然后把這些圖片放在tableView里面以每個cell一張圖片的方式顯示。在這里還需要提一下系統加載jpeg圖片時速度會比png圖片要快,但是因為XCode會在引入png圖片時對png圖片進行解碼優化,所以解碼操作上png要比jpeg更快,因為jpeg圖片的解壓算法更復雜。主要代碼如下:

#define  Width  [UIScreen mainScreen].bounds.size.width
#define  Height  [UIScreen mainScreen].bounds.size.height
@interface TableViewController ()<UITableViewDelegate, UITableViewDataSource>
@property (nonatomic, strong) UITableView *table;
@property (nonatomic, strong) NSMutableArray *dataArr;
@property (nonatomic, strong) UIScrollView *scroll;
@end

@implementation TableViewController

- (void)viewDidLoad {
  [super viewDidLoad];
  self.view.backgroundColor = [UIColor whiteColor];

  self.dataArr = [NSMutableArray arrayWithCapacity:0];
  for (NSInteger i = 1; i < 6; i++) {
      NSString *path = [[NSBundle mainBundle] pathForResource:[NSString stringWithFormat:@"b%ld", i] ofType:@"png"];
      [self.dataArr addObject:path];
  }
  [self.view addSubview:self.table];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
  static NSString *identifier = @"cell";
  const NSInteger imageTag = 99;
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
  if (!cell) {
      cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
  }

  UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
  if (!imageView) {
      imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
      imageView.tag = imageTag;
      [cell.contentView addSubview:imageView];
  }
  cell.tag = indexPath.row;


  NSString *imagePath = self.dataArr[indexPath.row];
  imageView.image = [UIImage imageWithContentsOfFile:imagePath];
  return cell;
}

運行這段代碼發現已經發生了很嚴重的卡頓,原因在于這里將圖片的加載,解碼和渲染操作全部放在了主線程。在1/60秒內根本無法完成這些操作。所以需要將任務分散來緩解主線程壓力。至于為什么使用imageWithContentsOfFile:方法而不是imageNamed:,為了演示效果,我不希望系統將解碼后的圖片進行緩存所以沒有使用imageNamed方法。

關于UIImage的這兩個方法,他們的相同點是都只是將數據讀入內存而不進行解碼,只有當圖片將要顯示之前才會被解碼(事實上UIImage的幾個創建方法都是這樣的)。不同點是imageNamed:方法會在第一次解碼顯示之后將解碼后的位圖進行全局緩存,只有在程序退入后臺或者接收到內存警告時才會將位圖釋放。這也是為什么在第一次滑動tableVIew的時候會卡,之后再反復滑動就不會卡頓的原因。imageWithContentsOfFile:方法雖然在64位設備是默認也會緩存(緩存到CGImage內部),但是一旦圖片被釋放,緩存的數據也會被釋放。

imageWithContentsOfFile:這個方法的底層實現是調用了ImageIO框架的CGImageSourceCreateWithData()方法,該方法在有一個ShouldCache參數,64位設備上參數值默認是YES。(這句話99.9%抄襲自ibireme的文章)。

當我在看《iOS Core Animation: Advanced Techniques》這本書的時候,書中提到可以使用CGImageSourceCreateWithURL()方法生成image來避免延時解碼,不知道是我看的這版年代太久還是理解有誤。使用這個方法實質上和CGImageSourceCreateWithData()沒有什么區別,都達不到解碼的效果。并且在實際的代碼中測試發現確實沒有解決延時解碼的問題。測試代碼如下:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
static NSString *identifier = @"cell";
const NSInteger imageTag = 99;
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
}

  UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
    if (!imageView) {
      imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
      imageView.tag = imageTag;
      [cell.contentView addSubview:imageView];
  }
  cell.tag = indexPath.row;
  cell.tag = indexPath.row;
  imageView.image = nil;
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
      NSInteger index = indexPath.row;
      NSURL *imageURL = [NSURL fileURLWithPath:self.dataArr[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);
      dispatch_async(dispatch_get_main_queue(), ^{
          if (index == cell.tag) {
              imageView.image = image;
          }
      });
  });
  return cell;
}

將代碼修改為這樣后卡頓依然存在。

這里可以簡單實現一下異步解碼的操作,將cellForRow方法里面的部分代碼改成這樣:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
    NSInteger index = indexPath.row;
    NSString *imagePath = self.dataArr[index];
    UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
    //redraw image using device context
    UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 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;
        }
    });
});

將圖片繪制到畫布上,然后從畫布取出圖片。這樣做的好處是圖像的繪制完全可以放在后臺執行,我們只需在繪制完成后取出圖片,然后在主線程上賦值給UIImageView就可以,這個時候的圖片因為是程序繪制的就已經不再是jpg或者png或者其他格式了,所以自然也不需要解碼操作。這只是簡單地實現,具體的可是學習YYWebImage或者SDWebImageDecoder。

緩存和異步解碼只是緩解CPU壓力的方法之一,除此之外還有很多地方可以優化CPU和GPU資源,比如之前提到的對于圓角和陰影的處理。

另外還有一種比較有意思的方式用來顯示很大的圖片,就是使用CATiledLayer,在iOS6以前系統的地圖就是使用它來實現的。這個類的出現也是為了解決加載大圖造成的性能問題,它會將一張大圖分解成多張小圖碎片,然后分開顯示。關于CATiledLayer的使用我也是從《iOS Core Animation: Advanced Techniques》這本書中看到的,書中給了一個例子,將一張20482048的圖片分割成64張小圖,然后將CATiledLayer添加在一個大小是256 * 256的UIScrollView上,contentSize為20482048,開始的時候顯示第一片圖片,然后根據手勢的滑動方向以及當前的位置,CATiledLayer的代理方法- (void)drawLayer: inContext: 會加載相應的圖片碎片,就像是地圖應用中地圖會一塊一塊的加載出來一樣。

這種方法也可以使用到前面的例子當中,我們把代碼改成這樣:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
  static NSString *identifier = @"cell";
  const NSInteger imageTag = 99;
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
  if (!cell) {
      cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:identifier];
  }

  UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
  if (!imageView) {
      imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)];
      imageView.tag = imageTag;
      [cell.contentView addSubview:imageView];
  }
  cell.tag = indexPath.row;

  CATiledLayer *tileLayer = (CATiledLayer *)[cell.contentView.layer.sublayers lastObject];
  if (!tileLayer) {
      tileLayer = [CATiledLayer layer];
      tileLayer.frame = CGRectMake(0, 0, Width, Height);
      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];
  }
  tileLayer.contents = nil;
  [tileLayer setValue:@(indexPath.row) forKey:@"index"];
  [tileLayer setNeedsDisplay];

return cell;
}

可以看到當滑動屏幕的時候圖片的顯示會呈現碎片式的淡入淡出效果。

參考的文章:
iOS圖片加載速度極限優化—FastImageCache解析
iOS 處理圖片的一些小 Tip
iOS 保持界面流暢的技巧
《iOS Core Animation: Advanced Techniques》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,001評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,786評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,986評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,204評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,964評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,354評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,410評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,554評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,106評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,918評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,093評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,648評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,342評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,755評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,009評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,839評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,107評論 2 375

推薦閱讀更多精彩內容