跳转至

图片加载解码渲染

在iOS中使用 UIImageUIImageView来加载图片,遵守MVC架构,UIImage是数据相当于ModelUIImageView是视图相当于View

UIImage负责加载图片UIImageView负责渲染图片

图片的渲染流程分为3个阶段

  1. 加载(Load),数据缓冲区(DataBuffer)
  2. 解码(Decoder),图像缓冲区(imageBuffer)
  3. 渲染(Render),帧缓冲区(framebuffer)

以加载一个尺寸为:2048 px * 1536 px,在磁盘上的大小为:590kb的图片为例,来分析前两个阶段

1、图片加载

使用+imageWithContentsOfFile:方法从磁盘加载一张图片文件到内存中,使用Image I/O创建CGImageRef内存映射数据,此时图像尚未解码。

这个过程中先从磁盘拷贝数据到内核缓冲区,再从内核缓冲区复制数据到用户空间。

DataBuffer

DataBuffer只是一种包含一系列字节的缓冲区。通常以某些元数据开头,元数据描述了存储在数据缓冲区中的图像大小(包含图形数据本身),图像数据以某种形式编码(如 JPEG压缩或PNG),这意味着,该字节并不直接描述图像中像素的任何内容。此时的 DataBuffer大小为 590kb

SD源码分析

SDWebImage中,图片加载完成后,在 sd_imageFormatForImageData的方法中,是通过DataBuffer第一个字节来判断图片的格式的。

+ (SDImageFormat)sd_imageFormatForImageData:(nullable NSData *)data {
    if (!data) {
        return SDImageFormatUndefined;
    }

    uint8_t c;
    [data getBytes:&c length:1];
    switch (c) {
        case 0xFF:
            return SDImageFormatJPEG;
        case 0x89:
            return SDImageFormatPNG;
        case 0x47:
            return SDImageFormatGIF;
        case 0x49:
        case 0x4D:
            return SDImageFormatTIFF;
        case 0x52:
            // R as RIFF for WEBP
            if (data.length < 12) {
                return SDImageFormatUndefined;
            }

            NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
            if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
                return SDImageFormatWebP;
            }
    }
    return SDImageFormatUndefined;
}

2、图片的解码、解压缩(位图bitmap)

  • 用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。

  • 图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码(还原成位图)。

  • 把图像数据赋值给UIImageView,如果图像数据未解码(PNG/JPG),解码为位图数据。

  • 这一步是发生在主线程的,并且不可避免。图片的解压缩是需要消耗大量CPU时间,非常消耗性能。

  • 解压过的图片就不会重复解压,会缓存起来。

  • UIImage有两种缓存:一种是UIImage类的缓存,这种缓存保证imageNamed初始化的UIImage只会被解码一次。另一种是UIImage对象的缓存,这种缓存保证只要UIImage没有被释放,就不会再解码。

如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。

目前常见的网络图片库都自带这个功能。

位图

位图(Bitmap),又称栅格图(英语:Raster graphics)或点阵图,是使用像素阵列(Pixel-array/Dot-matrix点阵)来表示的图像

一张图片有成千上万个像素点,每一个像素点还原成红R绿G蓝B,透明度(默认1)。

位图就是一个像素数组,数组中的每个像素就代表着图片中的一个点。经常用到的 JPEG 和 PNG 图片就是位图。

获取图片的原始像素数据,用以下代码:

UIImage *image = [UIImage imageNamed:@"text.png"];
CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage));

不管是 JPEG 还是 PNG 图片,都是一种压缩的位图图形格式。PNG图片是无损压缩,并且支持 alpha 通道,JPEG 图片是有损压缩,可以指定 0-100% 的压缩比。值得一提的是,在苹果的 SDK 中专门提供了两个函数用来生成 PNG 和 JPEG 图片:

// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);

// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)                           
UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);   

为什么需要解压缩

磁盘中的图片渲染到屏幕之前,必须先要得到图片的原始像素数据,才能执行后续的绘制操作。

解压缩原理

利用CoreGraphics对图片进行重新绘制。

当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而如果图片已经解压缩了,系统就不会再对图片进行解压缩。因此,也就有了业内的解决方案,在子线程提前对图片进行强制解压缩。

而强制解压缩的原理就是对图片进行重新绘制,得到一张新的解压缩后的位图。其中,用到的最核心的函数是 CGBitmapContextCreate:

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(__MAC_10_0, __IPHONE_2_0);

这个函数用于创建一个位图上下文,用来绘制一张宽 width 像素,高 height 像素的位图。

  • data :如果不为 NULL ,那么它应该指向一块大小至少为 bytesPerRow * height 字节的内存;如果 为 NULL ,那么系统就会为我们自动分配和释放所需的内存,所以一般指定 NULL 即可;
  • width 和 height :位图的宽度和高度,分别赋值为图片的像素宽度和像素高度即可;
  • bitsPerComponent :像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可;
  • bytesPerRow :位图的每一行使用的字节数,大小至少为 width * bytes per pixel 字节。有意思的是,当我们指定 0 时,系统不仅会为我们自动计算,而且还会进行 cache line alignment 的优化,更多信息可以查看为什么CoreAnimation要字节对齐? 为什么我的图像的每行字节数超过其每像素的字节数乘以其宽度?

图像的绘制

图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是 [UIView drawRect:] 里面了。

由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。

YYImage\SDWebImage开源框架实现

用于解压缩图片的函数 YYCGImageCreateDecodedCopy 存在于 YYImageCoder 类中,核心代码如下

CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
    ...

    if (decodeForDisplay) { // decode with redraw (may lose some precision)
        CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;

        BOOL hasAlpha = NO;
        if (alphaInfo == kCGImageAlphaPremultipliedLast ||
            alphaInfo == kCGImageAlphaPremultipliedFirst ||
            alphaInfo == kCGImageAlphaLast ||
            alphaInfo == kCGImageAlphaFirst) {
            hasAlpha = YES;
        }

        // BGRA8888 (premultiplied) or BGRX8888
        // same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
        CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
        bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;

        //核心代码:
        CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo);
        if (!context) return NULL;

        CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode
        CGImageRef newImage = CGBitmapContextCreateImage(context);
        CFRelease(context);

        return newImage;
    } else {
        ...
    }
}

YYImage加载流程:

  • 获取图片二进制数据
  • 创建一个CGImageRef对象
  • 使用CGBitmapContextCreate()方法创建一个上下文对象context
  • 使用CGContextDrawImage()方法绘制到上下文
  • 使用CGBitmapContextCreateImage()生成CGImageRef对象。
  • 最后使用imageWithCGImage()方法将CGImage转化为UIImage。

SDWebImage 中对图片的解压缩过程与上述完全一致,只是传递给 CGBitmapContextCreate 函数的部分参数存在细微的差别。

ImageBuffer

图片加载完后,需要将Data BufferJPEG,PNG或其他编码的数据,转换为每个像素图像信息,这个过程,称为Decoder(解码),将像素信息存放在ImageBuffer

占用内存大小

图片占用的内存大小与图像的宽高尺寸有关,与它的文件大小无关解压缩后的图片大小 = 图片的像素宽 * 图片的像素高 * 每个像素所占的字节数

在iOSSRGB显示格式中(4byte空间显示一个像素),如果解析所有的像素,需要 2048 px * 1536 px * 4 byte/px = 10MB的空间,此时的 ImageBuffer的大小为10MB

ImageBuffer解析完后,提交给frameBuffer进行渲染显示。

图片加载过程和消耗的内存如下图所示:

图片

Xcode测试

Xcode工程中,当push新页面的时候,只加载一个图片。

加载前内存值:

图片

加载后内存值:

图片

大多数情况下,我们并不需要如此高精度的显示图片,占用了这么多的内存,能否减少加载图片时占用的内存值呢?

如何减少图像占用内存

向下采样

在苹果官方文档中,建议在对图片进行解码时使用向下采样(Downsampleing)的技术,来加载图片,减少ImageBuffer的大小。

图片

方法如下:

// MARK: 向下采样,减少内存消耗
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) ->UIImage {
    //利用图像地址创建imageSource
    let imageSourcesOptions = [kCGImageSourceShouldCache: false] as CFDictionary//原始图像不解码
    let imageSource: CGImageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourcesOptions)!
    //下采样
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
    let downsampleOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,// 缩小图像的同时进行解码
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize:maxDimensionInPixels
        ] as CFDictionary
    let downsampledImage: CGImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
    return UIImage(cgImage: downsampledImage)
 }

测试一下:

   let imageStr = Bundle.main.path(forResource: "view_site.jpeg", ofType: nil)
   let imageURL =  URL(string: "file://" + (imageStr ?? ""))
   guard let imgURL = imageURL else {
       return
   }
   imageView.image = downsample(imageAt:imgURL , to: CGSize(width: 200, height: 200), scale: UIScreen.main.scale)

图片

加载之前时是13M,加载之后是 17M,节省了大约 5M的内存空间。

SD源码分析解码过程

SDWebIamge中,一共有3种类型的解码器:SDImageIOCoder, SDImageGIFCoder, SDImageAPNGCoder,根据DataBuffer的编码类型,使用相对应的编码器。

-(UIImage *)decodedImageWithData:(NSData *)data方法中,配置解码参数,开始进行解码操作。

在下面的方法中,完成图像解码。

//图像解码
+ (UIImage *)createFrameAtIndex:(NSUInteger)index source:(CGImageSourceRef)source scale:(CGFloat)scale preserveAspectRatio:(BOOL)preserveAspectRatio thumbnailSize:(CGSize)thumbnailSize options:(NSDictionary *)options {
    // Some options need to pass to `CGImageSourceCopyPropertiesAtIndex` before `CGImageSourceCreateImageAtIndex`, or ImageIO will ignore them because they parse once :)
    // Parse the image properties
    NSDictionary *properties = (__bridge_transfer NSDictionary *)CGImageSourceCopyPropertiesAtIndex(source, index, (__bridge CFDictionaryRef)options);
    CGFloat pixelWidth = [properties[(__bridge NSString *)kCGImagePropertyPixelWidth] doubleValue];
    CGFloat pixelHeight = [properties[(__bridge NSString *)kCGImagePropertyPixelHeight] doubleValue];
    CGImagePropertyOrientation exifOrientation = (CGImagePropertyOrientation)[properties[(__bridge NSString *)kCGImagePropertyOrientation] unsignedIntegerValue];
    if (!exifOrientation) {
        exifOrientation = kCGImagePropertyOrientationUp;
    }

    CFStringRef uttype = CGImageSourceGetType(source);
    // Check vector format
    BOOL isVector = NO;
    if ([NSData sd_imageFormatFromUTType:uttype] == SDImageFormatPDF) {
        isVector = YES;
    }

    NSMutableDictionary *decodingOptions;
    if (options) {
        decodingOptions = [NSMutableDictionary dictionaryWithDictionary:options];
    } else {
        decodingOptions = [NSMutableDictionary dictionary];
    }
    CGImageRef imageRef;
    BOOL createFullImage = thumbnailSize.width == 0 || thumbnailSize.height == 0 || pixelWidth == 0 || pixelHeight == 0 || (pixelWidth <= thumbnailSize.width && pixelHeight <= thumbnailSize.height);
    if (createFullImage) {
        if (isVector) {
          //省略代码
        }
          //重要‼️
        imageRef = CGImageSourceCreateImageAtIndex(source, index, (__bridge CFDictionaryRef)[decodingOptions copy]);
    } else {
        decodingOptions[(__bridge NSString *)kCGImageSourceCreateThumbnailWithTransform] = @(preserveAspectRatio);
        CGFloat maxPixelSize;
        if (preserveAspectRatio) {
            CGFloat pixelRatio = pixelWidth / pixelHeight;
            CGFloat thumbnailRatio = thumbnailSize.width / thumbnailSize.height;
            if (pixelRatio > thumbnailRatio) {
                maxPixelSize = thumbnailSize.width;
            } else {
                maxPixelSize = thumbnailSize.height;
            }
        } else {
            maxPixelSize = MAX(thumbnailSize.width, thumbnailSize.height);
        }
        decodingOptions[(__bridge NSString *)kCGImageSourceThumbnailMaxPixelSize] = @(maxPixelSize);
        decodingOptions[(__bridge NSString *)kCGImageSourceCreateThumbnailFromImageAlways] = @(YES);
        //重要‼️
        imageRef = CGImageSourceCreateThumbnailAtIndex(source, index, (__bridge CFDictionaryRef)[decodingOptions copy]);
    }
    if (!imageRef) {
        return nil;
    }
    // Thumbnail image post-process
    if (!createFullImage) {
        if (preserveAspectRatio) {
            // kCGImageSourceCreateThumbnailWithTransform will apply EXIF transform as well, we should not apply twice
            exifOrientation = kCGImagePropertyOrientationUp;
        } else {
            // `CGImageSourceCreateThumbnailAtIndex` take only pixel dimension, if not `preserveAspectRatio`, we should manual scale to the target size
            CGImageRef scaledImageRef = [SDImageCoderHelper CGImageCreateScaled:imageRef size:thumbnailSize];
            CGImageRelease(imageRef);
            imageRef = scaledImageRef;
        }
    }

#if SD_UIKIT || SD_WATCH
    UIImageOrientation imageOrientation = [SDImageCoderHelper imageOrientationFromEXIFOrientation:exifOrientation];
    UIImage *image = [[UIImage alloc] initWithCGImage:imageRef scale:scale orientation:imageOrientation];
#else
    UIImage *image = [[UIImage alloc] initWithCGImage:imageRef scale:scale orientation:exifOrientation];
#endif
    CGImageRelease(imageRef);
    return image;
}

选择正确的图片渲染格式

渲染格式

在 iOS中,渲染图片格式有4种

  • Alpha 8 Format:1字节显示1像素,擅长显示单色调的图片。
  • Luminance and alpha 8 format: 亮度和 alpha 8 格式,2字节显示1像素,擅长显示有透明度的单色调图片。
  • SRGB Format: 4个字节显示1像素
  • Wide Format: 广色域格式,8个字节显示1像素。适用于高精度图片,
如何正确的选择渲染格式

正确的思路是:不选择渲染格式,让渲染格式选择你

使用 UIGraphicsImageRender来替换UIGraphicsBeginImageContextWithOptions,前者在iOS12以后,会自动选择渲染格式,后者默认都会选择SRGB Format

func render() -> UIImage{
   let bounds = CGRect(x: 0, y: 0, width: 300, height: 100)
   let render = UIGraphicsImageRenderer(size: bounds.size)
   let image = render.image { context in
       UIColor.blue.setFill()
           let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: CGSize(width: 20, height: 20))
       path.addClip()
       UIRectFill(bounds)
    }
    return image
}

此时,系统为自动选择Alpha 8 Format格式,内容空间占用,将会减少75%

减少后备存储器的使用

减少或者不使用 draw(rect:) 方法

在需要绘制带有子视图的View时,不使用 draw(rect:)方法,使用系统的View属性或者添加子视图的方式,将绘制工作交给系统来处理。

背景色直接通过UIView.backgroundColor设置,而非使用draw(rect:)

如何在列表中加载图片

对图片进行子线程异步加载,在后台进行 解码和下采样。在列表中,有时会加载很多图片,此时应该注意线程爆炸问题。

线程爆炸

当我们要求系统去做比CPU能够做的工作更多的工作时就会发生这种情况,比如我们要显示8张图片,但我们只有两个CPU,就不能一次完成所有这些工作,无法在不存在的CPU上进行并行处理,

为了避免向一个全局队列中异步的分配任务时发生死锁GCD将创建新线程来捕捉我们要求它所做的工作,然后CPU将花费大量时间,在这些线程之间进行切换,尝试在所有工作上取得我们要求操作系统为我们做的渐进式进展,在这些线程之间不停切换,实际上是相当大的开销,现在不是简单地将工作分派到全局异步队列之一,而是创建一个串行队列

在预取的方法中,异步的将工作分派到该队列,它的确意味着单个图像的加载,可能要比以前晚,才能开始取得进展,但CPU将花费更少的时间,在它可以做的小任务之间来回切换。

SDWebImage中,解码的队列 _coderQueue.maxConcurrentOperationCount = 1就是一个串行队列。这样就很好的解决了多图片异步解码时,线程爆炸问题。

3、图像图形渲染流程

UIImageView UIView背后都是一个layer,有一个图层树。

  1. 隐式CATransaction 捕获到UIImageView layer树的变化。

  2. 在主线程的下一个 runloop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐,可能会涉及到以下操作:

  3. 分配内存缓冲区用于管理文件 IO 和解压缩操作

  4. 最后 Core Animation 中CALayer使用未压缩的位图数据渲染 UIImageView 的图层

  5. CPU计算好图片的Frame,对图片解压之后,就会将解码后纹理图片位图数据通过数据总线交给GPU,GPU处理位图数据,进行渲染。

渲染流程

着色器渲染过程:在渲染过程中,必须存储2种着色器,分别是顶点着色器、片元着色器。顶点着色器是第一个着色器、片元着色器是最后一个。顶点着色器处理顶点、片元着色器处理像素点颜色。

显示器上如何显示:

image-20220416162010441

  1. 先确定顶点信息 交给顶点着色器

图片在什么位置显示 (iOS坐标转换屏幕坐标)

GPU获取图片的坐标(Frame),将坐标交给顶点着色器VertexShader (顶点变换计算)

图片/视频帧位置确定

iOS核心动画的旋转/缩放/平移,底层在顶点着色器用算法方式算好实现。

  1. 图元装配

三角形、线段、顶点

  1. 光栅化

图元转换为像素,获取图片对应屏幕上的像素点,实际绘制或填充每个顶点之间的像素

  1. 执行片元着色器(高度并发GPU)

片元着色器处理图片范围内每一个像素点颜色值。

片元着色器FragmentShader计算每个像素点的最终显示的颜色值,根据纹理坐标获取每个像素点的颜色值(如果出现透明值需要将每个像素点的颜色*透明度值)

​ 例:1200个像素点 片元着色器需要执行1200次。一个着色器只处理一个像素点。所以CPU不够用,循环太多,需要GPU。

​ GPU有很多计算单元,能实现高并发。CPU是多核 切换时间片模拟并发。

  1. 帧缓冲区

最后的结果放在帧缓冲区中。

  1. 从帧缓存区中渲染到屏幕上

OpenGL_ES做什么

  1. 动画效果
  2. 图片进行滤镜/特效处理
  3. 视频中加入滤镜/特效处理

OpenGL只提供了点、线段、三角形。

如何构成圆形:通过三角形,三角形够多够细。三角形可以构成任何图形。

正方形由两个三角形组成。一个三角形需要三组顶点数据,两个三角形就需要6组顶点数据。

特效滤镜如何实现

图片基本原理

简单:颜色

滤镜处理其实就是影响每一个像素点的颜色。

使用可编程管线实现特效

灵魂出窍滤镜算法实现思路

图片上处理灵魂缩放的特效

灵魂出窍滤镜:是两个层的叠加,并且上面的那层随着时间的推移,会逐渐放大且不透明度逐渐降低。这里也用到了放大的效果,我们这次用片元着色器来实现。

两部分组成:

  1. 原始图层。没变化
  2. 放大的 透明度变低的图层

两者结合。

.vsh 叫 vertex shader 顶点着色器

vec4 叫四维向量。包括:x横坐标,y纵坐标,z深度值,w缩放

vec2 二维向量。纹理(图片)坐标。

vsh/fsh 由开发者自行编译链接执行,不能有中文注释,否则编译无法通过。