iOS 深入分析大圖顯示問題

小和山吳彥祖發表於2018-04-23

前言

依稀記得很久以前被問到過這麼一個問題。

如果網路下載下來的圖片很大的情況下要怎麼處理。

那時候對這塊內容不是特別瞭解,大致只知道記憶體肯定會爆掉。然後回答的是超大圖就不顯示了吧???。後面也嘗試去Google了,但是可能那時候比較急躁,沒有很深入的去理解這個問題。今天我在回味YY大佬的iOS 處理圖片的一些小 Tip的時候看到了下面的評論裡面有人也提了相同的問題,大佬的回答是

可以參考蘋果官方例子: https://developer.apple.com/library/ios/samplecode/LargeImageDownsizing/ 另外,在用圖片庫時,用引數禁用解碼。

鑑於我最近高漲的學習興趣,決定去一探究竟。

載入大圖可能出現的問題

我這邊嘗試寫了個demo來看看具體會發生什麼。(空的初始化工程,只是首頁展示了這張圖片)

  • Xcode 9.3,模擬器是iPhoneX。
  • 圖片是8.3MB,JPEG格式,7033 × 10110尺寸。

具體結果:

  • 當用[UIImage imageNamed:name]的方式載入本地大圖的時候,記憶體的變化是 45 MB —> 318.5MB。可以說是記憶體暴增了,這樣的暴增帶來的結果很有可能就是閃退。

  • 當用SDWebImage或者YYWebImage載入的時候結果類似,有細微的幾MB的差別。差不多都是 45MB -> 240MB -> 47Mb。可以看到還是有段時間是記憶體暴增的情況,還是存在閃退的風險。

圖片載入流程

搞清楚這個問題之前,我們先來看一下圖片載入的具體流程。方便後面理解。

假設我們用的是imageNamed的方式來載入圖片

  • 1.先在bundle裡查詢圖片的檔名返回給image。

  • 2.載入圖片前,通過檔名載入image賦值給imageView。這個時候圖片並不會直接解壓縮。

  • 3.一個隱式的 CATransaction 捕獲到了 UIImageView 圖層樹的變化;

  • 4.在主執行緒的下一個 run loop 到來時,Core Animation 提交了這個隱式的 transaction ,這個過程可能會對圖片進行 copy 操作,而受圖片是否位元組對齊等因素的影響,這個 copy 操作可能會涉及以下部分或全部步驟:

      a.分配記憶體緩衝區用於管理檔案 IO 和解壓縮操作;
      b.將檔案資料從磁碟讀到記憶體中;
      c.將壓縮的圖片資料解碼成未壓縮的點陣圖形式,這是一個非常耗時的 CPU 操作;  
      d.最後 Core Animation 使用未壓縮的點陣圖資料渲染 UIImageView 的圖層。
    複製程式碼

我們可以看到,圖片並不是一賦值給imageView就顯示的。圖片需要在顯示之前解壓縮成未壓縮的點陣圖形式才能顯示。但是這樣的一個操作是非常耗時的CPU操作,並且這個操作是在主執行緒當中進行的。所以如果沒有特殊處理的情況下,在圖片很多的列表裡快速滑動的情況下會有效能問題。

為什麼要解壓縮呢

在接下去講內容之前先來解釋下這個問題。不管是 JPEG 還是 PNG 圖片,都是一種壓縮的點陣圖圖形格式。按照我的理解就是把原始的點陣圖資料壓縮一下,按照特定的格式刪掉一些內容,這樣一來資料就變少了。圖片也就變小了。但是我們展示的時候這樣壓縮過的格式是無法直接顯示的,我們需要拿到圖片的原始資料,所以我們就需要在展示前解壓縮一下。

解壓縮卡頓問題

上面提到,如果我們不做特殊處理的話,解壓縮會帶來一些效能問題。但是如果我們給imageView提供的是解壓縮後的點陣圖那麼系統就不會再進行解壓縮操作。這種方式也是SDWebImageYYWebImage的實現方式。具體解壓縮的原理就是CGBitmapContextCreate方法重新生產一張點陣圖然後把圖片繪製當這個點陣圖上,最後拿到的圖片就是解壓縮之後的圖片。

SDWebImage裡面的這部分程式碼

- (nullable UIImage *)sd_decompressedImageWithImage:(nullable UIImage *)image {
    if (![[self class] shouldDecodeImage:image]) {
        return image;
    }
    
    // autorelease the bitmap context and all vars to help system to free memory when there are memory warning.
    // on iOS7, do not forget to call [[SDImageCache sharedImageCache] clearMemory];
    @autoreleasepool{
        
        CGImageRef imageRef = image.CGImage;
        CGColorSpaceRef colorspaceRef = [[self class] colorSpaceForImageRef:imageRef];
        
        size_t width = CGImageGetWidth(imageRef);
        size_t height = CGImageGetHeight(imageRef);
        
        // kCGImageAlphaNone is not supported in CGBitmapContextCreate.
        // Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast
        // to create bitmap graphics contexts without alpha info.
        CGContextRef context = CGBitmapContextCreate(NULL,
                                                     width,
                                                     height,
                                                     kBitsPerComponent,
                                                     0,
                                                     colorspaceRef,
                                                     kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast);
        if (context == NULL) {
            return image;
        }
        
        // Draw the image into the context and retrieve the new bitmap image without alpha
        CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
        CGImageRef imageRefWithoutAlpha = CGBitmapContextCreateImage(context);
        UIImage *imageWithoutAlpha = [UIImage imageWithCGImage:imageRefWithoutAlpha
                                                         scale:image.scale
                                                   orientation:image.imageOrientation];
        
        CGContextRelease(context);
        CGImageRelease(imageRefWithoutAlpha);
        
        return imageWithoutAlpha;
    }
}
複製程式碼

YYWebImage裡的這部分程式碼

CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
    if (!imageRef) return NULL;
    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);
    if (width == 0 || height == 0) return NULL;
    
    if (decodeForDisplay) { //decode with redraw (may lose some precision)
        CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
        BOOL hasAlpha = NO;
        if (alphaInfo == kCGImageAlphaPremultipliedLast ||
            alphaInfo == kCGImageAlphaPremultipliedFirst ||
            alphaInfo == kCGImageAlphaLast ||
            alphaInfo == kCGImageAlphaFirst) {
            hasAlpha = YES;
        }
        // BGRA8888 (premultiplied) or BGRX8888
        // same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
        CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
        bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
        CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo);
        if (!context) return NULL;
        CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode
        CGImageRef newImage = CGBitmapContextCreateImage(context);
        CFRelease(context);
        return newImage;
        
    } else {
        CGColorSpaceRef space = CGImageGetColorSpace(imageRef);
        size_t bitsPerComponent = CGImageGetBitsPerComponent(imageRef);
        size_t bitsPerPixel = CGImageGetBitsPerPixel(imageRef);
        size_t bytesPerRow = CGImageGetBytesPerRow(imageRef);
        CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(imageRef);
        if (bytesPerRow == 0 || width == 0 || height == 0) return NULL;
        
        CGDataProviderRef dataProvider = CGImageGetDataProvider(imageRef);
        if (!dataProvider) return NULL;
        CFDataRef data = CGDataProviderCopyData(dataProvider); // decode
        if (!data) return NULL;
        
        CGDataProviderRef newProvider = CGDataProviderCreateWithCFData(data);
        CFRelease(data);
        if (!newProvider) return NULL;
        
        CGImageRef newImage = CGImageCreate(width, height, bitsPerComponent, bitsPerPixel, bytesPerRow, space, bitmapInfo, newProvider, NULL, false, kCGRenderingIntentDefault);
        CFRelease(newProvider);
        return newImage;
    }
}
複製程式碼

問題分析

現在來分析一下為什麼會出現記憶體暴增的問題,上面的內容看似和這個問題沒有上面聯絡。其實不然,上面我們都知道,用系統的方法和用SDWebImage或者YYWebImage載入圖片的時候都涉及到了一個解壓縮的操作,而解壓縮的操作有涉及到了一個點陣圖的建立。

第三方庫

先看看SDWebImage或者YYWebImage,它們用的是CGBitmapContextCreate這個方法。我在文件裡發現我們需要傳遞一個data引數,文件裡的解釋是如下。

data A pointer to the destination in memory where the drawing is to be rendered. The size of this memory block should be at least (bytesPerRow*height) bytes.

Pass NULL if you want this function to allocate memory for the bitmap. This frees you from managing your own memory, which reduces memory leak issues.

也就是說我們需要去生成一個一塊大小為bytesPerRow*height的記憶體,通過查閱其它部落格發現最後的計算記憶體大小的邏輯是(寬度 * 高度 * 4 (一個畫素4個位元組)),當然你也可以傳NULL,這樣的話系統就會幫你去建立。我們上面用的圖片是7033 × 10110計算出來的尺寸是271MB。這裡和上面看見的大小有細微出入,因為不是用的instruments所以可能不是很準,而且可能其它的東西會影響到這個結果。這裡暫且不論。我們看到上面的demo裡記憶體最後會有一個回落的過程,結合上面兩個庫裡的程式碼可以看到,拿到圖片之後這兩個庫都把點陣圖釋放掉了,記憶體得以釋放。所以記憶體也就回落了。

系統方法

看不到系統具體的建立方法,但是建立點陣圖的過程應該類似。並且我們都知道imageNamed載入圖片的方式最後會把點陣圖存到一個全域性快取裡面去,所以用系統的方式我們看不到記憶體的回落。

階段總結

記憶體暴增就是因為,解壓縮展示大圖的時候我們建立的點陣圖太大了,佔用了很大的記憶體空間。

解決方法

通過上面的分析我們已經知道具體的原因了。

1.第三方庫用引數禁用解碼

之前YY大佬的解釋裡的用引數禁用解碼也就很好理解了。由於用第三方庫的時候都是提前做了一步解壓縮操作,所以當圖片很大的情況下這一步建立的點陣圖會佔用很大的記憶體。所以我們需要禁止解壓縮。

2.使用蘋果的方案來顯示圖片

如果圖片不是解壓縮完的點陣圖,那麼想要顯示在螢幕上無論如何都是要做解壓縮的,之前第三方只是提前做了這步的操作。竟然有現成的方案了,我們來看一下具體是需要怎麼處理的。

核心的方法邏輯:

  • 計算大圖顯示對應當前機型對應的縮放比例,分塊繪製的高度等等資料,分塊繪製的組的數量
  • CGBitmapContextCreate方法生成一張比例縮放的點陣圖。
  • CGImageCreateWithImageInRect根據計算的rect獲取到圖片資料。
  • CGContextDrawImage根據計算的rect把獲取到的圖片資料繪製到點陣圖上。
  • CGBitmapContextCreateImage繪製完畢獲取到圖片顯示。

畫了個好像沒什麼用的圖:

iOS 深入分析大圖顯示問題

具體程式碼:

-(void)downsize:(id)arg {
    // 建立NSAutoreleasePool
    NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];

    // 獲取圖片,這個時候是不會繪製
    sourceImage = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:kImageFilename ofType:nil]];
    if( sourceImage == nil ) NSLog(@"input image not found!");

    // 拿到當前圖片的寬高
    sourceResolution.width = CGImageGetWidth(sourceImage.CGImage);
    sourceResolution.height = CGImageGetHeight(sourceImage.CGImage);

    // 當前圖片的畫素
    sourceTotalPixels = sourceResolution.width * sourceResolution.height;

    // 當前圖片渲染到介面上的大小
    sourceTotalMB = sourceTotalPixels / pixelsPerMB;

    // 獲取當前最合適的圖片渲染大小,計算圖片的縮放比例
    imageScale = destTotalPixels / sourceTotalPixels;

    // 拿到縮放後的寬高
    destResolution.width = (int)( sourceResolution.width * imageScale );
    destResolution.height = (int)( sourceResolution.height * imageScale );

    // 生成一個rgb的顏色空間
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    
    // 縮放情況下的每一行的位元組數
    int bytesPerRow = bytesPerPixel * destResolution.width;

    // 計算縮放情況下的點陣圖大小,申請一塊記憶體
    void* destBitmapData = malloc( bytesPerRow * destResolution.height );
    if( destBitmapData == NULL ) NSLog(@"failed to allocate space for the output image!");

    // 根據計算的引數生成一個合適尺寸的點陣圖
    destContext = CGBitmapContextCreate( destBitmapData, destResolution.width, destResolution.height, 8, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast );

    // 如果生成失敗了釋放掉之前申請的記憶體
    if( destContext == NULL ) {
        free( destBitmapData ); 
        NSLog(@"failed to create the output bitmap context!");
    }        

    // 釋放掉顏色空間
    CGColorSpaceRelease( colorSpace );
    
    // 座標系轉換
    CGContextTranslateCTM( destContext, 0.0f, destResolution.height );
    CGContextScaleCTM( destContext, 1.0f, -1.0f );
    
    // 分塊繪製的寬度(原始寬度)
    sourceTile.size.width = sourceResolution.width;
    
    // 分塊繪製的高度
    sourceTile.size.height = (int)( tileTotalPixels / sourceTile.size.width );     
    NSLog(@"source tile size: %f x %f",sourceTile.size.width, sourceTile.size.height);
    sourceTile.origin.x = 0.0f;

    // 繪製到點陣圖上的寬高
    destTile.size.width = destResolution.width;
    destTile.size.height = sourceTile.size.height * imageScale;        
    destTile.origin.x = 0.0f;
    NSLog(@"dest tile size: %f x %f",destTile.size.width, destTile.size.height);
    
    // 重合的畫素
    sourceSeemOverlap = (int)( ( destSeemOverlap / destResolution.height ) * sourceResolution.height );
    NSLog(@"dest seem overlap: %f, source seem overlap: %f",destSeemOverlap, sourceSeemOverlap);    
    CGImageRef sourceTileImageRef;

    // 分塊繪製需要多少次才能繪製完成
    int iterations = (int)( sourceResolution.height / sourceTile.size.height );
    int remainder = (int)sourceResolution.height % (int)sourceTile.size.height;
    if( remainder ) iterations++;
    
    // 新增重合線條
    float sourceTileHeightMinusOverlap = sourceTile.size.height;
    sourceTile.size.height += sourceSeemOverlap;
    destTile.size.height += destSeemOverlap;    

    // 分塊繪製
    for( int y = 0; y < iterations; ++y ) {
        // create an autorelease pool to catch calls to -autorelease made within the downsize loop.
        NSAutoreleasePool* pool2 = [[NSAutoreleasePool alloc] init];
        NSLog(@"iteration %d of %d",y+1,iterations);
        sourceTile.origin.y = y * sourceTileHeightMinusOverlap + sourceSeemOverlap; 
        destTile.origin.y = ( destResolution.height ) - ( ( y + 1 ) * sourceTileHeightMinusOverlap * imageScale + destSeemOverlap );
        
        // 分塊拿到圖片資料
        sourceTileImageRef = CGImageCreateWithImageInRect( sourceImage.CGImage, sourceTile );
        
        // 計算繪製的位置
        if( y == iterations - 1 && remainder ) {
            float dify = destTile.size.height;
            destTile.size.height = CGImageGetHeight( sourceTileImageRef ) * imageScale;
            dify -= destTile.size.height;
            destTile.origin.y += dify;
        }
        
        // 繪製到點陣圖上
        CGContextDrawImage( destContext, destTile, sourceTileImageRef );
        
        // 釋放記憶體
        CGImageRelease( sourceTileImageRef );
        [sourceImage release];
        [pool2 drain];
        
        // 更新圖片顯示
        if( y < iterations - 1 ) {            
            sourceImage = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:kImageFilename ofType:nil]];
            [self performSelectorOnMainThread:@selector(updateScrollView:) withObject:nil waitUntilDone:YES];
        }
    }

    // 顯示圖片,釋放記憶體
    [self performSelectorOnMainThread:@selector(initializeScrollView:) withObject:nil waitUntilDone:YES];
	CGContextRelease( destContext );
    [pool drain];
}
複製程式碼

最後

希望能對大家有一點點的幫助。

參考連結

談談 iOS 中圖片的解壓縮

iOS 處理圖片的一些小 Tip

相關文章