UIImage

本文章主要是笔者对于UIImage相关知识的整理、以及查阅资料过程中对有疑惑地方进行的验证;测试设备为iPhone 6s Plus, 测试系统:iOS 11.2

一、基本概念

(一)bitmap位图
bitmap位图由两部分构成:文件头与图像数据块
1.文件头存储了图片的一些基本信息,诸如像素宽、像素高、编码格式、色深、颜色空间、调色板、Exif信息、设备信息等;
2.图像数据块即图像中各个像素的颜色数据;

(二)像素格式
一般常见的像素格式有RGB1、RGB2、RGB4、RGB8、RGB16、RGB24、RGB32;其中像素格式RGB1、RGB2、RGB4、RGB8一般用于索引图像;
像素格式为RGB16、RGB24、RGB32一般用于RGB彩色图像,
RGB16的像素格式有R5G5B5、R5G6B5、R4G4B4,
RGB24即为R8G8B8,
RGB32就是在RGB24的基础上多了个alpha通道;


Snipaste_2022-10-11_19-18-33.png

这些数字1、2、4、8、16、24、32代表的是1像素所占的位数。索引图像是一种把像素值直接作为RGB调色板下标的图像,索引图像可把像素值“直接映射”为调色板数值,索引图像的文件头中会含有调色板信息;以RGB8的索引图、RGB32的彩色图为例,下图中color对应调色板中的一个颜色,r、g、b三个颜色分量可组成一个颜色,color、r、g、b、a都占8位,即取值区间为[0,255];故RGB8的索引图仅能展示256个颜色,而32位的彩色图可展示1670万个颜色并附带透明度变化;同时在相同尺寸的图片中索引图的点阵数据要比彩色图小得多;
Snipaste_2022-10-07_17-44-27.png
在iOS系统中支持的颜色通道仅有Gray与RGB。iOS系统支持的像素格式有RGB16、RGB32,不支持RGB24;同时CGImageAlphaInfo透明信息仅支持预乘的值;
下图为iOS系统中位图上下文所支持的像素格式
Snipaste_2022-10-03_15-47-30.png
截图中的简写
CS:ColorSpace的缩写,颜色通道;CS为NULL时代表黑白
bpp:bitsPerPiexl的缩写,每个像素占用的位数
bpc:bitsPerComponent的缩写,每个颜色组成部分占用的位数

(三)颜色空间ColorSpace

这时仅讨论RGB通道下的颜色空间,iPhone 7之前的是sRGB,iPhone 7以及之后的所有机型都是DCI-P3,,这两颜色空间的差异就是色域的不同,DCI-P3的色域比sRGB广,下图是sRGB与DCI-P3色域的对比
Snipaste_2022-10-11_16-24-07.png

注意的是iPhone 7之后的机型中CGColorSpaceCreateDeviceRGB()所生成的颜色空间仍是sRGB,我们手机拍照的颜色空间为P3;若P3图片上的有超sRGB空间的颜色,会出现什么情况呢?在7以下机型上这些超sRGB色域的颜色会丢失,7以上的机型可正常展示;

(四)预乘
预乘就是在图片解码生成位图时,把像素中的每个颜色分量都乘以他的Alpha值,如RGBA,预乘后为(r * a, g * a, b * a, a),以此避免了渲染时的额外乘法运算。

二、图片的解码

一般的图像为JPEG/PNG格式,它们都是经过编码压缩后的数据;而屏幕显示这些图片时会先通过CPU把这些经过特殊编码的图片,解码转成GPU所能识别的特定像素格式的bitmap位图数据后,GPU才能进行渲染。而我们在创建图片时,图片数据并不会立刻解码,未解码的图片在从 UIImage 加载到 UIImageView 上时, 会触发隐式解码, CPU 需要将未解码的 UIImage 转化为位图, 然后再转交给 GPU 渲染并展示在屏幕上。解码这个过程会大量消耗 CPU 且发生在主线程。如果想要绕开这个机制,自己在后台线程解码图片,常见做法有如下几种:

  1. CGBitmapContext
    先创建CGBitmapContext ,并通过CGContextDrawImage方法把图片绘制到画布上,然后从 Bitmap 直接创建图片。CGContextDrawImage里会触发系统的解码操作,目前网络图片库就是使用这个来解码。
CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(
  void * __nullable data,
  size_t width, size_t height, 
  size_t bitsPerComponent, 
  size_t bytesPerRow,
  CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo
)CG_AVAILABLE_STARTING(10.0, 2.0);

该方法参数比较多:
(1)width、height是图片像素的宽、高;
(2)data:位图存储空间,保存位图解码后的颜色数据。可以设置为NULL,由系统自动分配内存这时bytesPerRow也可以设为0;如果不为空,那么bytesPerRow也就不能为0,需要填写相应的数值(width*bpp/8)。
(3)bitsPerComponent、btyesPerRow、颜色空间要与系统支持的像素格式相匹配;这里填写的参数系统不支持时,大多数导致生成的context不存在;也会有一些异常情况,比如生成的图片“花了”、最后生成的是张黑色背景图等错误图片;
(4)bitmapInfo: 由CGBitmapInfo布局布局信息 | CGImageAlphaInfo透明信息,iOS系统都是小端模式故CGBitmapInfo根据像素位数来选择16位还是32位;CGImageAlphaInfo透明信息选择的first与last决定了最后生成的像素数据是RGBA结构,还是ARGB结构,若无需处理像素数据时first与last可随便填写。
需注意的是解码时内存占用比较大,以该图片为例,解码导致的内存峰值能达204M,96.67M(创建上下文的内存)+ 96.62M (图片解码数据的内存)+ 10.25M(图片的原始数据)

2.Graphics Context(图形上下文);
UIGraphicsBeginImageContext内部就是对CGBitmapContext的一个封装,返回的context的颜色空间就是32位RGB,颜色分量8位,颜色空间为sRGB。

3.CGImageSourceCreateImageAtIndex/CGImageSourceCreateThumbnailAtIndex
网络上很多的资料都认为CGImageSource创建的图片也是未解码的,但根据官方文档上参数(options字典中)的shouldCacheImmediately这个key的注释,其值为YES时,图片在创建时是进行了解码操作;

/* Specifies whether image decoding and caching should happen at image creation time.
 * The value of this key must be a CFBooleanRef. The default value is kCFBooleanFalse (image decoding will
 * happen at rendering time).
 */

对此使用instruments中的time profiler 与allocation进行测试分析;我们知道解码过程中的内存开销最小会占用(width * height * bpp/8)字节,因此我就通过allocation监测目标函数运行时的内存开销来确定是否有进行解码操作,以及图片渲染时通过time profiler监测代码占用CPU的耗时,监测是否有执行系统的解码来验证该解码方式是否有效。

测试代码

- (UIImage *)decodeImage:(NSData *)data {
    CGImageSourceRef sourceRef = 
    CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
    NSMutableDictionary *options = [NSMutableDictionary dictionary];
    options[(id)kCGImageSourceShouldCache] = @(YES);
    options[(id)kCGImageSourceShouldCacheImmediately] = @(YES);
    CGImageRef imageRef = 
    CGImageSourceCreateImageAtIndex(sourceRef, 0, (CFDictionaryRef)options);
    UIImage *image = [UIImage imageWithCGImage:imageRef];
    CGImageRelease(imageRef);
    CFRelease(sourceRef);
    return image;
}

可以在Instruments看到tapAction方法调用后的app内存分析;
Snipaste_2022-10-06_13-00-24.png

该图片的分辨率为4961 * 5105、bpp=32;故解码所需的内存4961 * 5105 * 32/8 = 96.611M 正好对应上图中选择部分的内存96.62;AppleJPEG库是iOS系统中图片的编解码库,上面执行的方法名判断createImageBlockSetWithHardwareDecode()就是时机执行解码的方法,其内存开销占用了96.62M。故可以说明shouldCacheImmediately为YES时CGImageSourceCreateImageAtIndex在创建图片时是有对图片进行了解码操作;


Snipaste_2022-10-11_21-16-25.png

根据渲染时占用CPU的时间可知:经CGImageSourceCreateImageAtIndex解码的图片在渲染时仍需CPU预处理;该方式解码后仍会保留图片的原数据;

CGImageSourceCreateThumbnailAtIndex也是相似的测试,先简单的修改decodeImage方法里的代码

- (UIImage *)decodeImage:(NSData *)data {
    CGImageSourceRef sourceRef = 
    CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
    NSMutableDictionary *options = [NSMutableDictionary dictionary];
    options[(id)kCGImageSourceShouldCache] = @(YES);
    options[(id)kCGImageSourceShouldCacheImmediately] = @(YES);
    options[(id)kCGImageSourceCreateThumbnailFromImageAlways] = @(YES);
    options[(id)kCGImageSourceThumbnailMaxPixelSize] = @(3000);
    CGImageRef imageRef = 
    CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (CFDictionaryRef)options);
    UIImage *image = [UIImage imageWithCGImage:imageRef];
    CGImageRelease(imageRef);
    CFRelease(sourceRef);
    return image;
}

可以看到instruments的内存分析结果
Snipaste_2022-10-06_14-36-38.png

将采样后图片的分辨率为2961 * 3000,那解码后的内存及2961 * 3000 * 32/8 = 33.382M ,也对应了选择部分的33.42M;故说明了CGImageSourceCreateThumbnailAtIndex在降采样时shouldCacheImmediately设置为NO也会解码图片;

thumb创建的图片渲染时TimePrifiler.png

根据渲染时占用CPU的时间可知:经CGImageSourceCreateThumbnailAtIndex解码的图片在渲染时无需CPU再次处理;CGImageSourceCreateThumbnailAtIndex这个方法里设置kCGImageSourceThumbnailMaxPixelSize时,会触发ImageIO的降采样操作;一个简单的逻辑:未完成解码的图片是无法进行降采样的,若降采样时把shouldCacheImmediately设置为NO这时图片还会解码吗?经测试仍会解码;该方式解码后仍会保留图片的原数据;

4.imageByPreparingForDisplay
iOS15上UIImage的新属性,可直接对图片解码;对比其它方式,解码消耗的内存最小,对CPU的消耗也最小,用了的都说好;

三、图片的压缩
以jpg图片的压缩为例,UIKit的UIImageJPEGRepresentation()方法是基于Image、IO中CGImageDestinationRef的封装;根据TimeProfiler中的执行方法的耗时,猜测其内部实现是先通过IIOImagePixelDataProvider::getBytesDataProvider 读取图片的像素数据,再调用applejpeg_encode_image_row做编码相关操作;


Snipaste_2022-10-08_14-18-44.png


分别做了4次测试来验证对比:

测试1.当传入一张未解码的图片时
Snipaste_2022-10-08_10-40-34.png

测试2.当传入一张渲染后的图片时
Snipaste_2022-10-08_17-01-46.png

测试3.当传入一张经过CGImageSourceCreateThumbnailAtIndex解码后的图片时
Snipaste_2022-10-08_15-15-52.png

测试4.当传入一张经过CGImageSourceCreateImageAtIndex解码后的图片时
Snipaste_2022-10-08_11-51-17.png

4次对比实验无不说明了图片的压缩是基于解码后的位图来进行的压缩编码;在对已解码的图片进行压缩时,压缩方法也是直接进行压缩编码操作;而图片通过压缩方法解码后在需要渲染时仍需CPU再次解码,说明压缩时的解码数据系统并未缓存。
特别注意到测试3中内存增大了107M(压缩后数据10.47+ 解码产生的数据96.67),说明CGImageSourceCreateImageAtIndex解码过的图片压缩时仍会再次进行解码操作;且解码产生的数据96.67M在压缩数据data被释放后,仍在存在内存中没有被释放,这无疑表明出现了内存泄露;当再次对该图片压缩时,未进行解码操作,也没有新的内存泄露(切换前后台内存也未被释放);在iOS15上测试时发现已没有了这个内存泄露问题;

四、图片的缓存

1.CGImageSource创建图片的方法中参数options字典里有个shouldCache的key,控制是否把解码的数据缓存在CGImage里;在64位机器上默认是YES,即默认是缓存解码数据的;经测试在CGImageSourceCreateImageAtIndex中shouldCache=NO、shouldCacheImmediately=YES时,生成的CGImage仍是缓存了解码后的位图数据sShouldCache=NO、shouldCacheImmediately=NO时,生成的CGImage后系统解码的位图数据未缓存;即可以推测shouldCache这个key是用来控制系统渲染时解码的位图数据是否缓存;

2.imageWidthData:方法的底层实现是调用CGImageSourceCreateImageAtIndex方法来创建即解码后的数据会进行缓存。
imageNamed:方法创建的UIImage,也是CGImageSourceCreateImageAtIndex实现的,只是多了一个对生成的image缓存操作;据观察App在收到内存警告、切换前后台时,被缓存的image才会被清理,其他情况下image会一直存在。


imageNamed_timeProfiler.png

五、图片的像素处理

图片在解码生成位图后,若想直接对位图的像素数据进行编辑,可通过CGBitmapContextGetData()来获取像素数据bitmapData,bitmapData是一段连续的内存故可通过指针的方式对其内存数据编辑;CGBitmapInfo中的字节顺序在iOS系统默认为小端模式即读取数据时是以字节为单位倒序读取,CGImageAlphaInfo值为first时即像素格式为ARGB,故实际内存中读取的数据为BGRA顺序,同理明度信息值为last时,实际内存中数据为ABGR顺序;
下面示例方法是把图片rect区间填充为蓝色,CGImageAlphaInfo值为first代码中的tmp指针便指向B,(tmp+3)指针便指向A,这里获取与赋值的色值都是预乘后的色值;这种直接操作像素一般可以用来生成马赛克图片、获取图片中某一点的色值、去除简单的水印等;

- (UIImage *)operationPiexlData {
    CGImageRef imageRef = self.CGImage;
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    CGFloat width = CGImageGetWidth(imageRef);
    CGFloat height = CGImageGetHeight(imageRef);
    CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
    BOOL hasAlpha = NO;
    if (alphaInfo == kCGImageAlphaPremultipliedLast ||
        alphaInfo == kCGImageAlphaPremultipliedFirst ||
        alphaInfo == kCGImageAlphaLast ||
        alphaInfo == kCGImageAlphaFirst) {
        hasAlpha = YES;
    }
    CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
    if (hasAlpha) {
        bitmapInfo |= kCGImageAlphaPremultipliedFirst;
    }else{
        bitmapInfo |= kCGImageAlphaNoneSkipFirst;
    }
    CGContextRef context = CGBitmapContextCreate(nil, width, height, 8, width * 4, colorSpace, bitmapInfo);
    if (context == nil) {
        return self;
    }
    CGContextDrawImage(context, CGRectMake(0.0f, 0.0f, width, height), imageRef);
    unsigned char *bitmapData = CGBitmapContextGetData(context);
    CGPoint point = CGPointMake(0, 0);
    CGSize size = CGSizeMake(width, height * 0.1);
    for (int i = 0; i < size.height; i++) {
        for (int j = 0; j < size.width; j++) {
            NSInteger index = (point.y + i) * width + j + point.x;
            UInt8 *tmp = bitmapData + index * 4;
            *(tmp + 0) = 255;
            *(tmp + 1) = 0;
            *(tmp + 2) = 0;
            *(tmp + 3) = 255;
        }
    }
    CGDataProviderRef dataProvider = CGDataProviderCreateWithData(NULL, bitmapData, width * height * 4, NULL);
    CGImageRef mosaicImageRef = CGImageCreate(width, height, 8, 32, width*4, colorSpace, bitmapInfo, dataProvider, NULL, false, kCGRenderingIntentDefault);
    CGDataProviderRelease(dataProvider);
    CGContextRef outputContext = CGBitmapContextCreate(nil, width,  height, 8, width*4, colorSpace, bitmapInfo);
    CGContextDrawImage(outputContext, CGRectMake(0.0f, 0.0f, width, height), mosaicImageRef);
    CGImageRef resultImageRef = CGBitmapContextCreateImage(outputContext);
    UIImage *resultImage = [UIImage imageWithCGImage:resultImageRef];
    CFRelease(resultImageRef);
    CFRelease(mosaicImageRef);
    CFRelease(colorSpace);
    CFRelease(outputContext);
    CGContextRelease(context);
    return resultImage;
}

六、思考

思考1:系统的渲染图片时都是以RGB32的像素格式来处理图片的,那么一张RGB16像素格式的位图在渲染时CPU是否会预处理?


RGB16渲染时的TimProfiler.png
RGB32渲染时TimeProfiler.png

可以看到RGB16像素格式的图片在渲染时执行了prepare_commit(CA::Transaation*)花费了34ms;而RGB32像素格式得图片在渲染时直接提交了;故此说明RGB16像素格式的位图渲染时也会由CPU进行预处理;
图片的压缩方法对于RGB16格式的位图也会处理为RGB32后再编码,且压缩后生成图片的像素格式为RGB32;

思考2:我们知道7以上机型的色域是P3,但其CGColorSpaceCreateDeviceRGB()所生成的颜色空间仍是sRGB,若p3图片在使用CGBitmapContextCreate解码时,是否也会出现颜色信息丢失?
会出现颜色信息丢失,解决方案两种1. CGBitmapContextCreate参数传该图片的颜色空间P3;2.使用CGImageCreateCopyWithColorSpace方法把该图片的颜色空间替换成sRGB;这两方案各有优劣。方案1就是7以下的手机仍显示不了,只能解决部分问题;方案2就是会导致图片颜色整体失真;下面提供了一直P3的图片,在iphone7以下的机型上或直接使用CGBitmapContextCreate sRGB绘制时会显示成了一张纯色背景,丢失了图片的关键内容

思考3:为什么系统要把图片的解码放在渲染时,而不是提前异步解码?
图片提前解码会立即分配内存,增加了内存压力。举例子对于一张大图(4961*5105像素,32位色)来说,就会立即分配96.24MB的内存。由此可见,这是一个拿空间换时间的策略。iOS设备早期的内存都是非常有限的,UIKit整套渲染机制很多地方采取的都是时间换空间。

思考4:若只需要对图片rect部分解码,使用GImageCreateWithImageInRect(imageRef, rect)裁剪后再解码是否可行?
可行,GImageCreateWithImageInRect()生成的图片会带有全部的原始数据,使用CGBitmapContextCreate解码时,内存的开销仅为rect区域的大小;若不自主解码也会在图片即将渲染时会触发CPU的解码;

思考5:tinypng上为什么可以把图片压缩的这么小?
根据tinypng官网上的解释,它的实现就是减少图片内的颜色,把图像中的类似颜色组合在一起。通过减少颜色数量,24位PNG文件可以转换为更小的8位索引彩色图像。同时所有不必要的元数据也会被删除。

思考6:本地图片通过系统的png压缩方法得到的data与原来的data相比不一样了?
系统的压缩方法压缩时都是对解码后的位图进行的压缩编码,而各平台上压缩算法的不同,那么最后生成的结果data自然也不同;若是在iOS系统中压缩来生成的图片,再次压缩时两次得到的data就是相同的;

思考7:“当图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。目前来说纹理尺寸上限都是 4096×4096。”网上大量的文章都有类似的话,但这话放在现在仍正确?
查阅苹果的官方文档,iphone6的纹理尺寸是8192px,6s及之后的机型的纹理尺寸是16384px;同时测试5000像素、10000像素的图片时均没有发现CPU额外的处理耗时;

最后
笔者对其认识尚浅,且本文带有较多的个人思考,难免纰漏之处,敬请指正。

参考文献:
https://developer.apple.com/library/archive/documentation/GraphicsImaging/Conceptual/drawingwithquartz2d/dq_context/dq_context.html#//apple_ref/doc/uid/TP30001066-CH203-BCIBHHBB

https://developer.apple.com/videos/play/wwdc2018/219

https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf

你可能感兴趣的:(UIImage)