Categories
程式開發

內存佔用過高怎麼辦? 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 。

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

當圖片顯示在屏幕上時,會復制顯示區域的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. 裁剪顯示的圖片

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

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

在這種情況中,即使給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 。

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

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

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

參考資料

[1] iOS記憶體深入研究:

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

[2] 圖像和圖形最佳實踐:

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

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

原文鏈接

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