本篇记录一下这两天遇到的关于图片发表遇到的几件事。

一、问题

  1. 压缩图片分辨率时,压缩失败,得到的最终结果是一张透明图image,或者是一张全黑的image.

  2. 用户选择多张图片时,内存暴涨。

  3. 遇到上面两类问题,客户端该采用什么样的策略进行兜底?

二、相关的知识点

(一)降低图片分辨率,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 的函数。它们的区别在于所使用的图像压缩算法和格式。以下是它们的区别以及何时使用它们的建议:

  1. UIImagePNGRepresentation:此函数将 UIImage 转换为 PNG 格式的 NSData。PNG 是一种无损压缩格式,这意味着在压缩过程中,图像信息不会丢失。PNG 使用 DEFLATE 压缩算法对图像进行压缩。由于 PNG 是无损压缩,因此在转换过程中不会损失图像质量。

    NSData *pngData = UIImagePNGRepresentation(image);

使用场景:

  • 当需要保留图像的完整质量时。
  • 当需要支持透明度(alpha通道)时。
  • 当处理具有许多相同颜色的图像(例如图标、截图或简单的图形)时,因为这种情况下 PNG 压缩效果更好。
  1. 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)对图像进行压缩。

以下是在压缩过程中可能丢失的信息:

  1. 高频细节信息:JPEG 压缩算法主要通过丢弃高频细节信息来减小文件大小。高频细节信息通常包括图像中的锐利边缘、纹理和细节。在压缩过程中,这些细节信息可能会被模糊或消除,从而导致图像质量下降。

  2. 色彩信息:JPEG 压缩还通过降低色彩分辨率来减小文件大小。人眼对亮度变化的敏感度高于颜色变化,因此 JPEG 压缩算法利用这一特性来降低色彩分辨率。这可能导致颜色失真和颜色平滑。

  3. 透明度信息:JPEG 格式不支持 alpha 通道,因此在将图像转换为 JPEG 格式时,透明度信息会丢失。如果原始图像具有透明度信息,压缩后的 JPEG 图像将不再透明。

压缩后的图片相比压缩前的图片,可能会出现以下问题:

  1. 图像模糊:由于丢失了高频细节信息,图像的锐利边缘、纹理和细节可能变得模糊。

  2. 块状效应:在压缩过程中,JPEG 算法将图像分割成 8x8 像素的块。由于每个块的压缩是独立进行的,这可能导致在块之间出现可见的边缘和不连续性,从而产生块状效应。

  3. 颜色失真和平滑:由于降低了色彩分辨率,图像的颜色可能会出现失真和平滑。

  4. 丢失透明度信息:如果原始图像具有透明度信息,压缩后的 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;
}