将 JPEG UIImage 的 RAM 使用量减少 50%
2013年,苹果从过去那种注重细节、纹理丰富的设计风格,转向了一种更简约的风格,充满了基本形状和矢量图标。但即使在这个充斥着直线和纯渐变的时代,光栅图形仍然没有过时。在iOS上尤其如此,因为iOS对快速矢量形状渲染的支持有限。绘制复杂用户界面最有效的方法是将静态部分转换为简单的图像,而这正是iOS合成器的优势所在。除了UI元素之外,光栅图形在非UI元素(例如照片)中也发挥着不可或缺的作用,因为这些元素无法仅使用矢量图形来充分呈现。
渲染光栅图像会以不为人知的方式显著影响设备的内存。这是因为图像占用的内存取决于其像素数,而不是文件大小。在本文中,我们将利用一种不太为人所知的技术在 Apple 平台上渲染此类图像。
RGB 与 YUV
在 iOS 中,我们通常将光栅图像表示为每个像素的 ARGB 值数组(其中 A 代表 alpha):
let pixels: [UInt8] = [
// a r g b
0xff, 0xff, 0x00, 0x00,
...
]
在这个表示中,一个像素恰好需要 4 个字节的存储空间。举例来说,一台分辨率为 2796 x 1290 的 iPhone 14 Pro Max,需要分配 2796 * 1290 * 4 = 14427360 字节(或 14.4 MB)的 RAM 才能用像素填满整个屏幕。相比之下,一张相同尺寸的普通 JPEG 照片大约占用 1 MB 的磁盘空间。
JPEG 使用了许多技巧来减小图像数据的大小,其中最先采用的技巧之一就是巧妙地重新组织颜色,即所谓的 YUV。RGB 数据可以无损转换为 YUV 表示形式:
func rgbToYuv(r: Double, g: Double, b: Double) -> (y: Double, u: Double, v: Double) {
let luminance = (0.299 * r) + (0.587 * g) + (0.114 * b)
let u = (1.0 / 1.772) * (b - luminance)
let v = (1.0 / 1.402) * (r - luminance)
return (luminance, u, v)
}
再说一遍:
func yuvToRgb(y: Double, u: Double, v: Double) -> (r: Double, g: Double, b: Double) {
let r = 1.402 * v + y
let g = (y - (0.299 * 1.402 / 0.587) * v - (0.114 * 1.772 / 0.587) * u)
let b = 1.772 * u + y
return (r, g, b)
}
在 YUV 表示中,第一个分量(Y)表示像素的亮度,而其他两个分量则编码颜色。
然而,简单地将 RGB 数据转换为 YUV 格式并不会节省任何数据存储。为了真正压缩数据,我们需要丢弃一些信息,这使得颜色转换有损。
色度子采样
人眼对像素亮度变化非常敏感,但对颜色的感知则较弱。事实证明,我们可以以一半的分辨率存储颜色数据,而不会损失太多视觉清晰度,尤其是在高分辨率和自然的摄影图像中。这被称为 4:2:0 子采样。
上图展示了色度子采样在视觉上显而易见的极端场景——两个颜色鲜艳、对比鲜明的物体重叠。在自然图像中,这种效果并不那么明显。
4:2:0 采样图像所需的存储空间减少了 50%,因为每四个 YUV 像素共享相同的颜色值:
4 RGB pixels = 4 * (4 * 3) = 12 bytes
4 YUV pixels = 4 * 1 + 1 + 1 = 6 bytes
UIImage 和 YUV
遗憾的是,iOS 不提供任何 API 允许我们将未压缩的 YUV 4:2:0 数据存储在 中UIImage
。不过,有几种方法可以解决这个问题。
首先,可以使用AVSampleBufferDisplayLayer
来显示静态图像内容。AVSampleBufferDisplayLayer
可以显示 ARGB 内容,但为了节省 RAM 空间,我们将使用将图像转换为 YUV 格式vImage
:
func imageToYUVCVPixelBuffer(image: UIImage) -> CVPixelBuffer? {
// 1
guard let image = image.preparingForDisplay(), let cgImage = image.cgImage, let data = cgImage.dataProvider?.data, let bytes = CFDataGetBytePtr(data), let colorSpace = cgImage.colorSpace, case .rgb = colorSpace.model, cgImage.bitsPerPixel / cgImage.bitsPerComponent == 4 else {
return nil
}
let width = cgImage.width
let height = cgImage.width
// 2
var pixelBuffer: CVPixelBuffer? = nil
let _ = CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, [
kCVPixelBufferIOSurfacePropertiesKey: NSDictionary()
] as CFDictionary, &pixelBuffer)
guard let pixelBuffer else {
return nil
}
CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
defer {
CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
}
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer)?.assumingMemoryBound(to: CVPlanarPixelBufferInfo_YCbCrBiPlanar.self) else {
return nil
}
// 3
var pixelRange = vImage_YpCbCrPixelRange(Yp_bias: 0, CbCr_bias: 128, YpRangeMax: 255, CbCrRangeMax: 255, YpMax: 255, YpMin: 0, CbCrMax: 255, CbCrMin: 0)
var info = vImage_ARGBToYpCbCr()
if vImageConvert_ARGBToYpCbCr_GenerateConversion(kvImage_ARGBToYpCbCrMatrix_ITU_R_709_2, &pixelRange, &info, kvImageARGB8888, kvImage420Yp8_Cb8_Cr8, vImage_Flags(kvImageDoNotTile)) != kvImageNoError {
return nil
}
// 4
var srcBuffer = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: bytes), height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: cgImage.bytesPerRow)
var dstBufferY = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: baseAddress).advanced(by: Int(CFSwapInt32BigToHost(UInt32(baseAddress.pointee.componentInfoY.offset)))), height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: Int(CFSwapInt32BigToHost(baseAddress.pointee.componentInfoY.rowBytes)))
var dstBufferCbCr = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: baseAddress).advanced(by: Int(CFSwapInt32BigToHost(UInt32(baseAddress.pointee.componentInfoCbCr.offset)))), height: vImagePixelCount(height / 2), width: vImagePixelCount(width / 2), rowBytes: Int(CFSwapInt32BigToHost(baseAddress.pointee.componentInfoCbCr.rowBytes)))
// 5
let permuteMap: [UInt8] = [3, 0, 1, 2]
if vImageConvert_ARGB8888To420Yp8_CbCr8(&srcBuffer, &dstBufferY, &dstBufferCbCr, &info, permuteMap, vImage_Flags(kvImageDoNotTile)) != kvImageNoError {
return nil
}
return pixelBuffer
}
1 - 确保源图像已完全加载到内存中,并采用正确的格式。2
- 创建一个 CVImagePixelBuffer 来存储该kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
格式的数据。3
- 准备 vImage 以进行 RGB 到 YUV 的转换。4
- 填充源和目标 vImage 缓冲区结构。5
- 执行实际转换。
CVPixelBuffer
要在 内显示AVSampleBufferDisplayLayer
,需要将其封装在 中CMSampleBuffer
:
public func makeCMSampleBuffer(pixelBuffer: CVPixelBuffer) -> CMSampleBuffer? {
var sampleBuffer: CMSampleBuffer?
var videoInfo: CMVideoFormatDescription? = nil
CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescriptionOut: &videoInfo)
guard let videoInfo else {
return nil
}
var timingInfo = CMSampleTimingInfo(
duration: CMTimeMake(value: 1, timescale: 30),
presentationTimeStamp: CMTimeMake(value: 0, timescale: 30),
decodeTimeStamp: CMTimeMake(value: 0, timescale: 30)
)
CMSampleBufferCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, dataReady: true, makeDataReadyCallback: nil, refcon: nil, formatDescription: videoInfo, sampleTiming: &timingInfo, sampleBufferOut: &sampleBuffer)
guard let sampleBuffer else {
return nil
}
let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true)! as NSArray
let dict = attachments[0] as! NSMutableDictionary
dict.setValue(kCFBooleanTrue as AnyObject, forKey: kCMSampleAttachmentKey_DisplayImmediately as NSString as String)
return sampleBuffer
}
现在我们终于可以在屏幕上显示 YUV 图像了:
let videoLayer = AVSampleBufferDisplayLayer()
videoLayer.frame = CGRect(origin: CGPoint(x: 10.0, y: 70.0), size: CGSize(width: 200.0, height: 200.0))
videoLayer.enqueue(makeCMSampleBuffer(pixelBuffer: imageToYUVCVPixelBuffer(image: image)!)!)
view.layer.addSublayer(videoLayer)
更有效的替代方案
AVSampleBufferDisplayLayer
是一个相对繁重的 UI 元素,它在后台进行大量的隐藏状态管理。初始化此类图层所需的时间通常难以预测,并且偶尔会导致动画延迟。虽然它AVSampleBufferDisplayLayer
擅长显示 YUV 视频内容,但其高级功能对于呈现简单图像而言并非必需。我们可以利用 UIKit 中一个鲜为人知的功能,它允许我们将一个 IOSurface 支持的图层CVPixelBuffer
直接分配给CALayer.contents
:
let directLayer = CALayer()
directLayer.frame = CGRect(origin: CGPoint(x: 10.0, y: 70.0), size: CGSize(width: 200.0, height: 200.0))
directLayer.contents = imageToYUVCVPixelBuffer(image: image)!
view.layer.addSublayer(directLayer)
如果您在模拟器中运行此代码,则不会看到任何图像。它仅适用于实际的 iOS 设备和原生 macOS 应用。
结论
该技术可以轻松扩展以管理 YUV+Alpha 图像(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
根据需要使用和调整转换例程)。
4:2:0 编码的 YUV 图像所需的 RAM 空间比等效 RGB 图像少 50%。为了便于说明,本文中的代码将现有的 RGB 图像转换为 YUV 格式,导致应用程序在转换过程中占用 RGB 等效 RAM 的 150%。为了避免出现此峰值,建议直接以 YUV 格式获取图像。这可以使用能够直接输出 YUV 数据的 libjpeg 派生库来实现。
鏂囩珷鏉ユ簮锛�https://dev.to/petertech/reducing-jpeg-uiimage-ram-usage-by-50-2jed