解碼
在SDWebImageDownloaderOperation
的didCompleteWithError
中圖片下載完成,開始解析圖片:
......
dispatch_async(self.coderQueue, ^{
@autoreleasepool {
UIImage *image = SDImageLoaderDecodeImageData(imageData, self.request.URL, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context);
CGSize imageSize = image.size;
if (imageSize.width == 0 || imageSize.height == 0) {
[self callCompletionBlocksWithError:[NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorBadImageData userInfo:@{NSLocalizedDescriptionKey : @"Downloaded image has 0 pixels"}]];
} else {
[self callCompletionBlocksWithImage:image imageData:imageData error:nil finished:YES];
}
[self done];
}
});
......
coderQueue
是個串行隊列:
_coderQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderOperationCoderQueue", DISPATCH_QUEUE_SERIAL);
接下來調用圖片解碼的方法SDImageLoaderDecodeImageData
。
先不考慮動圖的解碼,這里首先獲取圖片的scale
。scale可以在請求圖片的context
中通過SDWebImageContextImageScaleFactor
設置。如果沒有特別指定,則通過SDImageScaleFactorForKey(cacheKey)
方法獲取。cacheKey
默認就是圖片的地址,SDImageScaleFactorForKey
根據圖片地址中是否含有@2x、@3x、%402x、%403x來決定圖片的scale,默認是1。
之后調用SDImageCodersManager
來解析圖片,將NSData轉為UIImage:
image = [[SDImageCodersManager sharedManager] decodedImageWithData:imageData options:coderOptions];
SDImageCodersManager
中有一個數組來保存Decoder:_imageCoders
。_imageCoders的初始化:
_imageCoders = [NSMutableArray arrayWithArray:@[[SDImageIOCoder sharedCoder], [SDImageGIFCoder sharedCoder], [SDImageAPNGCoder sharedCoder]]];
默認定義了三個ImageCoder。SDImageCodersManager在解析圖片時會先詢問ImageCoder是否能解碼該格式的圖片:
if ([coder canDecodeFromData:data]) {
......
SDImageIOCoder
除了WebP之外基本都能解析。
看看SDImageIOCoder
解析圖片的方法:
- (UIImage *)decodedImageWithData:(NSData *)data options:(nullable SDImageCoderOptions *)options {
if (!data) {
return nil;
}
CGFloat scale = 1;
NSNumber *scaleFactor = options[SDImageCoderDecodeScaleFactor];
if (scaleFactor != nil) {
scale = MAX([scaleFactor doubleValue], 1) ;
}
UIImage *image = [[UIImage alloc] initWithData:data scale:scale];
image.sd_imageFormat = [NSData sd_imageFormatForImageData:data];
return image;
}
基本上就是調用了系統利用NSData
創建UIImage的方法。
解壓縮
到這里圖片解碼已經完成,得到了UIImage,但這時UIImage還不是位圖,如果要顯示到UIImageView上面,還要經過一次解壓縮。如果我們直接把這個UIImage傳給UIImageView,那UIImageView會幫我們做這個解壓縮的操作,但有可能會卡主線程。如果我們解壓縮完成之后再傳給UIImageView,那圖片顯示的效率會高很多。所以接下來SDWebImage開始對圖片進行解壓縮。我們也可以設置context中的SDWebImageAvoidDecodeImage
來禁止自動解壓縮。另外如果是動圖也不會進行解壓縮:
BOOL shouldDecode = (options & SDWebImageAvoidDecodeImage) == 0;
if ([image conformsToProtocol:@protocol(SDAnimatedImage)]) {
// `SDAnimatedImage` do not decode
shouldDecode = NO;
} else if (image.sd_isAnimated) {
// animated image do not decode
shouldDecode = NO;
}
......
解壓縮在SDImageCoderHelper
中的decodedImageWithImage
方法進行。首先判斷是否需要進行解壓縮。解壓縮過的或者動圖都不需要解壓。SDWebImage用sd_isDecoded
來標記解壓縮過的圖片。
接下來來到下面這個方法,進行解壓縮的操作:
+ (CGImageRef)CGImageCreateDecoded:(CGImageRef)cgImage orientation:(CGImagePropertyOrientation)orientation {
......
這個方法不是很長,核心的函數就是CGBitmapContextCreate
,這個函數用于創建一個位圖上下文,用來繪制一張寬 width
像素,高 height
像素的位圖:
CGContextRef CGBitmapContextCreate(void *data, size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow, CGColorSpaceRef space, uint32_t bitmapInfo);
我們在這個context上繪制的UIImage會被渲染成位圖。
位圖
位圖就是像素數組
,每個像素有固定的格式,稱為像素格式
,它由以下三個參數決定:
顏色空間
一個像素中每個獨立的顏色分量使用的 bit 數(Bits per component)
透明值(CGBitmapInfo)
顏色空間
顏色空間
是對色彩的一種描述方式,主要有6種:RGB、CMY/CMYK、HSV/HSB、HSI/HSL、Lab、YUV/YCbCr。
比如RGB是通過紅綠藍三原色來描述顏色的顏色空間,R=Red、G=Green、B=Blue。RGB顏色空間下,一個像素由R、G、B三個顏色分量表示,每個分量使用的bit 數就是bpc。若每個分量用8位,那么一個像素共用24位表示,24就是像素的深度
。
最常用的就是RGB和CMYK。同一個色值在不同的顏色空間下表現出來是不同的顏色。
比如我們拿一個RGB格式的圖片去打印,會發現打印出來的顏色和我們在電腦上面看到的有色差,這就是因為顏色空間不同導致的,因為打印機的顏色空間是CMYK。
PBC
然后這個的PBC
就是一個像素中每個獨立的顏色分量
使用的 bit 數。
顏色分量是什么?比如RGB是通過紅綠藍三原色來描述顏色的顏色空間,R=Red、G=Green、B=Blue,也就是紅綠藍。RGB顏色空間下,一個像素就由R、G、B三個顏色分量表示,這個就是顏色分量。每個分量使用的bit 數就是bpc。
如果每個分量用8位,那么一個像素共用24位表示,24就是像素的深度
。再加上如果有透明度信息,那就是8888,一共有32位也就是4個字節,就是我們前面說的iOS中每個像素所占的字節數。
BitmapInfo
然后還有BitmapInfo
。BitmapInfo就是用來說明每個像素中的bits包含了哪些信息。有以下三個方面:
- 是否包含Alpha通道,如果包含 alpha ,那么 alpha 信息所處的位置,在像素的最低有效位,比如 RGBA ,還是最高有效位,比如 ARGB ;
- 如果包含 alpha ,那么每個顏色分量是否已經乘以 alpha 的值,這種做法可以加速圖片的渲染時間,因為它避免了渲染時的額外乘法運算。比如,對于 RGB 顏色空間,用已經乘以 alpha 的數據來渲染圖片,每個像素都可以避免 3 次乘法運算,紅色乘以 alpha ,綠色乘以 alpha 和藍色乘以 alpha
- 顏色分量是否為浮點數
iOS中,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 */
};
-
kCGImageAlphaNone
: 無alpha通道 -
kCGImageAlphaOnly
:無顏色數據,只有alpha通道 -
kCGImageAlphaNoneSkipLast
、kCGImageAlphaNoneSkipFirst
:有alpha通道,但是忽略了alpha值,即透明度不起作用。兩者的區別是alpha通道所在的位置 -
kCGImageAlphaLast
、kCGImageAlphaFirst
:有alpha通道,且alpha通道起作用,兩者的區別是alpha通道所在的位置不同 -
kCGImageAlphaPremultipliedLast
、kCGImageAlphaPremultipliedFirst
:有alpha通道,且alpha通道起作用。這兩個值的區別是alpha通道坐在的位置不同。和kCGImageAlphaLast、kCGImageAlphaFirst的區別是:帶有Premultiplied,在解壓縮的時候就將透明度乘到每個顏色分量上,這樣渲染的時候就不用再處理alpha通道,提高了渲染的效率。
對于位圖來說,像素格式并不是隨意組合的,目前只支持以下有限的 17 種特定組合:
iOS支持的只有8種,除去無顏色空間的和灰色的之外,只剩下RGB的5種,所有iOS 并不支持 CMYK 的顏色空間。
根據蘋果官方文檔的介紹,如果圖片無alpha通道,則應該使用kCGImageAlphaNoneSkipFirst
,如果圖片含alpha通道,則應該使用kCGImageAlphaPremultipliedFirst
。
如果我們拿不在列表里面的像素格式去創建位圖上下文會創建失敗,比如下面這中,bpc為8,但bpp為16:
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder16Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate(NULL, newWidth, newHeight, 8, 0, [self colorSpaceGetDeviceRGB], bitmapInfo);
這是就會得到以下提示:
CGBitmapContextCreate: unsupported parameter combination: set CGBITMAP_CONTEXT_LOG_ERRORS environmental variable to see the details
看看不同的像素格式下,一個像素是被如何表示的:
CGBitmapContextCreate
現在回到系統的CGBitmapContextCreate
函數,看看它的參數分別有什么含義:
CGContextRef CGBitmapContextCreate(void *data, size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow, CGColorSpaceRef space, uint32_t bitmapInfo);
data
:一個指針,它應該指向一塊大小至少為 bytesPerRow * height
字節的內存。如果 為 NULL
,那么系統就會為我們自動分配和釋放所需的內存,所以一般指定 NULL
即可。
**bytesPerRow
**:位圖的每一行使用的字節數,大小至少為 width * bytes per pixel
字節。
這里為什么需要指定每行所占的字節數呢?因為大家可能覺得直接就是寬度乘以每個像素所占的直接數就行了。但是這里涉及到一個CPU緩存行對齊的問題。
緩存行對齊。每次內存和CPU緩存之間交換數據都是固定大小,cache line就表示這個固定的長度,一般為64個字節。如果我們的數據是它的倍速,那數據的讀取效率就會快很多。
當我們指定 0 時,系統不僅會為我們自動計算,而且還會進行Cache Line Alignment
的優化。
比如我們看一個解壓縮完成的圖片:
這里的row bytes不是540 * 4 = 2160,而是2176,而且2176剛好能被64整除。
space
就是顏色空間,前面提到過了,這里就是RGB,因為iOS只支持RGB。
然后就是bitmapInfo
。這個參數除了要指定alpha的信息外,就是前面提到的ARGB還是RGBA,另外還需要指定字節順序
。
字節順序分為兩種:小端模式和大端模式。它是由枚舉值 CGImageByteOrderInfo
來表示的:
typedef CF_ENUM(uint32_t, CGImageByteOrderInfo) {
kCGImageByteOrderMask = 0x7000,
kCGImageByteOrderDefault = (0 << 12),
kCGImageByteOrder16Little = (1 << 12),
kCGImageByteOrder32Little = (2 << 12),
kCGImageByteOrder16Big = (3 << 12),
kCGImageByteOrder32Big = (4 << 12)
} CG_AVAILABLE_STARTING(10.0, 2.0);
在iOS中使用的是小端模式,在macOS中使用的是大端模式,為了兼容,使用kCGBitmapByteOrder32Host
,32位字節順序,該宏在不同的平臺上面會自動組裝換成不同的模式。32是指數據以32bit為單位(字節順序)。字節順序也以32bit為單位排序。
#ifdef __BIG_ENDIAN__
# define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Big
# define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Big
#else /* Little endian. */
# define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Little
# define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Little
#endif
下面是SD解壓縮圖片的源碼,拿到位圖的上下文CGContextRef之后,調用CGContextDrawImage
進行繪制,然后就可以通過CGBitmapContextCreateImage
拿到位圖。
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate(NULL, newWidth, newHeight, 8, 0, [self colorSpaceGetDeviceRGB], bitmapInfo);
if (!context) {
return NULL;
}
// Apply transform
CGAffineTransform transform = SDCGContextTransformFromOrientation(orientation, CGSizeMake(newWidth, newHeight));
CGContextConcatCTM(context, transform);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage); // The rect is bounding box of CGImage, don't swap width & height
CGImageRef newImageRef = CGBitmapContextCreateImage(context);
CGContextRelease(context);
到這里圖片的解壓縮就結束了。
再回到SDWebImage,這里圖片解壓縮結束。
如果再在context中設置了SDWebImageScaleDownLargeImages
,那在解壓縮的時候就要做進一步縮放處理。一般來說SDWebImage會保持圖片的原始尺寸,但如果圖片過大且設置了SDWebImageScaleDownLargeImages
,則會對圖片進行縮小。這時候會邊解壓縮邊縮小。具體的實現在下面這個方法:
?```objectivec
- (UIImage *)decodedAndScaledDownImageWithImage:(UIImage *)image limitBytes:(NSUInteger)bytes {
......
`limitBytes`可以限制圖片的大小,如果傳入0則使用默認值,就是解壓縮后不超過60MB。這里傳入的就是0。
先看看下面這段代碼:
```objectivec
......
CGFloat destTotalPixels;
CGFloat tileTotalPixels;
if (bytes > 0) {
destTotalPixels = bytes / kBytesPerPixel;
tileTotalPixels = destTotalPixels / 3;
} else {
destTotalPixels = kDestTotalPixels;
tileTotalPixels = kTileTotalPixels;
}
......
這里圖片最大限制為60MB,每個像素占4個字節,1MB就有1024 * 1024個字節,那1MB有1024 * 1024 / 4個像素,所以kDestTotalPixels
為1024 * 1024 / 4 * 60,即輸出圖片的像素。
接著根據目標總像素和原圖像素計算目標圖片的尺寸:
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 = (int)(sourceResolution.width * imageScale);
destResolution.height = (int)(sourceResolution.height * imageScale);
然后調用前面提到的CGBitmapContextCreate
創建位圖上下文。
CGContextSetInterpolationQuality(destContext, kCGInterpolationHigh);
設置圖像插值的質量為高。
接下來開始圖片縮小,算法的基本流程如下:
基本的思想就是每次壓縮一小部分,然后繪制到輸出的上下文中。
每次讀取的大小定義在kSourceImageTileSizeMB
中:
/*
* Defines the maximum size in MB of a tile used to decode image when the flag `SDWebImageScaleDownLargeImages` is set
* Suggested value for iPad1 and iPhone 3GS: 20.
* Suggested value for iPad2 and iPhone 4: 40.
* Suggested value for iPhone 3G and iPod 2 and earlier devices: 10.
*/
static const CGFloat kSourceImageTileSizeMB = 20.f;
知道每次讀取的大小,就可以計算每次讀取的像素,接著就可以得到讀取的矩形區域:
// Now define the size of the rectangle to be used for the
// incremental blits 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 opertion by achnoring 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 = (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;
這里為了防止有空隙,每次會重疊兩個像素:
static const CGFloat kDestSeemOverlap = 2.0f; // the numbers of pixels to overlap the seems where tiles meet.
......
// 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 ouput image.
float sourceSeemOverlap = (int)((kDestSeemOverlap/destResolution.height)*sourceResolution.height);
......
// 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 );
}
}
主要是兩個關鍵函數:CGImageCreateWithImageInRect( sourceImageRef, sourceTile )
、CGContextDrawImage( destContext, destTile, sourceTileImageRef )
。這里因為坐標系的原因,destTile和sourceTile起始點是相反的。
之后就是調用CGBitmapContextCreateImage(destContext)
來得到位圖,再創建UIImage即可。
到這里SDWebImageDownloaderOperation
中的圖片解壓縮和縮小就結束了。這時結果被返回到SDWebImageManager
。
SDWebImageManager
在寫入緩存之前,會對圖片做進一步變換處理。我們可以通過context的SDWebImageContextImageTransformer
來指定圖片的變換,包括修改圖片大小、圓角剪裁、模糊處理等等。SDWebImage提供了一些默認的變換:
SDImageRoundCornerTransformer
SDImageResizingTransformer
SDImageCroppingTransformer
SDImageFlippingTransformer
SDImageRotationTransformer
SDImageTintTransformer
SDImageBlurTransformer
SDImageFilterTransformer`
還可以用`SDImagePipelineTransformer`組合多個變換。
變換完成后,SDWebImage要把轉換后的UIImage轉為NSData并寫入緩存,此時需要SDImageCodersManager對圖片進行編碼。先看看SDImageIOCoder如何對圖片進行編碼。
在SDImageCoder的encodedDataWithImage方法中:
首先調用以下方法的得到imageDestination:
CGImageDestinationRef CGImageDestinationCreateWithData(CFMutableDataRef data, CFStringRef type, size_t count, CFDictionaryRef options);
參數注釋:
data
:The data object to write to. For more information on data objects, see CFData and Data Objects.type
:The uniform type identifier (UTI) of the resulting image file. See Uniform Type Identifiers Overview for a list of system-declared and third-party UTIs.count
:The number of images (not including thumbnail images) that the image file will contain.options
:Reserved for future use. Pass NULL.
接著設置圖片的方向和壓縮質量,
NSMutableDictionary *properties = [NSMutableDictionary dictionary];
#if SD_UIKIT || SD_WATCH
CGImagePropertyOrientation exifOrientation = [SDImageCoderHelper exifOrientationFromImageOrientation:image.imageOrientation];
#else
CGImagePropertyOrientation exifOrientation = kCGImagePropertyOrientationUp;
#endif
properties[(__bridge NSString *)kCGImagePropertyOrientation] = @(exifOrientation);
double compressionQuality = 1;
if (options[SDImageCoderEncodeCompressionQuality]) {
compressionQuality = [options[SDImageCoderEncodeCompressionQuality] doubleValue];
}
properties[(__bridge NSString *)kCGImageDestinationLossyCompressionQuality] = @(compressionQuality);
最后調用CGImageDestinationAddImage
壓縮圖片:
CGImageDestinationAddImage(imageDestination, image.CGImage, (__bridge CFDictionaryRef)properties);
到這里圖片轉為NSData就結束了。
接下來看看動圖的解析
這次是SDImageGIFCoder