內存佔用過高怎麼辦?iOS圖片內存優化指南

導語 | 一般來說,在 App 的內存佔用中,圖片很容易成爲其中的大頭。特別是在圖片相關的 App 中,稍不注意就容易引發內存佔用過高的問題。本文將就 iOS 圖片類應用的內存優化展開討論,希望與大家一同交流。文章作者:張恆銘,騰訊終端開發工程師。

一、內存優化的必要性

事實上,因爲目前 iPhone 配備的內存越來越高,當內存佔用過高時,並不一定會超過系統設定的閾值而引發強殺進程。

但這並不意味着減少內存佔用是沒有意義的,因爲當內存佔用過高時,很容易引起一系列的副作用。最直接的表現是 App Crash,當然還有很多更爲深遠的副作用。

1. FOOM

FOOM 是最直接的影響了,當內存佔用過多導致整個系統的可用內存不足時,App所在的進程容易被殺掉。而且相比於一般的 Crash 來說,FOOM 更難以檢測,並且也更難排查。

2. 限制併發數量

如果一個任務佔用了過多的內存,但總的內存是有限的,那麼任務的併發數將會受到直接限制。表現上就是 App 裏某個功能可同時執行的數量有限,或者可以同時顯示的內容有數量限制。

同時,因爲內存是有限資源,當佔用內存過多時,會容易導致操作系統殺掉其它 App 的進程來給當前的 App 提供足夠的內存空間,這對用戶體驗是不利的。

3. 增加耗電

由於 iOS 系統的 Memory Compressor 的存在,當可用內存不足時,一部分 Dirty Page 會被壓縮存儲到磁盤中,當用到這部分內存時,再從磁盤裏加載回來。這會造成 CPU 花費更多的時間來等待 IO, 間接提高 CPU 佔用率,造成耗電。

二、原因分析

1. 圖片顯示原理

圖片其實是由很多個像素點組成的,每個像素點描述了該點的顏色信息。這樣的數據是可以被直接渲染在屏幕上的,稱之爲 Image Buffer。

事實上,由於圖片源文件佔用的存儲空間非常大,一般在存儲時候都會進行壓縮,非常常見的就是 JPEG 和 PNG 算法壓縮的圖片。

因此當圖片存儲在硬盤中的時候,它是經過壓縮後的數據。經過解碼後的數據才能用於渲染,因此需要將圖片顯示在屏幕上的話,需要先經過解碼。解碼後的數據就是 Image Buffer 。

當圖片顯示在屏幕上時,會複製顯示區域的Image Buffer去進行渲染。

2. 圖片真實佔用內存

對於一張正在顯示在屏幕上的,尺寸爲 1920*1080 的圖片來說,如果採用 SRGB 的格式(每個像素點的顏色由 red,green,blue,alpha 一個共 4 個 bytes 來決定)的話,那麼它佔用的內存爲:

1920 * 1080 * 4 = 829440 bytes

也就是說,一張非常普通的圖片,解碼後佔用的內存就是 7.9 MB,這是非常誇張的。而圖片顯示時所佔的內存大小是與尺寸和顏色空間正相關的,與壓縮算法、圖片格式、圖片文件的大小沒有關聯。

三、解決方式

1. 避免將圖片放在內存裏

對於不顯示在屏幕上的圖片,在絕大部分時間裏,其實是沒有必要放在內存裏的。解碼後的 UIImage 是非常大的,對於不需要顯示的圖片是不需要解碼的。而對於不顯示在屏幕上的圖片,一般也沒有必要繼續持有着 UIImage 對象。

2. 圖片縮放

圖片縮放是很常見的處理方式,一般來說,常見的思想可能是重新畫一張小一點的圖片,往往是用 UIGraphicsBeginImageContextWithOptions的方式:

extension UIImage {
        public func scaling(to size:CGSize) -> UIImage? {
            let drawScale = self.scale            UIGraphicsBeginImageContextWithOptions(size, false, drawScale)
            let drawRect:CGRect = CGRect(origin:.zero,size:size)
            draw(in: drawRect)
            let result = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            return result        }
    }

這種方式存在以下問題:

第一,默認是 SRGB 的格式,也就是說每個像素需要佔4個bytes的空間,對於一些黑白或者僅有alpha通道的數據來說是沒有必要的。

第二,需要將原圖片完全解碼後渲染出來,原圖片的解碼會造成內存佔用的高峯。

對於問題一的解決,可以使用新的 UIGraphicsImageRenderer 的方式,這種情況下框架會自動幫你選擇對應的顏色格式,減少不必要的消耗。

extension UIImage {
    func scaling(to size:CGSize) -> UIImage? {
        let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: size))
        return renderer.image { context in
            self.draw(in: context.format.bounds)
        }
    }
}

這種方式在一定的場景有所優化,但是沒有解決問題二中存在的內存峯值的問題。由於處理前的圖片並不一定展示在屏幕上,解碼後的數據是冗餘信息,因此應該避免圖片的解碼。

對於峯值過高的問題,最直接的思想是採用流式的方式進行處理。而底層的 ImageIO 的接口就採用了這種方式:

func resizedCgImage(url:URL,for size: CGSize) -> CGImage? {
        let options: [CFString: Any] = [
            kCGImageSourceShouldCache:false,
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height)
        ]
        
        guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
            let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
            else {                
                return nil
        }
        
        return image    }

3. 降低峯值

通過 ARC 管理內存的對象,註冊在某個 Autoreleasepool 中,Autoreleasepool 在 drain 的時候釋放已經沒有使用的對象。

一般沒有進行特殊處理的話,會在 Runloop 結束後,有一次 Autoreleasepool 的 drain 操作,而這次 Runloop 中生成的對象也是由這個 Autoreleasepool 來管理的。這部分的原理有很多的文章介紹,這裏就不多贅述了。

在圖片批量處理的過程中,由於還在一個 Runloop 裏,此時引用計數爲 0 的對象是不會被釋放的。因此需要在每次循環後觸發 Autoreleasepool 的 drain 操作:

for image in images {
    autoreleasepool {
   operation()
    }
}

4. 裁剪顯示的圖片

在很多場景下,圖片是不會完整的顯示出來的,例如下圖所示的情況:

在這種情況中,即使給 UIImageView 一張完整的圖片,最後渲染的時候也只會截取顯示區域的 Image Buffer 去進行渲染。

這就意味着,區域外的數據,其實是沒有必要的。因此在這種場景下,其實只需要裁減顯示區域的圖片即可。

舉個例子,以前面提到 1920 * 1080 的圖片爲例, 顯示時需要佔用的內存爲 829440 bytes。如果它是以 ScaleAspectFill 的方式放置在一個 300 x 300 的 UIImageView 中時,那麼其實一張 300 x 300 的圖片就足以展示,而此時這張圖片佔用的內存爲 360000 bytes, 僅爲前者的 43% 。

func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
        let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
        let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
        let downsampleOptions =
            [kCGImageSourceCreateThumbnailFromImageAlways: true,
             kCGImageSourceShouldCacheImmediately: true,
             kCGImageSourceCreateThumbnailWithTransform: true,
             kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
        let downsampledImage =
            CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
        return UIImage(cgImage: downsampledImage)
    }

四、效果對比

在App進行優化前,是先將圖片的原圖顯示出來,並且持有這些圖片直到處理完畢。

在處理方式上,採用了 UIGraphicsBeginImageContextWithOptions 的方式來進行圖片的縮放。因此造成了持續的高內存佔用,峯值可以達到 600 MB 。

經過上述優化後,已經有了比較大的改觀。同樣的操作,總的內存佔用爲 221 MB,僅爲之前的 36.4% 。

參考資料

[1] iOS Memory Deep Dive:

https://developer.apple.com/videos/play/wwdc2018/416

[2] Image and Graphics Best Practices:

https://developer.apple.com/videos/play/wwdc2018/219

本文轉載自公衆號雲加社區(ID:QcloudCommunity)。

原文鏈接

內存佔用過高怎麼辦?iOS圖片內存優化指南

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章