YYImage Bug:一行之差,正常 JPEG 如何在 iOS 被读成条纹

0 阅读4分钟

深入理解代替单纯记忆

先看问题:原图正常,App 大图却扭曲

用户发布的是一张横拍 JPEG。把原图下载下来或在浏览器中打开,内容完全正常;但 iOS App 的大图页会显示为横向条纹和拉伸。

问题样本的原始像素是 4000 × 3000,EXIF Orientation 是 6(Right) 。这表示:像素数据本身是横向存储的,展示前还需要向右旋转 90°。

因此,问题不在 CDN、下载过程或图片上传结果,而在客户端对这张“需要旋转”的 JPEG 进行解码和方向归一化时。

真正的处理过程:src、tmp、dest 分别是什么

YYImage是如何做图像的旋转的?

下面用一张由“绿植、杯子、苹果、手机”等局部组成的缩小照片举例。

每个格子代表照片的一块区域:旋转后,照片内容已在 tmp 中转正;dest 的照片内容相同,但它是 vImage 为最终显示格式新分配的另一块内存。YYImage最终还要将这块内存数据转为可使用的CGImage格式

image.png

为什么需要 tmp? 旋转不能在原图内存上边读边写:先写入的新位置可能覆盖尚未读取的原始像素。因此需要 tmp 作为新的旋转画布。

为什么还需要 dest? tmp 使用旋转函数方便处理的 ARGB 格式;如果最终显示需要不同的字节序或 alpha 格式,vImage 会新建 dest 做格式转换。若格式相同,代码会直接返回 tmpImage,不会创建 dest。

错误怎样变成条纹

  • dest 是 vImage 生成的 vImage_Buffer。vImage 为了让下一行从更适合 CPU / SIMD 读取的内存地址开始,可能把每行实际占用的字节数补齐到 16、32 或其他对齐边界

    • 下图中的灰色部分就是这段对齐填充字节:它占用内存,但不是图像像素,正常情况下不会显示出来。
  • 错误发生在dest转为 CGImage 的时候( 对应YYImageCoder.m中YYCGImageCreateAffineTransformCopy方法)

  • 创建 CGImage 时错误地使用了 destBytesPerRow,而不是 dest.rowBytes。这会让 CGImage 按错误的行距解释同一块 dest.data:第一行通常正常,从第二行开始会从错误偏移读取像素,最终绘制成条纹/拉伸

下图展示了出错的过程:

image.png

// 此处 dest 由 vImageBuffer_Init 在 helper 内部重新分配
YYCGImageDecodeToBitmapBufferWith32BitFormat(tmpImage, &dest, destBitmapInfo);

// 错:dest.data 的实际行距应是 dest.rowBytes
destImage = CGImageCreate(destWidth, destHeight, 8, 32,
                          destBytesPerRow, ...);

为什么YYImage使用错误的destBytesPerRow? destBytesPerRow 是 YYImage 按宽度推算出来的,这是它认为的最终 dest 每行应有的字节数,并非实际的字节数

怎么修:使用 vImage 实际给出的行距

修复只改两处:Provider 的总字节数和 CGImage 的每行字节数都以 dest.rowBytes 为准。

// dest.rowBytes 是 vImage 实际输出缓冲区的行距
destProvider = CGDataProviderCreateWithData(dest.data, dest.data,
                                             dest.height * dest.rowBytes,
                                             YYCGDataProviderReleaseDataCallback);
destImage = CGImageCreate(destWidth, destHeight, 8, 32,
                          dest.rowBytes, // 与 dest.data 的真实内存布局一致
                          YYCGColorSpaceGetDeviceRGB(), destBitmapInfo,
                          destProvider, NULL, false,
                          kCGRenderingIntentDefault);

这是组件层的根因修复:不需要业务侧关闭 EXIF,也不需要把每张图先重绘、再重新编码。所有经过该旋转函数的路径都会使用正确的内存布局。

什么场景更容易复现

特征为什么值得重点排查
JPEG 的 EXIF Orientation 不是 Up需要执行方向旋转,才会进入这段代码;Right(6)和 Left(8)尤其典型。
Android / 三星手机横拍图片本次问题样本属于这一类。不是三星专属,但这类来源的横拍 JPEG 更容易带有需要旋转的 EXIF 元数据。
原图、浏览器正常,只有 iOS App 异常说明文件内容大概率正常,应优先检查 App 是否由 YYImage 执行了方向归一化。
同类图偶现而非必现实际行距是否与预计行距不同受图像尺寸、像素格式、系统和内存对齐影响;这正是问题看起来罕见的原因。

补充:YYImage Issue #131 也在方向修正后出现乱码,最终同样会走到这个旋转函数;该 issue 的业务层做法是先手动转正以绕开问题,而本修复直接更正了底层行距。