圖像優化

做者:Jordan Morgan,原文連接,原文日期:2018-12-11 譯者:Nemocdz;校對:numbbbbbWAMaker;定稿:Pancfgit


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

在度假?不偷偷拍幾張記錄在你的 Instagram 故事裏?不存在的。objective-c

出現爆炸新聞?查看 Twitter,就能夠知道是哪些媒體正在報道,經過他們揭露事件的實時照片。shell

等等……編程

正由於圖像在平臺上無處不在,若是管理不當,很容易出現性能和內存問題。稍微瞭解下 UIKit,搞清楚它處理圖像的機制,能夠節省大量時間,避免作無用功。swift

理論知識

快問快答 - 這是一張我漂亮(且時髦)女兒的照片,大小爲 266KB,在一個 iOS 應用中展現它須要多少內存?網絡

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

爲啥呢?app

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

相關文章
相關標籤/搜索