首先我们来看iOS加载一张图片所经历的过程:(下面所讲述的代码基本以 imageWithContentsOfFile 方法来举例)
帧缓冲区
然后
视频控制器
会按照 VSync
信号逐行读取帧缓冲区
的数据,给显示器显示从上述渲染过程中,我寻找其可优化点。
当你用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。
iOS默认会在主线程对图像进行解码。解码过程是一个相当复杂的任务,需要消耗非常长的时间。由于在主线程超过16.7ms的任务会引起掉帧,所以我们把解码操作从主线程移到子线程,让耗时的解码操作不占用主线程的时间,解码的核心方法如下:
CGContextRef CGBitmapContextCreate(
void * data, //这块内存用于存储被绘制的图形,这块内存的size最小不能小于bytesPerRow*height(图形每行的字节数乘以图形的高度),传递NULL意味着由这个函数来管理图形的内存,这可以减少内存泄漏的问题;
size_t width, //图形的width
size_t height,//图形的height
size_t bitsPerComponent, //像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可
size_t bytesPerRow,//位图的每一行使用的字节数,大小至少为 width * bytes per pixel 字节。有意思的是,当我们指定 0 时,系统不仅会为我们自动计算,而且还会进行 cache line alignment 的优化
CGColorSpaceRef _Nullable space, //就是我们前面提到的颜色空间,一般使用 RGB 即可;
uint32_t bitmapInfo//是一个枚举,
)
异步解码上代码
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
NSString *imagePath = self.imagePaths;
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, YES, 0);
[image drawInRect:imageView.bounds];
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
});
});
虽说能够正常解压,但是我们也会发现一个问题,就是大图片的解压,所以这个地方安装苹果和各大三方代码中的提示要分为2种情况讨论:
1.对于小于60M的图片我们直接对图片解码,下面是SD的代码
+ (UIImage *)decodedImageWithImage:(UIImage *)image {
if (![self shouldDecodeImage:image]) {
return image;
}
CGImageRef imageRef = [self CGImageCreateDecoded:image.CGImage];
if (!imageRef) {
return image;
}
UIImage *decodedImage = [[UIImage alloc] initWithCGImage:imageRef scale:image.scale orientation:image.imageOrientation];
CGImageRelease(imageRef);
SDImageCopyAssociatedObject(image, decodedImage);
decodedImage.sd_isDecoded = YES;
return decodedImage;
}
2.对于大于60M的图片,会对原图片进行缩放以减少占用内存空间,并且解码图片时会把原始的图片数据分成多个tail进行解码,下面是SD的代码
+ (UIImage *)decodedAndScaledDownImageWithImage:(UIImage *)image limitBytes:(NSUInteger)bytes {
if (![self shouldDecodeImage:image]) {
return image;
}
if (![self shouldScaleDownImage:image limitBytes:bytes]) {
return [self decodedImageWithImage:image];
}
CGFloat destTotalPixels;
CGFloat tileTotalPixels;
if (bytes == 0) {
bytes = kDestImageLimitBytes;
}
destTotalPixels = bytes / kBytesPerPixel;
tileTotalPixels = destTotalPixels / 3;
CGContextRef destContext;
// autorelease the bitmap context and all vars to help system to free memory when there are memory warning.
// on iOS7, do not forget to call [[SDImageCache sharedImageCache] clearMemory];
@autoreleasepool {
CGImageRef sourceImageRef = image.CGImage;
CGSize sourceResolution = CGSizeZero;
sourceResolution.width = CGImageGetWidth(sourceImageRef);
sourceResolution.height = CGImageGetHeight(sourceImageRef);
CGFloat sourceTotalPixels = sourceResolution.width * sourceResolution.height;
// Determine the scale ratio to apply to the input image
// that results in an output image of the defined size.
// see kDestImageSizeMB, and how it relates to destTotalPixels.
CGFloat imageScale = sqrt(destTotalPixels / sourceTotalPixels);
CGSize destResolution = CGSizeZero;
destResolution.width = MAX(1, (int)(sourceResolution.width * imageScale));
destResolution.height = MAX(1, (int)(sourceResolution.height * imageScale));
// device color space
CGColorSpaceRef colorspaceRef = [self colorSpaceGetDeviceRGB];
BOOL hasAlpha = [self CGImageContainsAlpha:sourceImageRef];
// iOS display alpha info (BGRA8888/BGRX8888)
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
// kCGImageAlphaNone is not supported in CGBitmapContextCreate.
// Since the original image here has no alpha info, use kCGImageAlphaNoneSkipFirst
// to create bitmap graphics contexts without alpha info.
destContext = CGBitmapContextCreate(NULL,
destResolution.width,
destResolution.height,
kBitsPerComponent,
0,
colorspaceRef,
bitmapInfo);
if (destContext == NULL) {
return image;
}
CGContextSetInterpolationQuality(destContext, kCGInterpolationHigh);
// Now define the size of the rectangle to be used for the
// incremental bits from the input image to the output image.
// we use a source tile width equal to the width of the source
// image due to the way that iOS retrieves image data from disk.
// iOS must decode an image from disk in full width 'bands', even
// if current graphics context is clipped to a subrect within that
// band. Therefore we fully utilize all of the pixel data that results
// from a decoding operation by anchoring our tile size to the full
// width of the input image.
CGRect sourceTile = CGRectZero;
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 = MAX(1, (int)(tileTotalPixels / sourceTile.size.width));
sourceTile.origin.x = 0.0f;
// The output tile is the same proportions as the input tile, but
// scaled to image scale.
CGRect destTile;
destTile.size.width = destResolution.width;
destTile.size.height = sourceTile.size.height * imageScale;
destTile.origin.x = 0.0f;
// The source seem overlap is proportionate to the destination seem overlap.
// this is the amount of pixels to overlap each tile as we assemble the output image.
float sourceSeemOverlap = (int)((kDestSeemOverlap/destResolution.height)*sourceResolution.height);
CGImageRef sourceTileImageRef;
// calculate the number of read/write operations required to assemble the
// output image.
int iterations = (int)( sourceResolution.height / sourceTile.size.height );
// If tile height doesn't divide the image height evenly, add another iteration
// to account for the remaining pixels.
int remainder = (int)sourceResolution.height % (int)sourceTile.size.height;
if(remainder) {
iterations++;
}
// Add seem overlaps to the tiles, but save the original tile height for y coordinate calculations.
float sourceTileHeightMinusOverlap = sourceTile.size.height;
sourceTile.size.height += sourceSeemOverlap;
destTile.size.height += kDestSeemOverlap;
for( int y = 0; y < iterations; ++y ) {
@autoreleasepool {
sourceTile.origin.y = y * sourceTileHeightMinusOverlap + sourceSeemOverlap;
destTile.origin.y = destResolution.height - (( y + 1 ) * sourceTileHeightMinusOverlap * imageScale + kDestSeemOverlap);
sourceTileImageRef = CGImageCreateWithImageInRect( sourceImageRef, sourceTile );
if( y == iterations - 1 && remainder ) {
float dify = destTile.size.height;
destTile.size.height = CGImageGetHeight( sourceTileImageRef ) * imageScale;
dify -= destTile.size.height;
destTile.origin.y += dify;
}
CGContextDrawImage( destContext, destTile, sourceTileImageRef );
CGImageRelease( sourceTileImageRef );
}
}
CGImageRef destImageRef = CGBitmapContextCreateImage(destContext);
CGContextRelease(destContext);
if (destImageRef == NULL) {
return image;
}
#if SD_MAC
UIImage *destImage = [[UIImage alloc] initWithCGImage:destImageRef scale:image.scale orientation:kCGImagePropertyOrientationUp];
#else
UIImage *destImage = [[UIImage alloc] initWithCGImage:destImageRef scale:image.scale orientation:image.imageOrientation];
#endif
CGImageRelease(destImageRef);
if (destImage == nil) {
return image;
}
SDImageCopyAssociatedObject(image, destImage);
destImage.sd_isDecoded = YES;
return destImage;
}
}
上述代码中,我们看对原始图片进行了缩放,并且对把原始图片分成多块进行批量解码,并且添加了自动释放池,保证了内存的释放操作,由于操作了底层相关的东西,也进行了手动内存的释放,这点是要注意的。当然子线程解码我们也要控制子线程数量,线程的数量控制最好合CPU核心数保持一致。针对大文件做缓存的图像体积也大,这个时候使用内存映射读取文件优势很大,内存拷贝的量少,拷贝后占用用户内存也不高,文件越大内存映射优势越大。
下面我们再看一下苹果官方提供的降低采样率方案:
swift版
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
return UIImage(cgImage: downsampledImage)
}
对应重写的OC版
//缩略图核心代码
+ (UIImage *)thumbnailWithImageWithoutScale:(UIImage *)image size:(CGSize)asize{
UIImage *newimage;
if (nil == image) {
newimage = nil;
}else{
CGSize oldsize = image.size;
CGRect rect;
if (asize.width/asize.height > oldsize.width/oldsize.height) {
rect.size.width = asize.height*oldsize.width/oldsize.height;
rect.size.height = asize.height;
rect.origin.x = (asize.width - rect.size.width)/2;
rect.origin.y = 0;
}else{
rect.size.width = asize.width;
rect.size.height = asize.width*oldsize.height/oldsize.width;
rect.origin.x = 0;
rect.origin.y = (asize.height - rect.size.height)/2;
}
UIGraphicsBeginImageContext(asize);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(context, [[UIColor clearColor] CGColor]);
UIRectFill(CGRectMake(0, 0, asize.width, asize.height));//clear background
[image drawInRect:rect];
newimage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
}
return newimage;
}
我们在上文的写缩略图生成过程中,已经对图片进行解码操作
用过FastImageCache的同学,都知道其使用了虚拟内存,进行文件映射,进行读写文件。
void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
start:映射开始地址,设置NULL则让系统决定映射开始地址;
length:映射区域的长度,单位是Byte;
prot:映射内存的保护标志,主要是读写相关,是位运算标志;(记得与下面fd对应句柄打开的设置一致)
flags:映射类型,通常是文件和共享类型;
fd:文件句柄;
off_toffset:被映射对象的起点偏移;
我们使用NSData与mmap之间的关系可以获取到映射的数据,如下
+ (id)dataWithContentsOfFile:(NSString *)path options:(NSDataReadingOptions)readOptionsMask error:(NSError **)errorPtr;
针对NSDataReadingOptions的几种类型有如下解释:
NSDataReadingMappedIfSafe 提示显示文件应该映射到虚拟内存,如果可能和安全
NSDataReadingUncached 提示显示文件不应该存储在文件系统缓存。数据读取一次,丢弃,这个选项可以提高性能
NSDataReadingMappedAlways 在如果可能提示映射文件
我们使用NSDataReadingMappedIfSafe,能够保证安全。
我们在SDWebImage中也可以看到相应的代码:
- (nullable NSData *)diskImageDataBySearchingAllPathsForKey:(nullable NSString *)key {
if (!key) {
return nil;
}
NSData *data = [self.diskCache dataForKey:key];
if (data) {
return data;
}
// Addtional cache path for custom pre-load cache
if (self.additionalCachePathBlock) {
NSString *filePath = self.additionalCachePathBlock(key);
if (filePath) {
data = [NSData dataWithContentsOfFile:filePath options:self.config.diskCacheReadingOptions error:nil];
}
}
return data;
}
经过分析,SD中SDImageCache缓存文件中默认是NSDataReadingMappedIfSafe。
说白了就是用mmap把文件映射到用户空间里的虚拟内存,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统VMS才根据缺页加载的机制从磁盘加载对应的数据块到物理内存,再进行渲染。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高。
我们都知道CoreAnimation在图像数据非字节对齐的情况下渲染前会先拷贝一份图像数据。
我们从堆栈中也能看得出系统使用了这个copy_image,进行图像数据拷贝。
字节对齐是为了提高读取的性能。因为处理器读取内存中的数据不是一个一个字节读取的,而是一块一块读取的一般叫做cache lines。如果一个不对齐的数据放在了2个数据块中,那么处理器可能要执行两次内存访问。当这种不对齐的数据非常多的时候,就会影响到读取性能了。这样可能会牺牲一些储存空间,但是提升了内存的读取性能。
我们在使用CGBitmapContextCreate创建绘图上下文的时候,目前我们使用的机器基本上是处理器的是64byte,所以可以指定bytesPerRow为64的整数倍,这样就可以减少这部分是耗时,提升性能。
我们能做的优化远不止于此,不断的探索才是我们的目标,加油!!!骚年