圖像渲染優化技巧

做者:Mattt,原文連接,原文日期:2019-05-06 譯者:ericchuhong;校對:numbbbbbWAMaker;定稿:Pancfphp

長期以來,iOS 開發人員一直被一個奇怪的問題困擾着:html

「如何對一張圖像進行渲染優化?」git

這個使人困擾的問題,是因爲開發者和平臺的相互不信任引發的。各類各樣的代碼示例充斥着 Stack Overflow,每一個人都聲稱只有本身的方法是真正的解決方案 —— 而別人的是錯的。github

在本週的文章中,咱們將介紹 5 種不一樣的 iOS 圖像渲染優化技巧(在 MacOS 上時適當地將 UIImage 轉換成 NSImage)。相比於對每一種狀況都規定一種方法,咱們將從人類工程學和性能表現方面進行衡量,以便你更好地理解什麼時該用哪種,不應用哪一些。編程

你能夠本身下載、構建和運行 示例項目代碼 來試驗這些圖像渲染優化技巧。swift


圖像渲染優化的時機和理由

在開始以前,讓咱們先討論一下爲何須要對圖像進行渲染優化。畢竟,UIImageView 會自動根據 contentmode 屬性 規定的行爲縮放和裁剪圖像。在絕大多數狀況下,.scaleAspectFit.scaleAspectFill.scaleToFill 已經徹底知足你的所需。vim

imageView.contentMode = .scaleAspectFit
imageView.image = image
複製代碼

那麼,何時對圖像進行渲染優化纔有意義呢?
當它明顯大於 UIImageView 顯示尺寸的時候緩存


看看來自 NASA 視覺地球相冊集錦 的這張 使人讚歎的圖片網絡

image-resizing-earth

想要完整渲染這張寬高爲 12,000 px 的圖片,須要高達 20 MB 的空間。對於當今的硬件來講,你可能不會在乎這麼少兆字節的佔用。但那只是它壓縮後的尺寸。要展現它,UIImageView 首先須要把 JPEG 數據解碼成位圖(bitmap),若是要在一個 UIImageView 上按原樣設置這張全尺寸圖片,你的應用內存佔用將會激增到幾百兆,對用戶明顯沒有什麼好處(畢竟,屏幕能顯示的像素有限)。但只要在設置 UIImageViewimage 屬性以前,將圖像渲染的尺寸調整成 UIImageView 的大小,你用到的內存就會少一個數量級:閉包

內存消耗 (MB)
無下采樣 220.2
下采樣 23.7

這個技巧就是衆所周知的下采樣(downsampling),在這些狀況下,它能夠有效地優化你應用的性能表現。若是你想了解更多關於下采樣的知識或者其它圖形圖像的最佳實踐,請參照 來自 WWDC 2018 的精彩課程

而如今,不多有應用程序會嘗試一次性加載這麼大的圖像了,可是也跟我從設計師那裏拿到的圖片資源不會差多。(認真的嗎?一張顏色漸變的 PNG 圖片要 3 MB?) 考慮到這一點,讓咱們來看看有什麼不一樣的方法,可讓你用來對圖像進行優化或者下采樣。

不用說,這裏全部從 URL 加載的示例圖像都是針對本地文件。記住,在應用的主線程同步使用網絡請求圖像毫不是什麼好主意。


圖像渲染優化技巧

優化圖像渲染的方法有不少種,每種都有不一樣的功能和性能特性。咱們在本文看到的這些例子,架構層次跨度上從底層的 Core Graphics、vImage、Image I/O 到上層的 Core Image 和 UIKit 都有。

  1. 繪製到 UIGraphicsImageRenderer 上
  2. 繪製到 Core Graphics Context 上
  3. 使用 Image I/O 建立縮略圖像
  4. 使用 Core Image 進行 Lanczos 重採樣
  5. 使用 vImage 優化圖片渲染

爲了統一調用方式,如下的每種技術共用一個公共接口方法:

func resizedImage(at url: URL, for size: CGSize) -> UIImage? { <#...#> }

imageView.image = resizedImage(at: url, for: size)
複製代碼

這裏,size 的計量單位不是用 pixel,而是用 point。想要計算出你調整大小後圖像的等效尺寸,用主 UIScreenscale,等比例放大你 UIImageViewsize 大小:

let scaleFactor = UIScreen.main.scale
let scale = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
let size = imageView.bounds.size.applying(scale)
複製代碼

若是你是在異步加載一張大圖,使用一個過渡動畫讓圖像逐漸顯示到 UIImageView 上。例如:

class ViewController: UIViewController {
    @IBOutlet var imageView: UIImageView!

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        let url = Bundle.main.url(forResource: "Blue Marble West",
                                withExtension: "tiff")!

        DispatchQueue.global(qos: .userInitiated).async {
            let image = resizedImage(at: url, for: self.imageView.bounds.size)

            DispatchQueue.main.sync {
                UIView.transition(with: self.imageView,
                                duration: 1.0,
                                options: [.curveEaseOut, .transitionCrossDissolve],
                                animations: {
                                    self.imageView.image = image
                                })
            }
        }
    }
}
複製代碼

技巧 #1: 繪製到 UIGraphicsImageRenderer 上

圖像渲染優化的最上層 API 位於 UIKit 框架中。給定一個 UIImage,你能夠繪製到 UIGraphicsImageRenderer 的上下文(context)中以渲染縮小版本的圖像:

import UIKit

// 技巧 #1
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
    guard let image = UIImage(contentsOfFile: url.path) else {
        return nil
    }

    let renderer = UIGraphicsImageRenderer(size: size)
    return renderer.image { (context) in
        image.draw(in: CGRect(origin: .zero, size: size))
    }
}
複製代碼

UIGraphicsImageRenderer 是一項相對較新的技術,在 iOS 10 中被引入,用以取代舊版本的 UIGraphicsBeginImageContextWithOptions / UIGraphicsEndImageContext API。你經過指定以 point 計量的 size 建立了一個 UIGraphicsImageRendererimage 方法帶有一個閉包參數,返回的是一個通過閉包處理後的位圖。最終,原始圖像便會在縮小到指定的範圍內繪製。

在不改變圖像原始縱橫比(aspect ratio)的狀況下,縮小圖像原始的尺寸來顯示一般頗有用。AVMakeRect(aspectRatio:insideRect:) 是在 AVFoundation 框架中很方便的一個函數,負責幫你作以下的計算:

import func AVFoundation.AVMakeRect
let rect = AVMakeRect(aspectRatio: image.size, insideRect: imageView.bounds)
複製代碼

技巧 #2:繪製到 Core Graphics Context 中

Core Graphics / Quartz 2D 提供了一系列底層 API 讓咱們能夠進行更多高級的配置。

給定一個 CGImage 做爲暫時的位圖上下文,使用 draw(_:in:) 方法來繪製縮放後的圖像:

import UIKit
import CoreGraphics

// 技巧 #2
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
    guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
        let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
    else {
        return nil
    }

    let context = CGContext(data: nil,
                            width: Int(size.width),
                            height: Int(size.height),
                            bitsPerComponent: image.bitsPerComponent,
                            bytesPerRow: image.bytesPerRow,
                            space: image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!,
                            bitmapInfo: image.bitmapInfo.rawValue)
    context?.interpolationQuality = .high
    context?.draw(image, in: CGRect(origin: .zero, size: size))

    guard let scaledImage = context?.makeImage() else { return nil }

    return UIImage(cgImage: scaledImage)
}
複製代碼

這個 CGContext 初始化方法接收了幾個參數來構造一個上下文,包括了必要的寬高參數,還有在給出的色域範圍內每一個顏色通道所須要的內存大小。在這個例子中,這些參數都是經過 CGImage 這個對象獲取的。下一步,設置 interpolationQuality 屬性爲 .high 指示上下文在保證必定的精度上填充像素。draw(_:in:) 方法則是在給定的寬高和位置繪製圖像,可讓圖片在特定的邊距下裁剪,也能夠適用於一些像是人臉識別之類的圖像特性。最後 makeImage() 從上下文獲取信息而且渲染到一個 CGImage 值上(以後會用來構造 UIImage 對象)。

技巧 #3:使用 Image I/O 建立縮略圖像

Image I/O 是一個強大(卻鮮有人知)的圖像處理框架。拋開 Core Graphics 不說,它能夠讀寫許多不一樣圖像格式,訪問圖像的元數據,還有執行常規的圖像處理操做。這個框架經過先進的緩存機制,提供了平臺上最快的圖片編碼器和解碼器,甚至能夠增量加載圖片。

這個重要的 CGImageSourceCreateThumbnailAtIndex 提供了一個帶有許多不一樣配置選項的 API,比起在 Core Graphics 中等價的處理操做要簡潔得多:

import ImageIO

// 技巧 #3
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageIfAbsent: 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 UIImage(cgImage: image)
}
複製代碼

給定一個 CGImageSource 和一系列配置選項,CGImageSourceCreateThumbnailAtIndex(_:_:_:) 函數建立了一個圖像的縮略圖。優化尺寸大小的操做是經過 kCGImageSourceThumbnailMaxPixelSize 完成的,它根據圖像原始寬高比指定的最大尺寸來縮放圖像。經過設定 kCGImageSourceCreateThumbnailFromImageIfAbsentkCGImageSourceCreateThumbnailFromImageAlways 選項,Image I/O 能夠自動緩存優化後的結果以便後續調用。

技巧 #4:使用 Core Image 進行 Lanczos 重採樣

Core Image 內置了 Lanczos 重採樣(resampling) 功能,它是以 CILanczosScaleTransform 的同名濾鏡命名的。雖然能夠說它是在 UIKit 層級之上的 API,但無處不在的 key-value 編寫方式致使它使用起來很不方便。

即使如此,它的處理模式仍是一致的。

建立轉換濾鏡,對濾鏡進行配置,最後渲染輸出圖像,這樣的步驟和其餘任何 Core Image 的工做流沒什麼不一樣。

import UIKit
import CoreImage

let sharedContext = CIContext(options: [.useSoftwareRenderer : false])

// 技巧 #4
func resizedImage(at url: URL, scale: CGFloat, aspectRatio: CGFloat) -> UIImage? {
    guard let image = CIImage(contentsOf: url) else {
        return nil
    }

    let filter = CIFilter(name: "CILanczosScaleTransform")
    filter?.setValue(image, forKey: kCIInputImageKey)
    filter?.setValue(scale, forKey: kCIInputScaleKey)
    filter?.setValue(aspectRatio, forKey: kCIInputAspectRatioKey)

    guard let outputCIImage = filter?.outputImage,
        let outputCGImage = sharedContext.createCGImage(outputCIImage,
                                                        from: outputCIImage.extent)
    else {
        return nil
    }

    return UIImage(cgImage: outputCGImage)
}
複製代碼

這個名叫 CILanczosScaleTransform 的 Core Image 濾鏡分別接收了 inputImageinputScaleinputAspectRatio 三個參數,每個參數的意思也都不言自明。

更有趣的是,CIContext 在這裏被用來建立一個 UIImage(間接經過 CGImageRef 表示),由於 UIImage(CIImage:) 常常不能按咱們本意使用。建立 CIContext 是一個代價很昂貴的操做,因此使用上下文緩存以便重複的渲染工做。

一個 CIContext 可使用 GPU 或者 CPU(慢不少)渲染建立出來。經過指定構造方法中的 .useSoftwareRenderer 選項來選擇使用哪一個硬件。(提示:用更快的那個,你以爲呢?)

技巧 #5: 使用 vImage 優化圖片渲染

最後一個了,它是古老的 Accelerate 框架 —— 更具體點來講,它是 vImage 的圖像處理子框架。

vImage 附帶有 一些不一樣的功能,能夠用來裁剪圖像緩衝區大小。這些底層 API 保證了高性能同時低能耗,但會致使你對緩衝區的管理操做增長(更不用說要編寫更多的代碼了):

import UIKit
import Accelerate.vImage

// 技巧 #5
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
    // 解碼源圖像
    guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
        let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil),
        let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
        let imageWidth = properties[kCGImagePropertyPixelWidth] as? vImagePixelCount,
        let imageHeight = properties[kCGImagePropertyPixelHeight] as? vImagePixelCount
    else {
        return nil
    }

    // 定義圖像格式
    var format = vImage_CGImageFormat(bitsPerComponent: 8,
                                      bitsPerPixel: 32,
                                      colorSpace: nil,
                                      bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.first.rawValue),
                                      version: 0,
                                      decode: nil,
                                      renderingIntent: .defaultIntent)

    var error: vImage_Error

    // 建立並初始化源緩衝區
    var sourceBuffer = vImage_Buffer()
    defer { sourceBuffer.data.deallocate() }
    error = vImageBuffer_InitWithCGImage(&sourceBuffer,
                                         &format,
                                         nil,
                                         image,
                                         vImage_Flags(kvImageNoFlags))
    guard error == kvImageNoError else { return nil }

    // 建立並初始化目標緩衝區
    var destinationBuffer = vImage_Buffer()
    error = vImageBuffer_Init(&destinationBuffer,
                              vImagePixelCount(size.height),
                              vImagePixelCount(size.width),
                              format.bitsPerPixel,
                              vImage_Flags(kvImageNoFlags))
    guard error == kvImageNoError else { return nil }

    // 優化縮放圖像
    error = vImageScale_ARGB8888(&sourceBuffer,
                                 &destinationBuffer,
                                 nil,
                                 vImage_Flags(kvImageHighQualityResampling))
    guard error == kvImageNoError else { return nil }

    // 從目標緩衝區建立一個 CGImage 對象
    guard let resizedImage =
        vImageCreateCGImageFromBuffer(&destinationBuffer,
                                      &format,
                                      nil,
                                      nil,
                                      vImage_Flags(kvImageNoAllocate),
                                      &error)?.takeRetainedValue(),
        error == kvImageNoError
    else {
        return nil
    }

    return UIImage(cgImage: resizedImage)
}
複製代碼

這裏使用 Accelerate API 進行的明確操做,比起目前爲止討論到的其餘優化方法更加底層。但暫時無論這些不友好的類型申明和函數名稱的話,你會發現這個方法至關直接了當。

  • 首先,根據你傳入的圖像建立一個輸入的源緩衝區,
  • 接着,建立一個輸出的目標緩衝區來接受優化後的圖像,
  • 而後,在源緩衝區裁剪圖像數據,而後傳給目標緩衝區,
  • 最後,從目標緩衝區中根據處理完後的圖像建立 UIImage 對象。

性能對比

那麼這些不一樣的方法是如何相互對比的呢?

這個項目 是一些 性能對比 結果,運行環境是 iPhone 7 iOS 12.2。

image-resizing-app-screenshot

下面的這些數字是屢次迭代加載、優化、渲染以前那張 超大地球圖片 的平均時間:

耗時 (seconds)
技巧 #1: UIKit 0.1420
技巧 #2: Core Graphics 1 0.1722
技巧 #3: Image I/O 0.1616
技巧 #4: Core Image 2 2.4983
技巧 #5: vImage 2.3126

1   設置不一樣的 CGInterpolationQuality 值出來的結果是一致的,在性能上的差別能夠忽略不計。

2   若在 CIContext 建立時設置 kCIContextUseSoftwareRenderer 的值爲 true,會致使耗時相比基礎結果慢一個數量級。

總結

  • UIKit, Core Graphics, 和 Image I/O 都能很好地用於大部分圖片的優化操做。若是(在 iOS 平臺,至少)要選擇一個的話,UIGraphicsImageRenderer 是你最佳的選擇。
  • Core Image 在圖像優化渲染操做方面性能表現優越。實際上,根據 Apple 官方 Core Image 編程規範中的性能最佳實踐單元,你應該使用 Core Graphics 或 Image I/O 對圖像進行裁剪和下采樣,而不是用 Core Image。
  • 除非你已經在使用 vImage,不然在大多數狀況下用到底層的 Accelerate API 所需的額外工做多是不合理的。

本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 swift.gg

相關文章
相關標籤/搜索