影像優化

SwiftGG翻譯組發表於2019-10-19

作者:Jordan Morgan,原文連結,原文日期:2018-12-11 譯者:Nemocdz;校對:numbbbbbWAMaker;定稿:Pancf


俗話說得好,最好的相機是你身邊的那個。那麼毫無疑問 - iPhone 可以說是這個星球最重要的的相機。而這在業界也已經達成共識。

在度假?不偷偷拍幾張記錄在你的 Instagram 故事裡?不存在的。

出現爆炸新聞?檢視 Twitter,就可以知道是哪些媒體正在報導,通過他們揭露事件的實時照片。

等等……

正因為影像在平臺上無處不在,如果管理不當,很容易出現效能和記憶體問題。稍微瞭解下 UIKit,搞清楚它處理影像的機制,可以節省大量時間,避免做無用功。

理論知識

快問快答 - 這是一張我漂亮(且時髦)女兒的照片,大小為 266KB,在一個 iOS 應用中展示它需要多少記憶體?

影像優化

劇透警告 - 答案不是 266KB,也不是 2.66MB,而是接近 14MB。

為啥呢?

iOS 實際上是從一幅影像的尺寸計算它佔用的記憶體 - 實際的檔案大小會比這小很多。這張照片的尺寸是 1718 畫素寬和 2048 畫素高。假設每個畫素會消耗我們 4 個位元:

1718 * 2048 * 4 / 1000000 = 14.07 MB 佔用
複製程式碼

假設你有一個使用者列表 table view,並且在每一行左邊使用常見的圓角頭像來展示他們的照片。如果你認為這些影像會像潔食(猶太人的食品,比喻事情完美無瑕)一樣,每個都被類似 ImageOptim 的工具壓縮過,那可就大錯特錯了。即使每個頭像的大小隻有 256x256,也會佔用相當一部分記憶體。

渲染流程

綜上所述 - 瞭解幕後原理是值得的。當你載入一張圖片時,會執行以下三個步驟:

1)載入 - iOS 獲取壓縮的影像並載入到 266KB 的記憶體(在我們這個例子中)。這一步沒啥問題。

2)解碼 - 這時,iOS 獲取影像並轉換成 GPU 能讀取和理解的方式。這裡會解壓圖片,像上面提到那樣佔用 14MB。

3)渲染 - 顧名思義,影像資料已經準備好以任意方式渲染。即使只是在一個 60x60pt 的 image view 中。

解碼階段是消耗最大的。在這個階段,iOS 會建立一塊緩衝區 - 具體來說是一塊影像緩衝區,也就是影像在記憶體中的表示。這解釋了為啥記憶體佔用大小和影像尺寸有關,而不是檔案大小。因此也可以理解,為什麼在處理圖片時,尺寸如此重要。

具體到 UIImage,當我們傳入從網路或者其它來源讀取的影像資料時,它會將資料解碼到緩衝區,但不會考慮資料的編碼方式(比如 PNG 或者 JPG)。然而,緩衝區實際上會儲存到 UIImage 中。由於渲染不是一瞬間的操作,UIImage 會執行一次解碼操作,然後一直保留影像緩衝區。

接著往下說 - 任何 iOS 應用中都有一整塊的幀緩衝區。它會儲存內容的渲染結果,也就是你在螢幕上看到的東西。每個 iOS 裝置負責顯示的硬體都用這裡面單個畫素資訊逐個點亮物理螢幕上合適的畫素點。

處理速度非常重要。為了達到黃油般順滑的每秒 60 幀滑動,在資訊發生變化時(比如給一個 image view 賦值一幅影像),幀緩衝區需要讓 UIKit 渲染 app 的 window 以及它裡面所有層級的子檢視。一旦延遲,就會丟幀。

覺得 1/60 秒太短不夠用?Pro Motion 裝置已經將上限拉到了 1/120 秒。

尺寸正是問題所在

我們可以很簡單地將這個過程和記憶體的消耗視覺化。我建立了一個簡單的應用,可以在一個 image view 上展示需要的影像,這裡用的是我女兒的照片:

let filePath = Bundle.main.path(forResource:"baylor", ofType: "jpg")!
let url = NSURL(fileURLWithPath: filePath)
let fileImage = UIImage(contentsOfFile: filePath)

// Image view
let imageView = UIImageView(image: fileImage)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 400).isActive = true

view.addSubview(imageView)
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
複製程式碼

實踐中請注意強制解包。這裡只是一個簡單的場景。

完成之後就會是這個樣子:

影像優化

雖然展示圖片的 image view 尺寸很小,但是用 LLDB 就可以看到影像的真正尺寸。

<UIImage: 0x600003d41a40>, {1718, 2048}
複製程式碼

需要注意的是 - 這裡的單位是。所以當我在 3x 或 2x 裝置時,可能還需要額外乘上這個數字。我們可以用 vmmap 來確認這張影像是否佔用了 14 MB:

shell
vmmap --summary baylor.memgraph
複製程式碼

一部分輸出(省略一些內容以便展示):

shell
Physical footprint:         69.5M
Physical footprint (peak):  69.7M
複製程式碼

我們看到這個數字接近 70MB,這可以作為基準來確認針對性優化的成果。如果我們用 grep 命令查詢 Image IO,或許會看到一部分影像消耗:

shell
vmmap --summary baylor.memgraph | grep "Image IO"

Image IO  13.4M   13.4M   13.4M    0K  0K  0K   0K  2
複製程式碼

啊哈 - 這裡有大約 14MB 的髒記憶體,和我們前面的估算一致。如果你不清楚每一列表示什麼,可以看下面這個截圖:

影像優化

通過這個例子可以清楚地看到,哪怕展示在 300x400 image view 中,影像也需要完整的記憶體消耗。影像尺寸很重要,但是尺寸並不是唯一的問題。

色彩空間

能確定的是,有一部分記憶體消耗來源於另一個重要因素 - 色彩空間。在上面的例子中,我們的計算基於以下假設 - 影像使用 sRGB 格式,但大部分 iPhone 不符合這種情況。sRGB 每個畫素有 4 個位元組,分別表示紅、藍、綠、透明度。

如果你用支援寬色域的裝置進行拍攝(比如 iPhone 8+ 或 iPhone X),那麼記憶體消耗將變成兩倍,反之亦然。Metal 會用僅有一個 8 位透明通道的 Alpha 8 格式。

這裡有很多可以把控和值得思考的地方。這也是為什麼你應該用 UIGraphicsImageRenderer 代替 UIGraphicsBeginImageContextWithOptions 的原因之一。後者總是會使用 sRGB,因此無法使用寬色域,也無法在不需要的時候節省空間。在 iOS 12 中,UIGraphicsImageRenderer 會為你做正確的選擇。

不要忘了,很多影像並不是真正的攝影作品,只是一些繪圖操作。如果你錯過了我最近的文章,可以再閱讀一遍下面的內容:

let circleSize = CGSize(width: 60, height: 60)

UIGraphicsBeginImageContextWithOptions(circleSize, true, 0)

// Draw a circle
let ctx = UIGraphicsGetCurrentContext()!
UIColor.red.setFill()
ctx.setFillColor(UIColor.red.cgColor)
ctx.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
ctx.drawPath(using: .fill)

let circleImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
複製程式碼

上面的圓形影像用的是每個畫素 4 個位元組的格式。如果換用 UIGraphicsImageRenderer,通過渲染器自動選擇正確的格式,讓每個畫素使用 1 個位元組,可以節省高達 75% 的記憶體:

let circleSize = CGSize(width: 60, height: 60)
let renderer = UIGraphicsImageRenderer(bounds: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))

let circleImage = renderer.image{ ctx in
    UIColor.red.setFill()
    ctx.cgContext.setFillColor(UIColor.red.cgColor)
    ctx.cgContext.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
    ctx.cgContext.drawPath(using: .fill)
}
複製程式碼

縮小圖片 vs 向下取樣

現在我們從簡單的繪圖場景回到現實世界 - 許多圖片其實並不是藝術作品,只是自拍或者風景照。

因此有些人可能會假設(並且確實相信)通過 UIImage 簡單地縮小圖片就夠了。但我們前面已經解釋過,縮小尺寸並不管用。而且根據 Apple 工程師 kyle Howarth 的說法,由於內部座標轉換的原因,縮小圖片的優化效果並不太好。

UIImage 導致效能問題的根本原因,我們在渲染流程裡已經講過,它會解壓原始影像到記憶體中。理想情況下,我們需要一個方法來減少影像緩衝區的尺寸。

慶幸的是,我們可以修改影像尺寸,來減少記憶體佔用。很多人以為影像會自動執行這類優化,但實際上並沒有。

讓我們嘗試用底層的 API 來對它進行向下取樣:

let imageSource = CGImageSourceCreateWithURL(url, nil)!
let options: [NSString:Any] = [kCGImageSourceThumbnailMaxPixelSize:400,
                               kCGImageSourceCreateThumbnailFromImageAlways:true]

if let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) {
    let imageView = UIImageView(image: UIImage(cgImage: scaledImage))
    
    imageView.translatesAutoresizingMaskIntoConstraints = false
    imageView.contentMode = .scaleAspectFit
    imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
    imageView.heightAnchor.constraint(equalToConstant: 400).isActive = true
    
    view.addSubview(imageView)
    imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
}
複製程式碼

通過這種取巧的展示方法,會獲得和以前完全相同的結果。不過在這裡,我們使用了 CGImageSourceCreateThumbnailAtIndex(),而不是直接將原始圖片放進 image view。再次使用 vmmap 來確認優化是否有回報(同樣,省略部分內容以便展示):

shell
vmmap -summary baylorOptimized.memgraph

Physical footprint:         56.3M
Physical footprint (peak):  56.7M
複製程式碼

效果很明顯。之前是 69.5M,現在是 56.3M,節省了 13.2M。這個節省相當大,幾乎和圖片本身一樣大。

更進一步,你可以在自己的案例中嘗試更多可能的選項來進行優化。在 WWDC 18 的 Session 219,“Images and Graphics Best Practices“中,蘋果工程師 Kyle Sluder 展示了一種有趣的方式,通過 kCGImageSourceShouldCacheImmediately 標誌位來控制解碼時機,:

func downsampleImage(at URL:NSURL, maxSize:Float) -> UIImage
{
    let sourceOptions = [kCGImageSourceShouldCache:false] as CFDictionary
    let source = CGImageSourceCreateWithURL(URL as CFURL, sourceOptions)!
    let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways:true,
                             kCGImageSourceThumbnailMaxPixelSize:maxSize
                             kCGImageSourceShouldCacheImmediately:true,
                             kCGImageSourceCreateThumbnailWithTransform:true,
                             ] as CFDictionary
    
    let downsampledImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions)!
    
    return UIImage(cgImage: downsampledImage)
}
複製程式碼

這裡 Core Graphics 不會開始圖片解碼,直到你請求縮圖。另外要注意的是,兩個例子都傳入了 kCGImageSourceCreateThumbnailMaxPixelSize,如果不這樣做,就會獲得和原圖同樣尺寸的縮圖。根據文件所示:

“...如果沒指定最大尺寸,返回的縮圖將會是完整影像的尺寸,這可能並不是你想要的。”

所以上面發生了什麼?簡而言之,我們將縮放的結果放入縮圖中,從而建立的是比之前小很多的影像解碼緩衝區。回顧之前提到的渲染流程,在第一個環節(載入)中,我們給 UIImage 傳入的緩衝區是需要繪製的圖片尺寸,不是圖片的真實尺寸。

如何用一句話總結本文?想辦法對影像進行向下取樣,而不是使用 UIImage 去縮小尺寸。

附贈內容

除了向下取樣,我自己還經常使用 iOS 11 引入的 預載入 API。請記住,我們是在解碼影像,哪怕是放在 Cell 展示之前執行,也會消耗大量 CPU 資源。

如果應用持續耗電,iOS 可以優化電量消耗。但是我們做的向下取樣一般不會持續執行,所以最好在一個佇列中執行取樣操作。與此同時,你的解碼過程也實現了後臺執行,一石多鳥。

做好準備,下面即將為您呈現的是——我自己業餘專案裡的 Objective-C 程式碼示例:

objective-c
// 不要用全域性非同步佇列,使用你自己的佇列,從而避免潛在的執行緒爆炸問題
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths
{
    if (self.downsampledImage != nil || 
        self.listItem.mediaAssetData == nil) return;
    
    NSIndexPath *mediaIndexPath = [NSIndexPath indexPathForRow:0
                                                     inSection:SECTION_MEDIA];
    if ([indexPaths containsObject:mediaIndexPath])
    {
        CGFloat scale = tableView.traitCollection.displayScale;
        CGFloat maxPixelSize = (tableView.width - SSSpacingJumboMargin) * scale;
        
        dispatch_async(self.downsampleQueue, ^{
            // Downsample
            self.downsampledImage = [UIImage downsampledImageFromData:self.listItem.mediaAssetData
                               scale:scale
                        maxPixelSize:maxPixelSize];
            
            dispatch_async(dispatch_get_main_queue(), ^ {
                self.listItem.downsampledMediaImage = self.downsampledImage;
            });
        });
    }
}
複製程式碼

建議使用 asset catalog 來管理原始影像資源,它已經實現了緩衝區優化(以及更多功能)。

想成為記憶體和影像處理專家?不要錯過 WWDC 18 這些資訊量巨大的 session:

總結

學無止境。如果選擇了程式設計,你就必須每小時跑一萬英里才能跟得上這個領域創新和變化的步伐……換句話說,一定會有很多你根本不知道的 API、框架、模式或者優化技巧。

在影像領域也是如此。大多數時候,你初始化一個了大小合適的 UIImageView 就不管了。我當然知道摩爾定律。現在手機確實很快,記憶體也很大,但是你要知道 - 將人類送上月球的計算機只有不到 100KB 記憶體。

長期和魔鬼共舞(譯者注:比喻不管記憶體問題),它總有露出獠牙的那天。等到一張自拍就佔掉 1G 記憶體的時候,後悔也來不及了。希望上述的知識和技術能幫你節省一些 debug 時間。

下次再見 ✌️。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

相關文章