本篇记录一下这两天遇到的关于图片发表遇到的几件事。
一、问题
压缩图片分辨率时,压缩失败,得到的最终结果是一张透明图image,或者是一张全黑的image.
用户选择多张图片时,内存暴涨。
遇到上面两类问题,客户端该采用什么样的策略进行兜底?
二、相关的知识点
(一)降低图片分辨率,iOS有什么方法?
在iOS上,对图片分辨率进行降低,有两种方法,一种是基于UIKit,一种是基于Core Graphics。
1. 基于 UIKit
这种方法使用 drawInRect: 函数将原始图片绘制到一个指定大小的矩形区域内。这会导致图片被缩放以适应新的尺寸。这种方法的原理是基于 UIKit 的绘图系统,它使用位图上下文(bitmap context)将原始图片绘制到一个新的图像上。
UIGraphicsBeginImageContextWithOptions(imgSize, NO, 0.0);
[originImage drawInRect:CGRectMake(0, 0, imgSize.width, imgSize.height)];
UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
优点:
- 简单易用,只需几行代码即可实现图片缩放。
- 适用于对图片质量要求不高的场景。
缺点:
- 可能会导致图片质量损失,尤其是在缩放比例较大时。
- 依赖于 UIKit,必须在主线程中执行。
2. 基于 Core Graphics
这种方法使用 Core Graphics 框架中的 CGImageSourceCreateThumbnailAtIndex 函数创建缩略图。首先,将原始 UIImage 转换为 JPEG 格式的 NSData,然后使用 CGImageSourceCreateWithData 函数创建一个图像源。接下来,使用 CGImageSourceCreateThumbnailAtIndex 函数创建缩略图。这种方法的原理是基于 Core Graphics 框架,它可以更高效地处理图像缩放。
NSData *data = UIImageJPEGRepresentation(img, 1);
CGFloat max = constraintSize.width;
if (max < constraintSize.height)
max = constraintSize.height;
CFDictionaryRef dicOptionsRef = (__bridge CFDictionaryRef) @{
(id)kCGImageSourceCreateThumbnailFromImageIfAbsent : @(YES),
(id)kCGImageSourceThumbnailMaxPixelSize : @(max),
(id)kCGImageSourceShouldCache : @(YES),
};
CGImageSourceRef src = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil);
CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(src, 0, dicOptionsRef);
UIImage *newImage = [UIImage imageWithCGImage:imageRef scale:img.scale orientation:UIImageOrientationUp];
if (imageRef != nil) {
CFRelease(imageRef);
}
if (src != nil) {
CFRelease(src);
}
优点:
- 使用 Core Graphics 框架,可以在后台线程中执行。
- 通常可以获得更高质量的缩放结果。
缺点:
- 代码相对复杂,需要处理底层的 Core Graphics API。
对比
第一种方法基于 UIKit,适用于简单的图片缩放任务,但可能导致较大的图片质量损失。第二种方法基于 Core Graphics,适用于需要在后台线程中执行的任务,通常可以获得更高质量的缩放结果。
所以基于以上的差别来看,我们还是推荐优先使用 Core Graphics 进行图片分辨率的降低。
(二)UIImagePNGRepresentation 和 UIImageJPEGRepresentation 区别
1. 两者的区别
UIImagePNGRepresentation 和 UIImageJPEGRepresentation 都是将 UIImage 转换为不同图像格式的 NSData 的函数。它们的区别在于所使用的图像压缩算法和格式。以下是它们的区别以及何时使用它们的建议:
UIImagePNGRepresentation:此函数将 UIImage 转换为 PNG 格式的 NSData。PNG 是一种无损压缩格式,这意味着在压缩过程中,图像信息不会丢失。PNG 使用 DEFLATE 压缩算法对图像进行压缩。由于 PNG 是无损压缩,因此在转换过程中不会损失图像质量。
NSData *pngData = UIImagePNGRepresentation(image);
使用场景:
- 当需要保留图像的完整质量时。
- 当需要支持透明度(alpha通道)时。
- 当处理具有许多相同颜色的图像(例如图标、截图或简单的图形)时,因为这种情况下 PNG 压缩效果更好。
UIImageJPEGRepresentation:此函数将 UIImage 转换为 JPEG 格式的 NSData。JPEG 是一种有损压缩格式,这意味着在压缩过程中,一些图像信息可能会丢失。JPEG 使用离散余弦变换(Discrete Cosine Transform,DCT)对图像进行压缩。在压缩过程中,一些高频细节信息可能会丢失,从而导致图像质量下降。此函数接受一个参数 compressionQuality,其值范围为 0.0(最大压缩,最低质量)到 1.0(最小压缩,最高质量)。
NSData *jpegData = UIImageJPEGRepresentation(image, compressionQuality);
使用场景:
- 当需要减小文件大小时,特别是对于大尺寸的照片和复杂的图像。
- 当对图像质量损失可以容忍时。
- 当不需要支持透明度时,因为 JPEG 格式不支持 alpha 通道。
2. JPG和jpeg的区别是什么?
JPG 是 JPEG 文件扩展名的简写形式,它们之间的区别仅在于文件扩展名。
3. 一个JPG的图片转成PNG是不是图片还会变大?为什么会变大?
将 JPG 图片转换为 PNG 图片,文件大小可能会变大。原因如下: JPG(JPEG)是一种有损压缩格式,它通过丢弃一些图像信息来减小文件大小。而 PNG 是一种无损压缩格式,它保留了图像的所有信息。因此,当将 JPG 图片转换为 PNG 图片时,文件大小可能会变大,因为 PNG 格式需要存储更多的图像信息。另外,PNG 格式通常在处理包含许多相同颜色的图像(如简单的图形、图标或截图)时具有更好的压缩效果。对于复杂的照片和图像,PNG 格式的压缩效果可能不如 JPG 格式。
4. 使用UIImageJPEGRepresentation压缩图片时,是对图片的什么信息进行压缩的呢? 压缩后的图片相比压缩前的图片会丢失什么信息呢?
当使用 UIImageJPEGRepresentation 压缩图片时,你实际上是在将 UIImage 转换为 JPEG 格式的数据。JPEG 是一种有损图像压缩格式,这意味着在压缩过程中,一些图像信息可能会丢失。JPEG 使用离散余弦变换(Discrete Cosine Transform,DCT)对图像进行压缩。
以下是在压缩过程中可能丢失的信息:
高频细节信息:JPEG 压缩算法主要通过丢弃高频细节信息来减小文件大小。高频细节信息通常包括图像中的锐利边缘、纹理和细节。在压缩过程中,这些细节信息可能会被模糊或消除,从而导致图像质量下降。
色彩信息:JPEG 压缩还通过降低色彩分辨率来减小文件大小。人眼对亮度变化的敏感度高于颜色变化,因此 JPEG 压缩算法利用这一特性来降低色彩分辨率。这可能导致颜色失真和颜色平滑。
透明度信息:JPEG 格式不支持 alpha 通道,因此在将图像转换为 JPEG 格式时,透明度信息会丢失。如果原始图像具有透明度信息,压缩后的 JPEG 图像将不再透明。
压缩后的图片相比压缩前的图片,可能会出现以下问题:
图像模糊:由于丢失了高频细节信息,图像的锐利边缘、纹理和细节可能变得模糊。
块状效应:在压缩过程中,JPEG 算法将图像分割成 8x8 像素的块。由于每个块的压缩是独立进行的,这可能导致在块之间出现可见的边缘和不连续性,从而产生块状效应。
颜色失真和平滑:由于降低了色彩分辨率,图像的颜色可能会出现失真和平滑。
丢失透明度信息:如果原始图像具有透明度信息,压缩后的 JPEG 图像将不再透明。
需要注意的是,UIImageJPEGRepresentation 函数接受一个 compressionQuality 参数,其值范围为 0.0(最大压缩,最低质量)到 1.0(最小压缩,最高质量)。通过调整此参数,你可以在文件大小和图像质量之间找到一个平衡点。在某些情况下,使用较高的压缩质量(如 0.8 到 0.9)可能仍然可以获得相当好的图像质量,同时显著减小文件大小。
(三)app加载图片内存相关
app应用中如果分别加载两张图片,一张是 4080x4080的小苏图片,图片大小是2MB,另外一张是 1080x1080的图片,图片大小是10MB,加载进内存后,哪个会让内存涨得更多呢? 为什么呢?
在加载两张不同尺寸和文件大小的图片时,内存占用取决于图像的像素数而不是文件大小。以下是详细解释:
- 图片1:尺寸为 4080x4080,像素数为 16,646,400,文件大小为 2MB。
- 图片2:尺寸为 1080x1080,像素数为 1,166,400,文件大小为 10MB。
当图片加载到内存中时,它们会以位图形式存储,每个像素占用一定数量的内存。对于 32 位颜色深度的图像(最常见的情况),每个像素占用 4 字节(32 位)的内存。因此,内存占用与像素数成正比。
图片1 的内存占用:16,646,400 像素 * 4 字节/像素 = 66,585,600 字节(约 63.5 MB)。 图片2 的内存占用:1,166,400 像素 * 4 字节/像素 = 4,665,600 字节(约 4.5 MB)。
从这个计算中,我们可以看到,尽管图片1 的文件大小较小(2MB),但由于其像素数较多,加载到内存中时,它会占用更多的内存(约 63.5 MB)。相比之下,图片2 的文件大小较大(10MB),但由于其像素数较少,加载到内存中时,它会占用较少的内存(约 4.5 MB)。因此,在这种情况下,加载图片1 会让内存涨得更多。
三、优化策略讨论
对用户发表图片的处理,一般是要经过两种处理环节: 一是对分辨率的压缩,二是对图片大小的压缩(一般是有损压缩)。
经过我过往对图片发表问题的梳理,容易出问题的地方是在【分辨率的压缩】环节,遇到的问题如下:
- 图片裁剪后有绿边
- 裁剪后的图片变成 全黑 或者 全透明
- 多张高清图片压缩爆内存
(一)问题原因讨论
接下来我们一个一个聊:
1. 图片裁剪后有绿边
我们之前收到过反馈:用户发表了视频后,用户动态的封面图出现了绿边。
这种问题本质上并不是裁剪图片的问题,而是裁剪视频导致的问题。
在处理视频编码和解码时,尤其是使用像 H.264 这样的编码标准时,视频的宽度和高度通常需要是 16 的倍数。这是因为这些编码标准使用了宏块(macroblock)进行编码,宏块的大小通常是 16x16 像素。如果视频的宽度和高度不是 16 的倍数,编码器可能需要在视频的右侧和底部添加填充(padding),这可能导致出现黑边或绿边。
所以之所以出现封面图是绿色的情况,是因为裁剪出来的视频出现了绿边导致的。
如果你单纯对图片进行裁剪,是不会有绿边的问题的,也不会要求你需要是16的倍数。
2. 裁剪后的图片变成 全黑 或者 全透明
第二部分我们也说了,图片裁剪最主流的有两种方式,一是基于UIKit,一是基于Core Graphics,我们遇到这类case时,基本都是基于UIKit裁剪导致的bug。
因为基于UIKit的压缩逻辑并没有源码可以debug,所以我们并不知晓为何这个接口容易出现这类问题,但我们通过对大量case进行分析,得到一个通用的结论。
就是出现UIKit裁剪图片导致的bug时,往往伴随着严重的内存告警,所以我们怀疑是这样:在内存严重不够时,UIKit压缩框架为了保证app不崩,停止了要继续的渲染工作。
3. 多张高清图片压缩爆内存
正如第二部分所说,图片加载进内存后,内存是否暴涨和图片的大小无关,和仅仅和它的分辨率有关。
而我们查过各类爆内存的case中,图片大小不大,但分辨率很大的情况在各手机厂商中愈演愈烈,随随便便拍一张图片都是2000像素起步。
(二)优化策略
基于我们上面的讨论,我们要重点处理两类case,【裁剪后的图片变成 全黑 或者 全透明】以及【多张高清图片压缩爆内存】。
【裁剪后的图片变成 全黑 或者 全透明】的诞生基本伴随着使用 UIKit 和 内存告警,所以你们业务如果要处理这类问题,我们建议先把【多张高清图片压缩爆内存】的问题处理掉。
1. 多张高清图片压缩爆内存
爆内存的原因很简单,就是一次性load太多高清图片,那么解决的思路也很显然,有两个方向:
- 不要展示高清原图
- 不要并发处理高清图片
- 添加autoreleasepool即使释放资源
(1)不要展示高清原图
这个怎么处理呢? 以微信为例,微信的图片编辑页面,可以一次性选中9张图片,如果这9张图片都是高清大图,那不就爆内存了吗?
所以我们在浏览图片时,就要对图片分辨率进行处理,先对图片一张一张进行压缩,然后再load进内存。
(2)不要并发处理高清图片
用户选择了多张图片后,我们在压缩图片分辨率时,要保证压缩任务的串行,一旦并发压缩多图,就容易出现内存猛涨的问题,但因为串行操作用户就会等待处理图片,所以如果你想降低用户的等待焦虑,那么就可以起一个后台线程进行处理,那么当然你不能使用UIKit框架,而只能使用 Core Graphics对图片进行压缩处理了。
(3)添加autoreleasepool即使释放资源
在使用 Core Graphics 进行大量的图像处理操作时,确实可能会出现内存占用过大的问题。这是因为 Core Graphics 的许多函数在执行时会创建并保留临时对象,这些对象可能会在当前的自动释放池周期结束之前一直占用内存。
为了解决这个问题,你可以使用 @autoreleasepool
块来更频繁地释放临时对象。@autoreleasepool
块会创建一个新的自动释放池,当这个块的代码执行完毕时,自动释放池会被销毁,所有在这个块中创建的临时对象都会被释放。
以下是一个使用 @autoreleasepool
块进行图像压缩的示例:
for (UIImage *image in images) {
@autoreleasepool {
// Perform image compression
CGImageRef cgImage = [image CGImage];
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
unsigned char *data = calloc(width * height * 4, sizeof(unsigned char));
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = CGBitmapContextCreate(data, width, height, 8, width * 4, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
// ... (More image compression code)
CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
free(data);
}
}
在这个示例中,我们在每次循环中都创建了一个新的 @autoreleasepool
块,这样可以确保每次处理完一张图片后,所有的临时对象都会被立即释放,从而减小内存占用。
2. 裁剪后的图片变成 全黑 或者 全透明
如果你彻底解决了内存不够的问题,那么使用UIKit框架进行图片压缩基本就碰不到压缩失败的case了,但!内存不够的问题,显然不是一个能100%优化的问题,所以我们这里一定要进行兜底处理。
针对这类case,我们处理的逻辑伪代码如下:
- (UIImage *)handleImageFrom:(UIImage *)fromImage toImage:(UIImage *)toImage {
if [self.class isBadImageFrom:fromImage toImage:toImage] == YES
// 是bad case 坏图,先尝试切换到 Core Graphics再裁剪一遍分辨率
toImage = useCoreGraphicsCompress(fromImage)
if [self.class isBadImageFrom:fromImage toImage:toImage] == YES
// Core Graphics 也解决不了问题,那就设置告警线
// 分辨率一定不能超过这条线,如果超过这条线,可能会导致低端机型刷到动态后爆内存
BOOL resolutionCheck = isOverResolutionThreshold(fromImage.size)
if resolutionCheck == YES
// 超过分辨率阈值,告诉用户发表失败,让用户尝试对分辨率进行处理,并补充idkey打点告警
return nil
else
// 没超过分辨率阈值,那就放过,允许发表
return fromImage
}
+ (BOOL)isBadImageFrom:(UIImage *)fromImage toImage:(UIImage *)toImage {
if fromImage != 全黑图 && toImage == 全黑图
return YES;
else if fromImage != 全透明图 && toImage == 全透明图
return YES;
return NO;
}
- (BOOL)isImageTransparentOrBlack:(UIImage *)image {
CGImageRef cgImage = [image CGImage];
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
unsigned char *data = calloc(width * height * 4, sizeof(unsigned char));
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = CGBitmapContextCreate(data, width, height, 8, width * 4, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
// 记得要按自身业务严格控制这里的step!step过小会有性能问题!且建议这个检测放在子线程
int step = 50; // Change this to increase/decrease the number of pixels checked
for (int y = 0; y < height; y += step) {
for (int x = 0; x < width; x += step) {
int pixelIndex = (width * y + x) * 4;
if (data[pixelIndex] > 0 || data[pixelIndex + 1] > 0 || data[pixelIndex + 2] > 0 || data[pixelIndex + 3] > 0) {
CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
free(data);
return NO;
}
}
}
CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
free(data);
return YES;
}