将 JPEG UIImage 的 RAM 使用量减少 50%

2025-06-10

将 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,
    ...
]
Enter fullscreen mode Exit fullscreen mode

在这个表示中,一个像素恰好需要 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)
}
Enter fullscreen mode Exit fullscreen mode

再说一遍:

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)
}
Enter fullscreen mode Exit fullscreen mode

在 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

现在我们终于可以在屏幕上显示 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)
Enter fullscreen mode Exit fullscreen mode

更有效的替代方案

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)
Enter fullscreen mode Exit fullscreen mode

如果您在模拟器中运行此代码,则不会看到任何图像。它仅适用于实际的 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
PREV
在您的 iOS 项目中使用 Bazel
NEXT
CSS 伪元素的工作原理,对初学者来说非常简单的解释