iOS圖片內存管理和性能優化

圖片在計算機中如何存儲和表示?

常見的圖片格式

JPEG 是目前最常見的圖片格式,它誕生于1992年,是一個很古老的格式。它只支持有損壓縮,其壓縮算法可以精確控制壓縮比,以圖像質量換得存儲空間。由于它太過常見,以至于許多移動設備的 CPU 都支持針對它的硬編碼與硬解碼。

PNG 誕生在 1995 年,比 JPEG 晚幾年。它本身的設計目的是替代 GIF 格式,所以它與 GIF 有更多相似的地方。PNG 只支持無損壓縮,所以它的壓縮比是有上限的。相對于 JPEG 和 GIF 來說,它最大的優勢在于支持完整的透明通道

GIF 誕生于 1987 年,隨著初代互聯網流行開來。它有很多缺點,比如通常情況下只支持 256 種顏色、透明通道只有 1 bit、文件壓縮比不高。它唯一的優勢就是支持多幀動畫,憑借這個特性,它得以從 Windows 1.0 時代流行至今,而且仍然大受歡迎。

格式 優點 缺點 用途
jpg 色彩豐富,文件小 有損壓縮 顏色豐富的圖
png 透明、無損壓縮、簡單圖文件小 若顏色較多復雜,則圖片生成后的文件很大 小圖標、透明背景
gif 動態、透明、文件小 色域不廣、只有256種顏色 動態圖片

除了以上面常見的格式,也有一些新型的格式:

APNG 是 Mozilla 在 2008 年發布的一種圖片格式,旨在替換掉畫質低劣的 GIF 動畫。它實際上只是相當于 PNG 格式的一個擴展,所以 Mozilla 一直想把它合并到 PNG 標準里面去。然而 PNG 開發組并沒有接受 APNG 這個擴展,而是一直在推進它自己的 MNG 動圖格式。MNG 格式過于復雜以至于并沒有什么系統或瀏覽器支持,而 APNG 格式由于簡單容易實現,目前已經漸漸流行開來。Mozilla 自己的 Firefox 首先支持了 APNG,隨后蘋果的 Safari 也開始有了支持, Chrome 目前也已經嘗試開始支持 ,可以說未來前景很好

APNG 與 Gif 對比

WebP 是 Google 在 2010 年發布的圖片格式,希望以更高的壓縮比替代 JPEG。它用 VP8 視頻幀內編碼作為其算法基礎,取得了不錯的壓縮效果。它支持有損和無損壓縮、支持完整的透明通道、也支持多幀動畫,并且沒有版權問題,是一種非常理想的圖片格式(美中不足的是,WebP格式圖像的編碼時間“比JPEG格式圖像長8倍)。借由 Google 在網絡世界的影響力,WebP 在幾年的時間內已經得到了廣泛的應用。看看你手機里的 App:微博、微信、QQ、淘寶、網易新聞等等,每個 App 里都有 WebP 的身影。Facebook 則更進一步,用 WebP 來顯示聊天界面的貼紙動畫。

關于以上幾種圖片格式在移動端的解碼和性能對比參見:移動端圖片格式調研

iOS中圖片加載過程和性能瓶頸

如上文所說,大部分格式的圖片都是被壓縮的,都需要被首先解碼為bitmap(未壓縮的位圖),然后才能渲染到UI上。
UIImageView 顯示圖片,也有類似的過程。實際上,一張圖片從在文件系統中,到被顯示到 UIImageView,會經歷以下幾個步驟:

  1. 假設我們使用 +imageWithContentsOfFile: 方法從磁盤中加載一張圖片,這個時候的圖片并沒有解壓縮;
  2. 然后將生成的 UIImage 賦值給 UIImageView
  3. 接著一個隱式的 CATransaction 捕獲到了 UIImageView 圖層樹的變化;
  4. 在主線程的下一個 run loop 到來時,Core Animation 提交了這個隱式的 transaction ,這個過程可能會對圖片進行 copy 操作,而受圖片是否字節對齊等因素的影響,這個 copy 操作可能會涉及以下部分或全部步驟:
    1. 分配內存緩沖區用于管理文件 IO 和解壓縮操作;
    2. 將文件數據從磁盤讀到內存中;
    3. 將壓縮的圖片數據解碼成未壓縮的位圖形式,這是一個非常耗時的 CPU 操作;
    4. 最后 Core Animation 使用未壓縮的位圖數據渲染 UIImageView 的圖層。

在上面的步驟中,我們提到了圖片的解壓縮是一個非常耗時的 CPU 操作,并且它默認是在主線程中執行的。那么當需要加載的圖片比較多時,就會對我們應用的響應性造成嚴重的影響,尤其是在快速滑動的列表上,這個問題會表現得更加突出。這就是 UIImageView 的一個性能瓶頸。

實際上,當我們調用[UIImage imageNamed:@"xxx"]后,UIImage 中存儲的是未解碼的圖片,而調用 [UIImageView setImage:image]后,會在主線程進行圖片的解碼工作并且將圖片顯示到 UI 上,這時候,UIImage 中存儲的是解碼后的 bitmap 數據。

為什么需要解壓縮

既然圖片的解壓縮需要消耗大量的 CPU 時間,那么我們為什么還要對圖片進行解壓縮呢?是否可以不經過解壓縮,而直接將圖片顯示到屏幕上呢?答案是否定的。要想弄明白這個問題,我們首先需要知道什么是位圖

bitmap:bitmap 又叫位圖文件,它是一種非壓縮的圖片格式,所以體積非常大。所謂的非壓縮,就是圖片每個像素的原始信息在存儲器中依次排列,一張典型的1920*1080像素的 bitmap 圖片,每個像素由 RGBA 四個字節表示顏色,那么它的體積就是 1920 * 1080 * 4 = 1012.5kb。

由于 bitmap 簡單順序存儲圖片的像素信息,它可以不經過解碼就直接被渲染到 UI 上。實際上,其它格式的圖片都需要先被首先解碼為 bitmap,然后才能渲染到界面上

不管是 JPEG 還是 PNG 圖片,都是一種壓縮的位圖圖形格式。只不過 PNG 圖片是無損壓縮,并且支持 alpha 通道,而 JPEG 圖片則是有損壓縮,可以指定 0-100% 的壓縮比。值得一提的是,在蘋果的 SDK 中專門提供了兩個函數用來生成 PNG 和 JPEG 圖片:

// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);

// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)                           
UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);

因此,在將磁盤中的圖片渲染到屏幕之前,必須先要得到圖片的原始像素數據,才能執行后續的繪制操作,這就是為什么需要對圖片解壓縮的原因。

圖片解壓縮的過程其實就是將圖片的二進制數據轉換成像素數據的過程

圖片的編碼和解碼
iOS 底層是用 ImageIO.framework 實現的圖片編解碼。目前 iOS 原生支持的格式有:JPEG、JPEG2000、PNG、GIF、BMP、ICO、TIFF、PICT,自 iOS 8.0 起,ImageIO 又加入了 APNG、SVG、RAW 格式的支持。在上層,開發者可以直接調用 ImageIO 對上面這些圖片格式進行編碼和解碼。對于動圖來說,開發者可以解碼動畫 GIF 和 APNG、可以編碼動畫 GIF。

注意:圖片所占內存的大小與圖片的尺寸有關,而不是圖片的文件大小

色彩空間和像素格式

計算圖片解碼后每行需要的比特數,由兩個參數相乘得到:每行的像素數 width,和存儲一個像素需要的比特數4

這里的4,其實是由每張圖片的像素格式和像素組合來決定的,下表是蘋果平臺支持的像素組合方式

image

)

表中的bpp,表示每個像素需要多少位;bpc表示顏色的每個分量,需要多少位。具體的解釋方式,可以看下面這張圖:

我們解碼后的圖片,默認采用 kCGImageAlphaNoneSkipLast RGB 的像素組合,沒有 alpha 通道,每個像素32位4個字節,前三個字節代表紅綠藍三個通道, 但是有時候,如果我們只是繪制一個蒙版,是不需要這么字節表示的比如 Alpha 8 format,每個像素只需要占用 1 個字節,這之間的差距就造成了內存浪費。

UIGraphicsImageRenderer 和 UIGraphicsBeginImageContextWithOptions

當我們為了離屏渲染,要創建 image buffer 時,我們通常會使用 UIGraphicsBeginImageContext,但是最好還是用 UIGraphicsImageRenderer,它的性能更好、更高效,并且支持廣色域。這里有一個中間地帶,如果你主要將圖像渲染到圖形圖像渲染器(graphic image render)中,該圖像可能使用超出 SRGB 色域的色彩空間值,但實際上并不需要更大的元素來存儲這些信息。所以 UIImage 有一個可以用來獲取預構建的 UIGraphicsImageRendererFormat 對象的 image renderer format 屬性,該對象用于重新渲染圖像時進行最優化存儲。

所以蘋果官方建議使用 UIGraphicsImageRenderer,這個方法是從 iOS 10 引入,在 iOS 12 上會自動選擇最佳的圖像格式,可以減少很多內存。系統可以根據圖片分辨率選擇創建解碼圖片的格式,如選用SRGB format 格式,每個像素占用 4 字節,而Alpha 8 format,每像素只占用 1 字節,可以減少大量的解碼內存占用。

擴展閱讀色彩空間與像素格式

imageWithContentsOfFile 和 imageNamed 對比

imgeNamed

用這個方法加載圖片分為兩種情況:

  1. 系統緩存有這個圖片,直接從緩存中取得
  2. 系統緩存沒有這個圖片
    通過傳入的文件名對整個工程進行遍歷 (在application bundle的頂層文件夾尋找名字的圖象 ), 如果如果找到對應的圖片,iOS 系統首先要做的是將這個圖片放到系統緩存中去,以備下次使用的時候直接從系統緩存中取, 接下來重復第一步,即直接從緩存中取

由于系統會緩存圖片,所以如果要加載的這個圖片的文件量很多,文件大小很大,內存不足,內存泄露,甚至是程序的崩潰都是很容易發生的事.

imageWithContentsOfFile

用這個方法只有一種情況,那就是僅僅加載圖片, 圖像數據不會被緩存. 因此在加載較大圖片的時候, 以及圖片使用情況很少的時候可以使用這兩個方法 , 降低內存消耗.

加載本地圖片,要比從Assets Catalogs耗時要長,具體見Assets Catalogs 與 I/O 優化

圖片內存優化

到此我們可知,圖片經過解壓之后,在內存中實際是根據圖片的分辨率和圖片渲染所用的像素格式而定的

一、對不常用的大圖片,使用 imageWithContentsOfFile 代替 imageNamed 方法,避免內存緩存(相應的使用imageNamed要避免載入大量的圖片造成內存暴增)

二、使用 ImageIO 方法,對大圖片進行縮放,減少圖片解碼占用內存大小。

UIImage 在設置和調整大小的時候,需要將原始圖像加壓到內存中,然后對內部坐標空間做一系列轉換,整個過程會消耗很多資源。我們可以使用 ImageIO,它可以直接讀取圖像大小和元數據信息,不會帶來額外的內存開銷。

三、繪制圖片,用 UIGraphicsImageRenderer 代替 UIGraphicsBeginImageContextWithOptions,自動管理顏色格式

四、超大圖片處理

  1. 加載使用蘋果推薦的DownSampling方案(縮略圖方式)
     // DownSampling(降低采樣)
     // 在視圖比較小,圖片比較大的場景下,直接展示原圖片會造成不必要的內存和CPU消耗,這里就可以使用ImageIO的接口,DownSampling,也就是生成縮略圖
     func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage
     {
         let sourceOpt = [kCGImageSourceShouldCache : false] as CFDictionary
         /**<
          這里有兩個注意事項
          
          設置kCGImageSourceShouldCache為false,避免緩存解碼后的數據,64位設置上默認是開啟緩存的,(很好理解,因為下次使用該圖片的時候,可能場景不同,需要生成的縮略圖大小是不同的,顯然不能做緩存處理)
          設置kCGImageSourceShouldCacheImmediately為true,避免在需要渲染的時候才做解碼,默認選項是false
          */
         // 其他場景可以用createwithdata (data并未decode,所占內存沒那么大),
         let source = CGImageSourceCreateWithURL(imageURL as CFURL, sourceOpt)!
         
         let maxDimension = max(pointSize.width, pointSize.height) * scale
         let downsampleOpt = [kCGImageSourceCreateThumbnailFromImageAlways : true,
                              kCGImageSourceShouldCacheImmediately : true ,
                              kCGImageSourceCreateThumbnailWithTransform : true,
                              kCGImageSourceThumbnailMaxPixelSize : maxDimension] as CFDictionary
         let downsampleImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOpt)!
         return UIImage(cgImage: downsampleImage)
     }
    
  2. 使用蘋果的CATiledLayer去加載。原理是分片渲染,滑動時通過指定目標位置,通過映射原圖指定位置的部分圖片數據解碼渲染。這里不再累述,有興趣的小伙伴可以自行了解下官方API。

五、網絡圖片加載方式:使用SDwebImage等三方庫

解決UIImageView的性能瓶頸

我們在討論UIImageView的性能瓶頸中發現,問題在于主線程進行圖片解壓縮占用了大量的CPU,解決問題的辦法就是:在子線程提前對圖片進行強制解壓縮

而強制解壓縮的原理就是對圖片進行重新繪制,得到一張新的解壓縮后的位圖。其中,用到的最核心的函數是 CGBitmapContextCreate :

/* Create a bitmap context. The context draws into a bitmap which is `width'
   pixels wide and `height' pixels high. The number of components for each
   pixel is specified by `space', which may also specify a destination color
   profile. The number of bits for each component of a pixel is specified by
   `bitsPerComponent'. The number of bytes per pixel is equal to
   `(bitsPerComponent * number of components + 7)/8'. Each row of the bitmap
   consists of `bytesPerRow' bytes, which must be at least `width * bytes
   per pixel' bytes; in addition, `bytesPerRow' must be an integer multiple
   of the number of bytes per pixel. `data', if non-NULL, points to a block
   of memory at least `bytesPerRow * height' bytes. If `data' is NULL, the
   data for context is allocated automatically and freed when the context is
   deallocated. `bitmapInfo' specifies whether the bitmap should contain an
   alpha channel and how it's to be generated, along with whether the
   components are floating-point or integer. */
CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(void * __nullable data,
    size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow,
    CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo)
    CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0);

如果 UIImage 中存儲的是已經解碼后的數據,速度就會快很多,所以優化的思路就是:在子線程中對圖片原始數據進行強制解碼,再將解碼后的圖片拋回主線程繼續使用,從而提高主線程的響應速度。
我們需要使用的工具是 Core Graphics 框架的 CGBitmapContextCreate 方法和相關的繪制函數。總體的步驟是:

  1. 創建一個指定大小和格式的 bitmap context
  2. 將未解碼圖片寫入到這個 context 中,這個過程包含了強制解碼。
  3. 從這個 context 中創建新的 UIImage 對象,返回。

SDWebImage 實現

下面是SDWebImage的核心代碼:

// 1. 從 UIImage 對象中獲取 CGImageRef 的引用。這兩個結構是蘋果在不同層級上對圖片的表示方式,UIImage 屬于 UIKit,是 UI 層級圖片的抽象,用于圖片的展示;CGImageRef 是 QuartzCore 中的一個結構體指針,用C語言編寫,用來創建像素位圖,可以通過操作存儲的像素位來編輯圖片。這兩種結構可以方便的互轉:
CGImageRef imageRef = image.CGImage;

// 2. 調用 UIImage 的 +colorSpaceForImageRef: 方法來獲取原始圖片的顏色空間參數。
CGColorSpaceRef colorspaceRef = [UIImage colorSpaceForImageRef:imageRef];
        
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);

// 3. 計算圖片解碼后每行需要的比特數,由兩個參數相乘得到:每行的像素數 width,和存儲一個像素需要的比特數4(這里的4,其實是由每張圖片的像素格式和像素組合來決定的)
size_t bytesPerRow = 4 * width;

// 4. 最關鍵的函數:調用 CGBitmapContextCreate() 方法,生成一個空白的圖片繪制上下文,我們傳入了上述的一些參數,指定了圖片的大小、顏色空間、像素排列等等屬性。
CGContextRef context = CGBitmapContextCreate(NULL,
                                             width,
                                             height,
                                             kBitsPerComponent,
                                             bytesPerRow,
                                             colorspaceRef,
                                             kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast);
if (context == NULL) {
    return image;
}
        
// 5. 調用 CGContextDrawImage() 方法,將未解碼的 imageRef 指針內容,寫入到我們創建的上下文中,這個步驟,完成了隱式的解碼工作。
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);

// 6. 從 context 上下文中創建一個新的 imageRef,這是解碼后的圖片了。
CGImageRef newImageRef = CGBitmapContextCreateImage(context);

// 7. 從 imageRef 生成供UI層使用的 UIImage 對象,同時指定圖片的 scale 和 orientation 兩個參數。
UIImage *newImage = [UIImage imageWithCGImage:newImageRef
                                        scale:image.scale
                                  orientation:image.imageOrientation];

CGContextRelease(context);
CGImageRelease(newImageRef);

return newImage;

通過以上的步驟,我們成功在子線程中對圖片進行了強制轉碼,回調給主線程使用,從而大大提高了圖片的渲染效率。這也是現在主流 App 和大量三方庫的最佳實踐。

SDWebImage配置優化,減小CG-raster-data內存占用

在使用SDWebImage的時候,會默認保存圖片解碼后的內存,以便提高頁面的渲染速度,但是這會導致內存的急速增加,所以可以在不影響體驗的情況下,選擇機型和系統,進行優化,避免大量的內存占用,引起OOM問題。關閉解碼內存緩存的方法如下:

[[SDImageCache sharedImageCache] setShouldDecompressImages:NO];
[[SDWebImageDownloader sharedDownloader] setShouldDecompressImages:NO];

附錄

WWDC2018 圖像和圖形的最佳實踐
WWDC2018 深入iOS內存
移動端圖片格式調研
談談 iOS 中圖片的解壓縮
iOS開發:圖片格式與性能優化

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Swift1> Swift和OC的區別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴謹 對...
    cosWriter閱讀 11,135評論 1 32
  • 誰吃掉我們的CPU: 方法CA::Render::create_image_from_provider 圖片預解碼...
    神采飛揚_2015閱讀 3,308評論 2 6
  • 要講圖片格式還先得從圖像的基本數據結構說起。在計算機中, 圖像是由一個個像素點組成,像素點就是顏色點,而顏色最簡單...
    404ErrorCrash閱讀 3,729評論 0 3
  • 卷首語 歡迎來到 objc.io 的第三期! 這一期都是關于視圖層的。當然視圖層有很多方面,我們需要把它們縮小到幾...
    評評分分閱讀 1,796評論 0 18
  • 對于大多數 iOS 應用來說,圖片往往是最占用手機內存的資源之一,同時也是不可或缺的組成部分。將一張圖片從磁盤中加...
    nongjiazhen閱讀 1,415評論 0 5