为什么图像在显示到屏幕上之前要进行解码
一般我们使用的图像是JPEG/PNG,这些图像数据不是位图,而是是经过编码压缩后的数据,需要线将它解码转成位图数据,然后才能把位图渲染到屏幕上。
当你用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。
图片加载的工作流
概括来说,从磁盘中加载一张图片,并将它显示到屏幕上,中间的主要工作流如下:
- 假设我们使用
+imageWithContentsOfFile:
方法从磁盘中加载一张图片,这个时候的图片并没有解压缩; - 然后将生成的
UIImage
赋值给UIImageView
; - 接着一个隐式的
CATransaction
捕获到了UIImageView
图层树的变化; - 在主线程的下一个 run loop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,而受图片是否字节对齐等因素的影响,这个 copy 操作可能会涉及以下部分或全部步骤:
- 分配内存缓冲区用于管理文件 IO 和解压缩操作;
- 将文件数据从磁盘读到内存中;
- 将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作;
- 最后 Core Animation 使用未压缩的位图数据渲染
UIImageView
的图层。
在上面的步骤中,我们提到了图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。
图像的解码
解码操作是比较耗时的,并且没有GPU硬解码,只能通过CPU,iOS默认会在主线程对图像进行解码。解码过程是一个相当复杂的任务,需要消耗非常长的时间。60FPS ≈ 0.01666s per frame = 16.7ms per frame,这意味着在主线程超过16.7ms的任务都会引起掉帧。很多库都解决了图像解码的问题,不过由于解码后的图像太大,一般不会缓存到磁盘,SDWebImage的做法是把解码操作从主线程移到子线程,让耗时的解码操作不占用主线程的时间。
对于PNG图片来说,因为文件可能更大,所以加载会比JPEG更长,但是解码会相对较快,而且Xcode会把PNG图片进行解码优化之后引入工程。JPEG图片更小,加载更快,但是解压的步骤要消耗更长的时间,因为JPEG解压算法比基于zip的PNG算法更加复杂。
当加载图片的时候,iOS通常会延迟解压图片的时间,直到加载到内存之后。因为需要在绘制之前进行解压,这就会在准备绘制图片的时候影响性能。
iOS通常会延时解压图片,等到图片在屏幕上显示的时候解压图片。解压图片是非常耗时的操作。
图像解码的核心方法CGBitmapContextCreate
CGContextRef CGBitmapContextCreate(
void * data,
size_t width,
size_t height,
size_t bitsPerComponent,
size_t bytesPerRow,
CGColorSpaceRef _Nullable space,
uint32_t bitmapInfo)
上面的第一个参数是一个只想一块内存的指针,这块内存用于存储被绘制的图形,这块内存的size最小不能小于bytesPerRow*height(图形每行的字节数乘以图形的高度),传递NULL意味着由这个函数来管理图形的内存,这可以减少内存泄漏的问题;
第二个参数with是图形的width;
第三个参数高度是图形的高度;
第四个参数是像素中每一个颜色分量的像素位数;
pixel formatter
Pixel Format
我们前面已经提到了,位图其实就是一个像素数组,而像素格式则是用来描述每个像素的组成格式,它包括以下信息:
- Bits per component :一个像素中每个独立的颜色分量使用的 bit 数;
- Bits per pixel :一个像素使用的总 bit 数;
- Bytes per row :位图中的每一行使用的字节数。
有一点需要注意的是,对于位图来说,像素格式并不是随意组合的,目前只支持以下有限的 17 种特定组合:
Color Spaces
在 Quartz 中,一个颜色是由一组值来表示的,比如 0, 0, 1 。而颜色空间则是用来说明如何解析这些值的,离开了颜色空间,它们将变得毫无意义。比如,下面的值都表示蓝色:
如果不知道颜色空间,那么我们根本无法知道这些值所代表的颜色。比如 0, 0, 1 在 RGB 下代表蓝色,而在 BGR 下则代表的是红色。在 RGB 和 BGR 两种颜色空间下,绿色是相同的,而红色和蓝色则相互对调了。因此,对于同一张图片,使用 RGB 和 BGR 两种颜色空间可能会得到两种不一样的效果:
BitmapInfo
Color Spaces and Bitmap Layout
我们前面已经知道了,像素格式是用来描述每个像素的组成格式的,比如每个像素使用的总 bit 数和每个独立的颜色分量使用的 bit 数。而要想确保 Quartz 能够正确地解析这些 bit 所代表的含义,我们还需要提供位图的布局信息 CGBitmapInfo :
typedef CF_OPTIONS(uint32_t, CGBitmapInfo) {
kCGBitmapAlphaInfoMask = 0x1F,
kCGBitmapFloatInfoMask = 0xF00,
kCGBitmapFloatComponents = (1 << 8),
kCGBitmapByteOrderMask = kCGImageByteOrderMask,
kCGBitmapByteOrderDefault = (0 << 12),
kCGBitmapByteOrder16Little = kCGImageByteOrder16Little,
kCGBitmapByteOrder32Little = kCGImageByteOrder32Little,
kCGBitmapByteOrder16Big = kCGImageByteOrder16Big,
kCGBitmapByteOrder32Big = kCGImageByteOrder32Big
} CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0);
它主要提供了三个方面的布局信息:
- alpha 的信息;
- 颜色分量是否为浮点数;
- 像素格式的字节顺序。
其中,alpha 的信息由枚举值 CGImageAlphaInfo来表示:
typedef CF_ENUM(uint32_t, CGImageAlphaInfo) {
kCGImageAlphaNone, /* For example, RGB. */
kCGImageAlphaPremultipliedLast, /* For example, premultiplied RGBA */
kCGImageAlphaPremultipliedFirst, /* For example, premultiplied ARGB */
kCGImageAlphaLast, /* For example, non-premultiplied RGBA */
kCGImageAlphaFirst, /* For example, non-premultiplied ARGB */
kCGImageAlphaNoneSkipLast, /* For example, RBGX. */
kCGImageAlphaNoneSkipFirst, /* For example, XRGB. */
kCGImageAlphaOnly /* No color data, alpha data only */
};
上面的注释其实已经比较清楚了,它同样也提供了三个方面的 alpha 信息:
- 是否包含 alpha ;
- 如果包含 alpha ,那么 alpha 信息所处的位置,在像素的最低有效位,比如 RGBA ,还是最高有效位,比如 ARGB ;
- 如果包含 alpha ,那么每个颜色分量是否已经乘以 alpha 的值,这种做法可以加速图片的渲染时间,因为它避免了渲染时的额外乘法运算。比如,对于 RGB 颜色空间,用已经乘以 alpha 的数据来渲染图片,每个像素都可以避免 3 次乘法运算,红色乘以 alpha ,绿色乘以 alpha 和蓝色乘以 alpha 。
那么我们在解压缩图片的时候应该使用哪个值呢?根据 Which CGImageAlphaInfo should we use 和官方文档中对 UIGraphicsBeginImageContextWithOptions
函数的讨论:
You use this function to configure the drawing environment for rendering into a bitmap. The format for the bitmap is a ARGB 32-bit integer pixel format using host-byte order. If the opaque parameter is YES, the alpha channel is ignored and the bitmap is treated as fully opaque (kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Host). Otherwise, each pixel uses a premultipled ARGB format (kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host).
我们可以知道,当图片不包含 alpha 的时候使用 kCGImageAlphaNoneSkipFirst
,否则使用 kCGImageAlphaPremultipliedFirst
。另外,这里也提到了字节顺序应该使用 32 位的主机字节顺序 kCGBitmapByteOrder32Host
,而这个值具体是什么,我们后面再讨论。
至于颜色分量是否为浮点数,这个就比较简单了,直接逻辑或 kCGBitmapFloatComponents
就可以了。更详细的内容就不展开了,因为我们一般用不上这个值。
接下来,我们来简单地了解下像素格式的字节顺序,它是由枚举值 CGImageByteOrderInfo
来表示的:
typedef CF_ENUM(uint32_t, CGImageByteOrderInfo) {
kCGImageByteOrderMask = 0x7000,
kCGImageByteOrder16Little = (1 << 12),
kCGImageByteOrder32Little = (2 << 12),
kCGImageByteOrder16Big = (3 << 12),
kCGImageByteOrder32Big = (4 << 12)
} CG_AVAILABLE_STARTING(__MAC_10_12, __IPHONE_10_0);
它主要提供了两个方面的字节顺序信息:
对于 iPhone 来说,采用的是小端模式,但是为了保证应用的向后兼容性,我们可以使用系统提供的宏,来避免 Hardcoding :
#ifdef __BIG_ENDIAN__
#define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Big
#define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Big
#else /* Little endian. */
#define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Little
#define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Little
#endif
根据前面的讨论,我们知道字节顺序的值应该使用的是 32 位的主机字节顺序 kCGBitmapByteOrder32Host
,这样的话不管当前设备采用的是小端模式还是大端模式,字节顺序始终与其保持一致。
下面,我们来看一张图,它非常形象地展示了在使用 16 或 32 位像素格式的 CMYK 和 RGB 颜色空间下,一个像素是如何被表示的:
我们从图中可以看出,在 32 位像素格式下,每个颜色分量使用 8 位;而在 16 位像素格式下,每个颜色分量则使用 5 位。
好了,了解完这些相关知识后,我们再回过头来看看 CGBitmapContextCreate
函数中每个参数所代表的具体含义:
-
data
:如果不为NULL
,那么它应该指向一块大小至少为bytesPerRow * height
字节的内存;如果 为NULL
,那么系统就会为我们自动分配和释放所需的内存,所以一般指定NULL
即可; -
width
和height
:位图的宽度和高度,分别赋值为图片的像素宽度和像素高度即可; -
bitsPerComponent
:像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可; -
bytesPerRow
:位图的每一行使用的字节数,大小至少为width * bytes per pixel
字节。有意思的是,当我们指定 0 时,系统不仅会为我们自动计算,而且还会进行 cache line alignment 的优化,更多信息可以查看 what is byte alignment (cache line alignment) for Core Animation? Why it matters? 和 Why is my image’s Bytes per Row more than its Bytes per Pixel times its Width? ,亲测可用; -
space
:就是我们前面提到的颜色空间,一般使用 RGB 即可; -
bitmapInfo
:就是我们前面提到的位图的布局信息。
+imageNamed:
通过 imageNamed 创建 UIImage 时,当 UIImage 第一次显示到屏幕上时,其内部的解码方法才会被调用,并且内存中自动缓存解压后的图片。当APP第一次退到后台和收到内存警告时,缓存才会被自动清空。
+imageWithContentsOfFile:
这个方法不会缓存解压后的图片,也就是说每次调用时都会对文件进行加载和解压。iOS通常会延迟解压图片,为了提升性能,在屏幕绘制前可以强制解压。
有两种方法可以强制解压:
- 将图片的一个像素绘制成一个像素大小的CGContext。这样仍然会解压整张图片,但是绘制本身并没有消耗任何时间。这样的好处在于加载的图片并不会在特定的设备上为绘制做优化,所以可以在任何时间点绘制出来。同样iOS也就可以丢弃解压后的图片来节省内存了。
//解压缩这个image,即时它只有一个像素。
- (void)decompressImage:(UIImage *)image
{
UIGraphicsBeginImageContext(CGSizeMake(1, 1));
[image drawAtPoint:CGPointZero];
UIGraphicsEndImageContext();
}
- 将整张图片绘制到CGContext中,丢弃原始的图片,并且用一个从上下文内容中新的图片来代替。这样比绘制单一像素那样需要更加复杂的计算,但是因此产生的图片将会为绘制做优化,而且由于原始压缩图片被抛弃了,iOS就不能够随时丢弃任何解压后的图片来节省内存了。
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
...
//切换到子线程
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
//load image
NSInteger index = indexPath.row;
NSString *imagePath = self.imagePaths[index];
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
//redraw image using device context
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, YES, 0);
[image drawInRect:imageView.bounds];
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
//set image on main thread, but only if index still matches up
dispatch_async(dispatch_get_main_queue(), ^{
if (index == cell.tag) {
imageView.image = image;
}
});
});
return cell;
}
Large Image Downsizing
SDWebImage解码的方法在SDWebImageDecoder这个类里。这个类里有两个方法,decodedImageWithImage
是直接对图片解码,decodedAndScaledDownImageWithImage
这个方法里会先判断图片的要解压缩的图片大小是否超过60M,没超过的话会调用decodedImageWithImage
这个方法直接解码图片,否则会对原图片进行缩放以减少占用内存空间,并且解码图片时会把原始的图片数据分成多个tail进行解码。这个过程应该是参考了apple的 Large Image Downsizing
在这个demo里,有一张large_leaves_70mp.jpg的图片,它在磁盘上的大小是8.3M,但它的像素是7033x10110的,也就是说图片解码后显示在屏幕上时所占的内存是7033x10110x4byte(一个像素是4byte),也就是271MB,这样一张图片,通过通常方法(imageView.image=image)是无法正常显示的。
demo里原始图片进行了缩放,并且对把原始图片分成多个tail进行解码。
缩放的主要代码:
原始图片的在像素上的宽高:
sourceResolution.width = CGImageGetWidth(sourceImage.CGImage);
sourceResolution.height = CGImageGetHeight(sourceImage.CGImage);
计算缩放比例
imageScale = destTotalPixels / sourceTotalPixels;
计算缩放目标图片的宽高
destResolution.width = (int)( sourceResolution.width * imageScale );
destResolution.height = (int)( sourceResolution.height * imageScale );
创建缩放目标图片的context:
// create the output bitmap context
destContext = CGBitmapContextCreate( destBitmapData, destResolution.width, destResolution.height, 8, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast );
虽然我们对超大图片进行了缩放,但是依然较大,特别是在绘制的时候,非常耗性能。所以Sample中的方法是将该图的绘制分成多个Tile来进行,在该Sample中这张图片被分成了14个Tile。sourceTile表示从原图上截取的Tile尺寸,destTile表示最终绘制到界面上的Tile尺寸。
demo里通过宏tileTotalPixels
定义了一个tile的总的尺寸,然后计算了原图片一个tile的高度
sourceTile.size.width = sourceResolution.width;
// the source tile height is dynamic. Since we specified the size
// of the source tile in MB, see how many rows of pixels high it
// can be given the input image width.
sourceTile.size.height = (int)( tileTotalPixels / sourceTile.size.width );
然后根据比例计算目标tile的高度:
destTile.size.height = sourceTile.size.height * imageScale;
计算原图片tile的个数:
int iterations = (int)( sourceResolution.height / sourceTile.size.height );
int remainder = (int)sourceResolution.height % (int)sourceTile.size.height; if( remainder ) iterations++;
根据tile的个数进行循环,把每一个原始图片的tile绘制到context中,代码里的注释提到了,CGContextDrawImage调用时数据会被解码。
for( int y = 0; y < iterations; ++y ) {
// create an autorelease pool to catch calls to -autorelease made within the downsize loop.
NSAutoreleasePool* pool2 = [[NSAutoreleasePool alloc] init];
NSLog(@"iteration %d of %d",y+1,iterations);
sourceTile.origin.y = y * sourceTileHeightMinusOverlap + sourceSeemOverlap;
destTile.origin.y = ( destResolution.height ) - ( ( y + 1 ) * sourceTileHeightMinusOverlap * imageScale + destSeemOverlap );
// create a reference to the source image with its context clipped to the argument rect.
sourceTileImageRef = CGImageCreateWithImageInRect(sourceImage.CGImage, sourceTile);
// if this is the last tile, it's size may be smaller than the source tile height.
// adjust the dest tile size to account for that difference.
if( y == iterations - 1 && remainder ) {
float dify = destTile.size.height;
destTile.size.height = CGImageGetHeight( sourceTileImageRef ) * imageScale;
dify -= destTile.size.height;
destTile.origin.y += dify;
}
// read and write a tile sized portion of pixels from the input image to the output image.
CGContextDrawImage( destContext, destTile, sourceTileImageRef );
/* release the source tile portion pixel data. note,
releasing the sourceTileImageRef doesn't actually release the tile portion pixel
data that we just drew, but the call afterward does. */
CGImageRelease(sourceTileImageRef);
/* while CGImageCreateWithImageInRect lazily loads just the image data defined by the argument rect,
that data is finally decoded from disk to mem when CGContextDrawImage is called. sourceTileImageRef
maintains internally a reference to the original image, and that original image both, houses and
caches that portion of decoded mem. Thus the following call to release the source image. */
[sourceImage release];
// free all objects that were sent -autorelease within the scope of this loop.
[pool2 drain];
// we reallocate the source image after the pool is drained since UIImage -imageNamed
// returns us an autoreleased object.
if( y < iterations - 1 ) {
sourceImage = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:kImageFilename ofType:nil]];
[self performSelectorOnMainThread:@selector(updateScrollView:) withObject:nil waitUntilDone:YES];
}
}