SDWebImage源碼刨根問底

前言:

SDWebImage是iOS中一款處理圖片的框架, 使用它提供的方法, 一句話就能讓UIImageView,自動去加載并顯示網絡圖片,將圖片緩存到內存或磁盤緩存,正好有閱讀開源項目的計劃,于是首選SDWebImage,本文向大家分享項目整體框架以及最基本相關的GCD與Block等相關知識和基本方法使用,以及設計的思想。

源碼來源:SDWebImage

源碼描述:

SDWebImage圖片緩存框架,最常用的是使用UItableViewCell使用UIImageView的來下載的圖片并緩存,功能官方的解釋是這樣的

This library provides a category for UIImageView with support for remote images coming from the web.*
SDImageView提供UIImageView、UIImage等分類支持從遠程服務器下載并緩存圖片

提供的功能如下:

AnUIImageViewcategory adding web image and cache management to the Cocoa Touch framework 一個帶有管理網絡圖片下載和緩存的UIImageView類別

An asynchronous image downloader 一個異步圖片下載器

An asynchronous memory + disk image caching with automatic cache expiration handling 一個提供內存和磁盤緩存圖片,并且能夠自動清理過期的緩存

Animated GIF support (支持GIF圖片)

WebP format support 支持WebP

A background image decompression 圖片后臺解壓圖片(空間換時間,這種做法會使內存激增,所以SD中含有**圖片是否解壓的參數)

A guarantee that the same URL won't be downloaded several times 保證一個URL不會下載多次

A guarantee that bogus URLs won't be retried again and again 保證黑名單的URL不會返回加載

A guarantee that main thread will never be blocked 保證主線程不會堵塞

Performances! 高性能

Use GCD and ARC 使用GCD和ARC

Arm64 support 支持Arm64

SDWebImage項目圖

項目圖

Cache:

SDImageCache 圖片緩存類

SDImageCache功能描述:

SDImageCache maintains a memory cache and an optional disk cache. Disk cache write operations are performed
asynchronous so it doesn’t add unnecessary latency to the UI.
<br />SDImageCache 維護一個內存緩存以及一個"可選"的磁盤緩存。磁盤緩存的寫入操作是異步執行(緩存任務加入到串行隊列),因此不會造成 UI 的延遲

緩存選項

在緩存的過程中,程序會根據設置的不同的緩存選項,而執行不同的操作。下載選項由枚舉SDImageCacheType定義,具體如下

typedef NS_ENUM(NSInteger, SDImageCacheType) {
/**
 * The image wasn't available the SDWebImage caches, but was downloaded from the web.
 * 不使用 SDWebImage 緩存,從網絡下載
 */
SDImageCacheTypeNone,
/**
 * The image was obtained from the disk cache.
 * 磁盤緩存圖像
 */
SDImageCacheTypeDisk,
/**
 * The image was obtained from the memory cache.
 * 內存緩存圖像
 */
SDImageCacheTypeMemory
};

查詢圖片

這些選項主要涉及到queryDiskCacheForKey方法使用內存緩存或磁盤緩存查詢數據

 /**
 *  從磁盤查詢數據
 *
 *  @param key       key
 *  @param doneBlock block回調
 *
 *  @return return value description
 */
- (NSOperation *)queryDiskCacheForKey:(NSString *)key done:(SDWebImageQueryCompletedBlock)doneBlock {
    if (!doneBlock) {
        return nil;
    }
    // 如果key為空
    if (!key) {
        doneBlock(nil, SDImageCacheTypeNone);
        return nil;
    }

    // First check the in-memory cache...
    // 首先查詢內存
    UIImage *image = [self imageFromMemoryCacheForKey:key];
    if (image) {
        doneBlock(image, SDImageCacheTypeMemory);
        return nil;
    }

    NSOperation *operation = [NSOperation new];

    dispatch_async(self.ioQueue, ^{  //  查詢磁盤緩存,將緩存操作作為一個任務放入ioQueue
        if (operation.isCancelled) {
            return;
        }

        @autoreleasepool {
            // 磁盤查詢
            UIImage *diskImage = [self diskImageForKey:key];
            // 如果圖片存在 并且要緩存到內存中 則將圖片緩存到內存
            if (diskImage) {
                CGFloat cost = diskImage.size.height * diskImage.size.width * diskImage.scale * diskImage.scale;
                [self.memCache setObject:diskImage forKey:key cost:cost];
            }
             // 回調
            dispatch_async(dispatch_get_main_queue(), ^{
                doneBlock(diskImage, SDImageCacheTypeDisk);
            });
        }
    });

    return operation;
}

如果使用SDImageCacheTypeDisk(磁盤緩存)查詢的過程是這樣的,傳入的key,此時通過ImageFromMemoryCacheForKey: @property (strong, nonatomic) NSCache *memCache(蘋果官方提供的緩存類NSCache對象); memCache內存緩存中查找,如果有,完成回調;如果沒有,在到磁盤緩存中去找對應的圖片,此時的傳入的key是沒有經過md5加密的,經過MD5加密后,磁盤緩存路徑中去查找,找到之后先將圖片緩存在內存中,然后在把圖片返回:,具體過程

queryDiskCacheForKey方法過程

在diskImageForKey方法會處理完整的圖片數據,對其進行適當的縮放與解壓操作,以提供給完成回調使用。

NSCache

NSCache

An NSCache object is a collection-like container, or cache, that stores key-value pairs, similar to the NSDictionary class.

NSCache 用法與 NSMutableDictionary 的用法很相似,是以 key-value 的形式進行存儲,通常會使用NSCache作為臨時數據和昂貴的對象存儲,重用這些對象可以優化性能。

  • NSCache 類使用了自動刪除策略,當內存緊張時系統拋出 Received memory warning.通知,此時在添加數據時,數據為空。
  • NSCache可以設置對象上限限制,通過countLimit與 totalCostLimit兩個屬性來限制cache的數量或者限制cost最大開銷。當緩存對象的數量和cost總和超過這些尺度時,NSCache會自動釋放部分緩存,釋放執行順序符合LRU(近期最少使用算法),如下圖所示
再次使用Key為0、2后對象釋放情況
添加后不使用其他對象釋放情況
  • NSCache是線程安全的,在多線程操作中,可以在不同的線程中添加、刪除和查詢緩存中的對象,不需要對Cache加鎖。
  • NSCache的對象并不會對Key進行Copy拷貝操作 而是strong強引用,對象不需要實現NSCopying協議,NSCache也不會像NSDictionary一樣復制對象。

緩存操作方式

創建串行隊列

 // Create IO serial queue
    // 磁盤讀寫隊列,串行隊列,任務一個執行完畢才執行下一個,所以不會出現一個文件同時被讀取和寫入的情況, 所以用 dispatch_async 而不必使用 disathc_barrier_async
    _ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);

將緩存操作作為一個任務放入ioQueue串行隊列中,開啟線程異步執行任務

 dispatch_async(self.ioQueue, ^{  //  查詢磁盤緩存,將緩存操作作為一個任務放入ioQueue

  ....
}  

ioQueue還作用在存儲圖片,在storeImage方法中異步存儲圖片

存儲圖片

當下載完圖片后,會先將圖片保存到 NSCache 中,并把圖片像素(Width × height × scale2)大小作為該對象的 cost 值,同時如果需要保存到硬盤,會先判斷圖片的格式,PNG 或者 JPEG,并保存對應的 NSData 到緩存路徑中,文件名為 URL 的 MD5 值:

/**
* 緩存圖片
*
* @param image 圖片
* @param recalculate 是否重新計算
* @param imageData imageData
* @param key 緩存的key
* @param toDisk 是否緩存到磁盤
*/
- (void)storeImage:(UIImage *)image recalculateFromImage:(BOOL)recalculate imageData:(NSData *)imageData forKey:(NSString *)key toDisk:(BOOL)toDisk {
if (!image || !key) {
return;
}

        // 緩存到內存
        [self.memCache setObject:image forKey:key cost:image.size.height * image.size.width * image.scale * image.scale];

        if (toDisk) {
            dispatch_async(self.ioQueue, ^{
                NSData *data = imageData;
                
                // if the image is a PNG
                if (image && (recalculate || !data)) {
    #if TARGET_OS_IPHONE
                    
                    //我們需要確定該圖像是PNG或JPEG格式
                                    // PNG圖像更容易檢測到,因為它們具有獨特的簽名(http://www.w3.org/TR/PNG-Structure.html)
                                    //第一個8字節的PNG文件始終包含以下(十進制)值:
                                    //13780787113 102610
                    
                     //我們假設圖像PNG,在例中為imageData是零(即,如果想直接保存一個UIImage)
                                    //我們會考慮它PNG以免丟失透明度
                    // We need to determine if the image is a PNG or a JPEG
                    // PNGs are easier to detect because they have a unique signature (http://www.w3.org/TR/PNG-Structure.html)
                    // The first eight bytes of a PNG file always contain the following (decimal) values:
                    // 137 80 78 71 13 10 26 10

                    // We assume the image is PNG, in case the imageData is nil (i.e. if trying to save a UIImage directly),
                    // we will consider it PNG to avoid loosing the transparency
                    BOOL imageIsPng = YES;

                    // But if we have an image data, we will look at the preffix
                    // 但如果我們有一個圖像數據,我們將看看前綴,png
                    if ([imageData length] >= [kPNGSignatureData length]) {// 將UIImage轉化為NSData,(1)這里使用的是UIImagePNGRepresentation(返回指定的PNG格式的圖片數據)或UIImageJPEGRepresentation(返回指定的JPEG格式的圖片數據)這種方式的好處如果PNG/JPEG數據不能正確生成返回nil,可以進行校驗  (2)第二種方式是通過[NSData dataWithContentsOfFile:image] 這種方式讀取圖片數據,圖片的部分壞掉,并不能校驗

                        imageIsPng = ImageDataHasPNGPreffix(imageData);
                    }

                    if (imageIsPng) {
                        // return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
                        data = UIImagePNGRepresentation(image);
                    }
                    else {
                        data = UIImageJPEGRepresentation(image, (CGFloat)1.0);
                    }
    #else
                    data = [NSBitmapImageRep representationOfImageRepsInArray:image.representations usingType: NSJPEGFileType properties:nil];
    #endif
                }

                if (data) {
                    if (![_fileManager fileExistsAtPath:_diskCachePath]) {
                        [_fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
                    }

                    [_fileManager createFileAtPath:[self defaultCachePathForKey:key] contents:data attributes:nil];
                }
            });
        }
    }

移除圖片

/**
 *  移除文件
 *
 *  @param key        key
 *  @param fromDisk   是否從磁盤移除
 *  @param completion block回調
 */    
- (void)removeImageForKey:(NSString *)key fromDisk:(BOOL)fromDisk withCompletion:(SDWebImageNoParamsBlock)completion {
    
    if (key == nil) {
        return;
    }
     // 如果有緩存 則從緩存中移除
    [self.memCache removeObjectForKey:key];
    // 從磁盤移除 異步操作
    if (fromDisk) {
        dispatch_async(self.ioQueue, ^{
            
            // 直接刪除文件
            [_fileManager removeItemAtPath:[self defaultCachePathForKey:key] error:nil];
            
            if (completion) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    completion();
                });
            }
        });
    } else if (completion){
        completion();
    }
    
}

清理圖片

通過設置

  • UIApplicationDidReceiveMemoryWarningNotification  通知來釋放內存 
  • UIApplicationWillTerminateNotification    通知清理磁盤
  • UIApplicationDidEnterBackgroundNotification  通知進入后臺清理磁盤 
 // -接收到內存警告通知-清理內存操作 - clearMemory
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(clearMemory)
                                                 name:UIApplicationDidReceiveMemoryWarningNotification
                                               object:nil];

    // -應用程序將要終止通知-執行清理磁盤操作 - cleanDisk
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(cleanDisk)
                                                 name:UIApplicationWillTerminateNotification
                                               object:nil];

    // - 進入后臺通知 - 后臺清理磁盤 - backgroundCleanDisk
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(backgroundCleanDisk)
                                                 name:UIApplicationDidEnterBackgroundNotification
                                               object:nil];

SDWebCache會在系統發出內存警告或進入后臺通知,清理磁盤緩存:

  • 刪除早于過期日期的文件(默認7天過期),可以通過maxCacheAge屬性重新設置緩存時間
  • 如果剩余磁盤緩存空間超出最大限額(maxCacheSize),再次執行清理操作,刪除最早的文件(按照文件最后修改時間的逆序,以每次一半的遞歸來移除那些過早的文件,直到緩存的實際大小小于我們設置的最大使用空間,可以通過修改 maxCacheSize 來改變最大緩存大小。)
// 清理過期的緩存圖片
- (void)cleanDiskWithCompletionBlock:(SDWebImageNoParamsBlock)completionBlock {
    dispatch_async(self.ioQueue, ^{
        
        // 獲取存儲路徑
        NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
        // 獲取相關屬性數組
        NSArray *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey];

        // This enumerator prefetches useful properties for our cache files.
        // 此枚舉器預取緩存文件對我們有用的特性。  預取緩存文件中有用的屬性
        NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
                                                   includingPropertiesForKeys:resourceKeys
                                                                      options:NSDirectoryEnumerationSkipsHiddenFiles
                                                                 errorHandler:NULL];

        // 計算過期日期
        NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
        NSMutableDictionary *cacheFiles = [NSMutableDictionary dictionary];
        NSUInteger currentCacheSize = 0;

        // Enumerate all of the files in the cache directory.  This loop has two purposes:
        // 遍歷緩存路徑中的所有文件,此循環要實現兩個目的
        //
        //  1. Removing files that are older than the expiration date.
        //     刪除早于過期日期的文件
        //  2. Storing file attributes for the size-based cleanup pass.
        //     保存文件屬性以計算磁盤緩存占用空間
        //
        NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
        for (NSURL *fileURL in fileEnumerator) {
            NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:NULL];

            // Skip directories. 跳過目錄
            if ([resourceValues[NSURLIsDirectoryKey] boolValue]) {
                continue;
            }

            // Remove files that are older than the expiration date; 記錄要刪除的過期文件
            NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
            if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
                [urlsToDelete addObject:fileURL];
                continue;
            }

            // Store a reference to this file and account for its total size.
            // 保存文件引用,以計算總大小
            NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
            currentCacheSize += [totalAllocatedSize unsignedIntegerValue];
            [cacheFiles setObject:resourceValues forKey:fileURL];
        }
        
        // 刪除過期的文件
        for (NSURL *fileURL in urlsToDelete) {
            [_fileManager removeItemAtURL:fileURL error:nil];
        }

        // If our remaining disk cache exceeds a configured maximum size, perform a second
        // size-based cleanup pass.  We delete the oldest files first.
        // 如果剩余磁盤緩存空間超出最大限額,再次執行清理操作,刪除最早的文件
        if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
            // Target half of our maximum cache size for this cleanup pass.
            const NSUInteger desiredCacheSize = self.maxCacheSize / 2;

            // Sort the remaining cache files by their last modification time (oldest first).
            NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
                                                            usingComparator:^NSComparisonResult(id obj1, id obj2) {
                                                                return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
                                                            }];

            // Delete files until we fall below our desired cache size.
            // 循環依次刪除文件,直到低于期望的緩存限額
            for (NSURL *fileURL in sortedFiles) {
                if ([_fileManager removeItemAtURL:fileURL error:nil]) {
                    NSDictionary *resourceValues = cacheFiles[fileURL];
                    NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
                    currentCacheSize -= [totalAllocatedSize unsignedIntegerValue];

                    if (currentCacheSize < desiredCacheSize) {
                        break;
                    }
                }
            }
        }
        if (completionBlock) {
            dispatch_async(dispatch_get_main_queue(), ^{
                completionBlock();
            });
        }
    });
}

Dowloader:

  • SDWebImageDownloader:管理著緩存SDImageCache和下載SDWebImageDownloader類相應設置下載對象。我們在這個類可以得到關于下載和緩存的相關狀態,該類有12個關于管理的SDWebImageOptions操作類型。
  • SDWebImageDownloaderOperation:是一個繼承自NSOperation并遵循SDWebImageOperation協議的類。

*/

SDWebImageDownloader 圖片下載器

SDImageCache功能描述:

Asynchronous downloader dedicated and optimized for image loading.
<br />專為加載圖像設計并優化的異步下載器

下載選項

在執行下載過程中,程序會根據設置的不同的緩下載選項,而對NSMutableURLRequest執行不同的操作;下載選項和執行順序由枚舉SDWebImageDownloaderOptions和SDWebImageDownloaderExecutionOrder組成,具體如下

typedef NS_OPTIONS(NSUInteger, SDWebImageDownloaderOptions) {
    /// 低優先權
    SDWebImageDownloaderLowPriority = 1 << 0,
     /// 下載顯示進度
    SDWebImageDownloaderProgressiveDownload = 1 << 1,

    /**
     * By default, request prevent the of NSURLCache. With this flag, NSURLCache
     * is used with default policies.
     * 默認情況下,請求不使用 NSURLCache。使用此標記,會使用 NSURLCache 和默認緩存策略
     */
    SDWebImageDownloaderUseNSURLCache = 1 << 2,

    /**
     * Call completion block with nil image/imageData if the image was read from NSURLCache
     * 如果圖像是從 NSURLCache 讀取的,則調用 completion block 時,image/imageData 傳入 nil
     *
     * (to be combined with `SDWebImageDownloaderUseNSURLCache`).
     * (此標記要和 `SDWebImageDownloaderUseNSURLCache` 組合使用)
     */

    SDWebImageDownloaderIgnoreCachedResponse = 1 << 3,
    /**
     * In iOS 4+, continue the download of the image if the app goes to background. This is achieved by asking the system for
     * 在 iOS 4+,當 App 進入后臺后仍然會繼續下載圖像。這是向系統請求額外的后臺時間以保證下載請求完成的
     *
     * extra time in background to let the request finish. If the background task expires the operation will be cancelled.
     * 如果后臺任務過期,請求將會被取消
     */

    SDWebImageDownloaderContinueInBackground = 1 << 4,

    /**
     * Handles cookies stored in NSHTTPCookieStore by setting
     * 通過設置
     * NSMutableURLRequest.HTTPShouldHandleCookies = YES;
     * 處理保存在 NSHTTPCookieStore 中的 cookies,通過設置 NSMutableURLRequest.HTTPShouldHandleCookies = YES 來處理存儲在 NSHTTPCookieStore 的cookies
     */
    SDWebImageDownloaderHandleCookies = 1 << 5,

    /**
     * Enable to allow untrusted SSL ceriticates.
     * 允許不信任的 SSL 證書
     *
     * Useful for testing purposes. Use with caution in production.
     * 可以出于測試目的使用,在正式產品中慎用
     */
    SDWebImageDownloaderAllowInvalidSSLCertificates = 1 << 6,

    /**
     * Put the image in the high priority queue.
     * 將圖像放入高優先級隊列
     */
    SDWebImageDownloaderHighPriority = 1 << 7,
    

};
// 下載執行順序:1.FIFO先進先出,隊列方式 2.LIFO 后進先出堆棧執行
typedef NS_ENUM(NSInteger, SDWebImageDownloaderExecutionOrder) {
    /**
     * Default value. All download operations will execute in queue style (first-in-first-out).
     * 默認值。所有下載操作將按照隊列的先進先出方式執行
     */
    SDWebImageDownloaderFIFOExecutionOrder,

    /**
     * All download operations will execute in stack style (last-in-first-out).
     * 所有下載操作將按照堆棧的后進先出方式執行
     */
    SDWebImageDownloaderLIFOExecutionOrder
};

下載block

當前下載進度、完成和設置過濾請求頭的相關信息,都是由block來呈現
/**
* 下載進度block
*
* @param receivedSize 已收到數據大小
* @param expectedSize 應該受到數據大小
/
typedef void(^SDWebImageDownloaderProgressBlock)(NSInteger receivedSize, NSInteger expectedSize);
/
*
* 下載完成block
*
* @param image 下載好的圖片
* @param data 下載的數據
* @param error 錯誤信息
* @param finished 是否完成
*/
typedef void(^SDWebImageDownloaderCompletedBlock)(UIImage *image, NSData data, NSError error, BOOL finished);
/

* 過濾請求頭部信息block
*
* @param url URL
* @param headers 請求頭部信息
*
* @return return value description
*/
typedef NSDictionary *(^SDWebImageDownloaderHeadersFilterBlock)(NSURL *url, NSDictionary *headers);

相關實例變量

設置圖片的下載并發數量、當前下載的數量,將下載操作添加到隊列和設置休眠狀態都是放在是一個NSOperationQueue操作隊列中來完成的

 /**
 *  設置并發下載數,默認為6
 */
@property (assign, nonatomic) NSInteger maxConcurrentDownloads;

/**
 * Shows the current amount of downloads that still need to be downloaded
 * <br />顯示仍需要下載的數量
 */

@property (readonly, nonatomic) NSUInteger currentDownloadCount;


/**
 *  The timeout value (in seconds) for the download operation. Default: 15.0.
 * <br />下載操作的超時時長(秒),默認:15秒
 */
@property (assign, nonatomic) NSTimeInterval downloadTimeout;


/**
 * Changes download operations execution order. Default value is `SDWebImageDownloaderFIFOExecutionOrder`.
 * <br />修改下載操作執行順序,默認值是 `SDWebImageDownloaderFIFOExecutionOrder`(先進先出)
 */
@property (assign, nonatomic) SDWebImageDownloaderExecutionOrder executionOrder;


// 下載操作隊列
@property (strong, nonatomic) NSOperationQueue *downloadQueue;
// 最后添加的操作 先進后出順序順序
@property (weak, nonatomic) NSOperation *lastAddedOperation;
// 圖片下載類
@property (assign, nonatomic) Class operationClass;
// URL回調字典 以URL為key,你裝有URL下載的進度block和完成block的數組為value (相當于下載操作管理器)
@property (strong, nonatomic) NSMutableDictionary *URLCallbacks;
// HTTP請求頭
@property (strong, nonatomic) NSMutableDictionary *HTTPHeaders;
// This queue is used to serialize the handling of the network responses of all the download operation in a single queue
// barrierQueue是一個并行隊列,在一個單一隊列中順序處理所有下載操作的網絡響應
@property (SDDispatchQueueSetterSementics, nonatomic) dispatch_queue_t barrierQueue

URLCallbacks: URL回調字典 以URL為key,裝有URL下載的進度block和完成block的數組為value,由于創建一個barrierQueue 并行隊列,所有下載操作的網絡響應序列化處理是放在一個自定義的并行調度隊列中來處理的,可能會有多個線程同時操作URLCallbacks屬性,其聲明及定義如下:

      /** 并行的處理所有下載操作的網絡響應
     第一個參數:隊列名稱
     第二個參數:隊列類型,NULL 則創建串行隊列處理方式,DISPATCH_QUEUE_CONCURRENT則是并行隊列處理方式
     _barrierQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderBarrierQueue", DISPATCH_QUEUE_CONCURRENT);

為了保證URLCallbacks添加和刪除線程安全性,SDWebImageDownloader將這些操作作為一個個任務放到barrierQueue隊列中,并設置屏障(dispatch_barrier_sync)來確保同一時間只有一個線程操作URLCallbacks屬性

// dispatch_barrier_sync 保證同一時間只有一個線程操作 URLCallbacks
dispatch_barrier_sync(self.barrierQueue, ^{
    // 是否第一次操作
    BOOL first = NO;
    if (!self.URLCallbacks[url]) {
        self.URLCallbacks[url] = [NSMutableArray new];
        first = YES;
    }

    // Handle single download of simultaneous download request for the same URL
    // 處理 同一個URL的單個下載
    NSMutableArray *callbacksForURL = self.URLCallbacks[url];
    NSMutableDictionary *callbacks = [NSMutableDictionary new];
    // 將 進度block和完成block賦值
    if (progressBlock) callbacks[kProgressCallbackKey] = [progressBlock copy];
    if (completedBlock) callbacks[kCompletedCallbackKey] = [completedBlock copy];
    [callbacksForURL addObject:callbacks];
    // 已URL為key進行賦值
    self.URLCallbacks[url] = callbacksForURL;
    
    // 如果是第一次下載 則回調
    if (first) {
        // 通過這個回調,可以實時獲取下載進度以及是下載完成情況
        createCallback();
    }
});

downloadImageWithURL: options: progress: completed:方法是該類的核心,調用了addProgressCallback來將請求的信息存入管理器(URLCallbacks)中,同時在創建回調的block中創建新的操作,配置之后將其放入downloadQueue操作隊列中,最后方法返回新創建的操作,返回一個遵循SDWebImageOperation協議的對象,SDWebImageOperation協議定義了一個cancel取消下載的方法;
SDWebImageDownloaderOperation(下載操作類)繼承自NSOperation并遵循SDWebImageOperation協議的類。

@interface SDWebImageDownloaderOperation : NSOperation <SDWebImageOperation>

.
- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url options:(SDWebImageDownloaderOptions)options progress:(SDWebImageDownloaderProgressBlock)progressBlock completed:(SDWebImageDownloaderCompletedBlock)completedBlock {
// 下載對象 block中要修改的變量需要__block修飾
__block SDWebImageDownloaderOperation *operation;
// weak self 防止retain cycle
__weak SDWebImageDownloader *wself = self; // 下面有幾行代碼中 有使用SDWebImageDownloader對象賦值給SDWebImageDownloader 對象,設置弱引用防止循環引用,
// 添加設置回調 調用另一方法,在創建回調的block中創建新的操作,配置之后將其放入downloadQueue操作隊列中。
[self addProgressCallback:progressBlock andCompletedBlock:completedBlock forURL:url createCallback:^{
// 設置延時時長 為 15.0秒
NSTimeInterval timeoutInterval = wself.downloadTimeout;
if (timeoutInterval == 0.0) {
timeoutInterval = 15.0;
}

        // In order to prevent from potential duplicate caching (NSURLCache + SDImageCache) we disable the cache for image requests if told otherwise
        // 為防止重復緩存(NSURLCache + SDImageCache),如果設置了 SDWebImageDownloaderUseNSURLCache(系統自帶的使用 NSURLCache 和默認緩存策略),則禁用 SDImageCache
        NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url cachePolicy:(options & SDWebImageDownloaderUseNSURLCache ? NSURLRequestUseProtocolCachePolicy : NSURLRequestReloadIgnoringLocalCacheData) timeoutInterval:timeoutInterval];
        // 是否處理cookies
        request.HTTPShouldHandleCookies = (options & SDWebImageDownloaderHandleCookies);
        request.HTTPShouldUsePipelining = YES;
        if (wself.headersFilter) {
            request.allHTTPHeaderFields = wself.headersFilter(url, [wself.HTTPHeaders copy]);
        }
        else {
            request.allHTTPHeaderFields = wself.HTTPHeaders;
        }
        // 創建下載對象 在這里是 SDWebImageDownloaderOperation 類
        operation = [[wself.operationClass alloc] initWithRequest:request
                                                          options:options
                                                         progress:^(NSInteger receivedSize, NSInteger expectedSize) {
                                                             SDWebImageDownloader *sself = wself;
                                                             if (!sself) return;
                                                             // URL回調數組
                                                             NSArray *callbacksForURL = [sself callbacksForURL:url];
                                                             for (NSDictionary *callbacks in callbacksForURL) {
                                                                 SDWebImageDownloaderProgressBlock callback = callbacks[kProgressCallbackKey];
                                                                 if (callback) callback(receivedSize, expectedSize);
                                                             }
                                                         }
                                                        completed:^(UIImage *image, NSData *data, NSError *error, BOOL finished) {
                                                            SDWebImageDownloader *sself = wself;
                                                            if (!sself) return;
                                                            NSArray *callbacksForURL = [sself callbacksForURL:url];
                                                            if (finished) {
                                                                [sself removeCallbacksForURL:url];
                                                            }
                                                            for (NSDictionary *callbacks in callbacksForURL) {
                                                               
                                                                SDWebImageDownloaderCompletedBlock callback = callbacks[kCompletedCallbackKey];
                                                                if (callback) callback(image, data, error, finished);
                                                            }
                                                        }
                                                        cancelled:^{
                                                            SDWebImageDownloader *sself = wself;
                                                            if (!sself) return;
                                                            // 如果下載完成 則從回調數組里面刪除
                                                            [sself removeCallbacksForURL:url];
                                                        }];
        
        // 如果設置了用戶名 & 口令
        if (wself.username && wself.password) {
            // 設置 https 訪問時身份驗證使用的憑據
            operation.credential = [NSURLCredential credentialWithUser:wself.username password:wself.password persistence:NSURLCredentialPersistenceForSession];
        }
        // 設置隊列的優先級
        if (options & SDWebImageDownloaderHighPriority) {
            operation.queuePriority = NSOperationQueuePriorityHigh;
        } else if (options & SDWebImageDownloaderLowPriority) {
            operation.queuePriority = NSOperationQueuePriorityLow;
        }
        // 將下載操作添加到下載隊列中
        [wself.downloadQueue addOperation:operation];
        // 如果是后進先出操作順序 則將該操作置為最后一個操作
        if (wself.executionOrder == SDWebImageDownloaderLIFOExecutionOrder) {
            // Emulate LIFO execution order by systematically adding new operations as last operation's dependency
            [wself.lastAddedOperation addDependency:operation];
            wself.lastAddedOperation = operation;
        }
    }];

    return operation;
}

SDWebImageDownloaderOperation

該類只向外提供了一個方法,初始化方法initWithRequest:options:progress:completed:cancelled:。該類通過NSURLConnection來獲取數據,通過 NSNotificationCenter來告訴其他類下載的相關進程,其實現
NSURLConnectionDelegate

// 本類中用到NSURLConnectionDataDelegate 代理方法同時NSURLConnectionDataDelegate 又用到NSURLConnectionDelegate代理方法:
/** NSURLConnectionDataDelegate 
 - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response;
 
 - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data;
 
 - (void)connectionDidFinishLoading:(NSURLConnection *)connection;
 
 - (NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse;
 */

NSURLConnectionDelegate

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error;

- (BOOL)connectionShouldUseCredentialStorage:(NSURLConnection *)connection;

- (void)connection:(NSURLConnection *)connection willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;

主要分析-connection:didReceiveData:和- (void)connection:didReceiveResponse:兩個主要方法。
-connection:didReceiveData:方法的主要任務是接收數據,每次接收到數據時,都會用現有的數據創建一個CGImageSourceRef對象以做處理。在首次獲取到數據時(width+height==0)會從這些包含圖像信息的數據中取出圖像的長、寬、方向等信息以備使用。而后在圖片下載完成之前,會使用CGImageSourceRef對象創建一個圖片對象,經過縮放、解壓縮操作后生成一個UIImage對象供完成回調使用。當然,在這個方法中還需要處理的就是進度信息。如果我們有設置進度回調的話,就調用這個進度回調以處理當前圖片的下載進度。

/**
 *  接收到數據
 *<#data description#>
 *  @param connection <#connection description#>
 *  @param data       
 */
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    // 追加數據
    [self.imageData appendData:data];

    if ((self.options & SDWebImageDownloaderProgressiveDownload) && self.expectedSize > 0 && self.completedBlock) {
        // The following code is from http://www.cocoaintheshell.com/2011/05/progressive-images-download-imageio/
        // Thanks to the author @Nyx0uf

        // Get the total bytes downloaded
        // 獲取已下載的圖片大小
        const NSInteger totalSize = self.imageData.length;

        // Update the data source, we must pass ALL the data, not just the new bytes
        // 更新數據源,我們必須傳入所有的數據 并不是這次接受到的新數據
        CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)self.imageData, NULL);
        // 如果寬和高都為0 即第一次接受到數據
        if (width + height == 0) {
            // 獲取圖片的高、寬、方向等相關數據 并賦值
            CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, NULL);
            if (properties) {
                NSInteger orientationValue = -1;
                CFTypeRef val = CFDictionaryGetValue(properties, kCGImagePropertyPixelHeight);
                if (val) CFNumberGetValue(val, kCFNumberLongType, &height);
                val = CFDictionaryGetValue(properties, kCGImagePropertyPixelWidth);
                if (val) CFNumberGetValue(val, kCFNumberLongType, &width);
                val = CFDictionaryGetValue(properties, kCGImagePropertyOrientation);
                if (val) CFNumberGetValue(val, kCFNumberNSIntegerType, &orientationValue);
                CFRelease(properties);

                // When we draw to Core Graphics, we lose orientation information,
                // which means the image below born of initWithCGIImage will be
                // oriented incorrectly sometimes. (Unlike the image born of initWithData
                // in connectionDidFinishLoading.) So save it here and pass it on later.
                // 當我們繪制 Core Graphics 時,我們將會失去圖片方向的信息,這意味著有時候由initWithCGIImage方法所創建的圖片的方向會不正確(不像在 connectionDidFinishLoading 代理方法里面 用 initWithData 方法創建),所以我們先在這里保存這個信息并在后面使用。
                orientation = [[self class] orientationFromPropertyValue:(orientationValue == -1 ? 1 : orientationValue)];
            }

        }
        // 已經接受到數據 圖片還沒下載完成
        if (width + height > 0 && totalSize < self.expectedSize) {
            // Create the image
            // 先去第一張 部分圖片
            CGImageRef partialImageRef = CGImageSourceCreateImageAtIndex(imageSource, 0, NULL);

#ifdef TARGET_OS_IPHONE
            // Workaround for iOS anamorphic image
            // 對iOS變形圖片工作
            if (partialImageRef) {
                const size_t partialHeight = CGImageGetHeight(partialImageRef);
                CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
                CGContextRef bmContext = CGBitmapContextCreate(NULL, width, height, 8, width * 4, colorSpace, kCGBitmapByteOrderDefault | kCGImageAlphaPremultipliedFirst);
                CGColorSpaceRelease(colorSpace);
                if (bmContext) {
                    CGContextDrawImage(bmContext, (CGRect){.origin.x = 0.0f, .origin.y = 0.0f, .size.width = width, .size.height = partialHeight}, partialImageRef);
                    CGImageRelease(partialImageRef);
                    partialImageRef = CGBitmapContextCreateImage(bmContext);
                    CGContextRelease(bmContext);
                }
                else {
                    CGImageRelease(partialImageRef);
                    partialImageRef = nil;
                }
            }
#endif
            // 存儲圖片
            if (partialImageRef) {
                UIImage *image = [UIImage imageWithCGImage:partialImageRef scale:1 orientation:orientation];
                // 獲取key
                NSString *key = [[SDWebImageManager sharedManager] cacheKeyForURL:self.request.URL];
                // 獲取縮放的圖片
                UIImage *scaledImage = [self scaledImageForKey:key image:image];
                // 解壓圖片
                image = [UIImage decodedImageWithImage:scaledImage];
                CGImageRelease(partialImageRef);
                dispatch_main_sync_safe(^{
                    // 完成block回調
                    if (self.completedBlock) {
                        self.completedBlock(image, nil, nil, NO);
                    }
                });
            }
        }

        CFRelease(imageSource);
    }
    // 進度block回調
    if (self.progressBlock) {
        self.progressBlock(self.imageData.length, self.expectedSize);
    }
}

啟動方法start,該方法使用runloop來保證圖片滑動的流暢性

  // 重寫NSOperation Start方法
- (void)start {
    @synchronized (self) {
        // 如果被取消了
        if (self.isCancelled) {
            // 則已經完成
            self.finished = YES;
            // 重置
            [self reset];
            return;
        }

#if TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_4_0
        // 后臺處理
        if ([self shouldContinueWhenAppEntersBackground]) {
            // 1.防止Block的循環引用(技巧),   wself是為了block不持有self,避免循環引用,
            __weak __typeof__ (self) wself = self;
            self.backgroundTaskId = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^{
                // 而再聲明一個strongSelf是因為一旦進入block執行,就不允許self在這個執行過程中釋放。block執行完后這個strongSelf會自動釋放,沒有循環引用問題。
                __strong __typeof (wself) sself = wself;

                if (sself) {
                    // 取消
                    [sself cancel];

                    [[UIApplication sharedApplication] endBackgroundTask:sself.backgroundTaskId];
                    sself.backgroundTaskId = UIBackgroundTaskInvalid;
                }
            }];
        }
#endif
        // 正在執行中
        self.executing = YES;
        self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
        self.thread = [NSThread currentThread];
    }
    // 開始請求
    [self.connection start];

    if (self.connection) {
        // 進度block回調
        if (self.progressBlock) {
            self.progressBlock(0, NSURLResponseUnknownLength);
        }
        // 通知 開始下載
        dispatch_async(dispatch_get_main_queue(), ^{
            [[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStartNotification object:self];
        });
        // 開始運行 runloop
        if (floor(NSFoundationVersionNumber) <= NSFoundationVersionNumber_iOS_5_1) {
            // Make sure to run the runloop in our background thread so it can process downloaded data
            // Note: we use a timeout to work around an issue with NSURLConnection cancel under iOS 5
            //       not waking up the runloop, leading to dead threads (see https://github.com/rs/SDWebImage/issues/466)
            
            /**
             *  Default NSDefaultRunLoopMode(Cocoa) kCFRunLoopDefaultMode (Core Foundation) 最常用的默認模式
             
             空閑RunLoopMode
             當用戶正在滑動 UIScrollView 時,RunLoop 將切換到 UITrackingRunLoopMode 接受滑動手勢和處理滑動事件(包括減速和彈簧效果),此時,其他 Mode (除 NSRunLoopCommonModes 這個組合 Mode)下的事件將全部暫停執行,來保證滑動事件的優先處理,這也是 iOS 滑動順暢的重要原因。
             當 UI 沒在滑動時,默認的 Mode 是 NSDefaultRunLoopMode(同 CF 中的 kCFRunLoopDefaultMode),同時也是 CF 中定義的 “空閑狀態 Mode”。當用戶啥也不點,此時也沒有什么網絡 IO 時,就是在這個 Mode 下。
             
             */
            CFRunLoopRunInMode(kCFRunLoopDefaultMode, 10, false);
        }
        else {
            CFRunLoopRun();
        }
        //  沒有完成 則取消
        if (!self.isFinished) {
            [self.connection cancel];
            [self connection:self.connection didFailWithError:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:@{NSURLErrorFailingURLErrorKey : self.request.URL}]];
        }
    }
    else {
        if (self.completedBlock) {
            self.completedBlock(nil, nil, [NSError errorWithDomain:NSURLErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey : @"Connection can't be initialized"}], YES);
        }
    }

#if TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_4_0
    // 后臺處理
    if (self.backgroundTaskId != UIBackgroundTaskInvalid) {
        [[UIApplication sharedApplication] endBackgroundTask:self.backgroundTaskId];
        self.backgroundTaskId = UIBackgroundTaskInvalid;
    }
#endif
}

Util:

  • SDWebImageDecoder對圖片的解壓縮操作,通過指定decodedImageWithImage方法來解壓圖片,這樣做的好處是防止圖片加載時有延時(圖片在UIImageView顯示時會進行一個解壓操作),但是用這個方法會導致內存暴漲等問題;
  • SDWebImagePrefetcher是預取圖片類,通過startPrefetchingAtIndex方法可以指定開始預取URL數組的第幾張圖片。

SDWebImageDecoder類

decodedImageWithImage方法來解壓圖片,具體過程是這樣的。

+ (UIImage *)decodedImageWithImage:(UIImage *)image {
    if (image.images) {
        // Do not decode animated images 不對動畫圖片進行解壓
        return image;
    }

    CGImageRef imageRef = image.CGImage; // 創建一個CGImage格式的圖片來支持解壓操作
    // 獲得圖片寬高
    CGSize imageSize = CGSizeMake(CGImageGetWidth(imageRef), CGImageGetHeight(imageRef));
    // 獲得一個圖片矩形
    CGRect imageRect = (CGRect){.origin = CGPointZero, .size = imageSize};
    // 創建一個RGB繪制空間
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    // 通過imageRef獲得Bitmap位圖信息
    CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(imageRef);
    
    // 設置圖片蒙版信息(例如是否透明)
    int infoMask = (bitmapInfo & kCGBitmapAlphaInfoMask);
    BOOL anyNonAlpha = (infoMask == kCGImageAlphaNone ||
            infoMask == kCGImageAlphaNoneSkipFirst ||
            infoMask == kCGImageAlphaNoneSkipLast);

    // CGBitmapContextCreate doesn't support kCGImageAlphaNone with RGB.
    // CGBitmapContextCreate 不支持在RGB上使用kCGImageAlphaNone
    // https://developer.apple.com/library/mac/#qa/qa1037/_index.html
    if (infoMask == kCGImageAlphaNone && CGColorSpaceGetNumberOfComponents(colorSpace) > 1) {
        // Unset the old alpha info. 取消舊的alpha信息
        bitmapInfo &= ~kCGBitmapAlphaInfoMask;

        // Set noneSkipFirst. 設置新的alpha信息
        bitmapInfo |= kCGImageAlphaNoneSkipFirst;
    }
            // Some PNGs tell us they have alpha but only 3 components. Odd.
    else if (!anyNonAlpha && CGColorSpaceGetNumberOfComponents(colorSpace) == 3) {
        // Unset the old alpha info.
        bitmapInfo &= ~kCGBitmapAlphaInfoMask;
        bitmapInfo |= kCGImageAlphaPremultipliedFirst;
    }

    // It calculates the bytes-per-row based on the bitsPerComponent and width arguments.
    CGContextRef context = CGBitmapContextCreate(NULL,
            imageSize.width,
            imageSize.height,
            CGImageGetBitsPerComponent(imageRef),
            0,
            colorSpace,
            bitmapInfo);
    CGColorSpaceRelease(colorSpace);

    // If failed, return undecompressed image
    if (!context) return image;

    CGContextDrawImage(context, imageRect, imageRef);
    CGImageRef decompressedImageRef = CGBitmapContextCreateImage(context);

    CGContextRelease(context);

    UIImage *decompressedImage = [UIImage imageWithCGImage:decompressedImageRef scale:image.scale orientation:image.imageOrientation];
    CGImageRelease(decompressedImageRef);
    return decompressedImage;
}

當你用 UIImage 或 CGImageSource 的幾個方法創建圖片時,圖片數據并不會立刻解碼。圖片設置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的數據才會得到解碼。這一步是發生在主線程的,并且不可避免。如果想要繞開這個機制,常見的做法是在后臺線程先把圖片繪制到 CGBitmapContext 中,然后從 Bitmap 直接創建圖片

  • 為什么從磁盤里面取出圖片后,block回調之前要解壓圖片呢?因為圖片在UIImageView上面顯示的時候需要解壓,而這個解壓操作是在主線程里面進行的,比較耗時,這樣就會產生延時效果在后臺解壓能夠解決這一問題,但是這種用空間換時間的方法也存在著內存暴增甚至崩潰等問題,所以自己得權衡一下。這就是為什么SDImageCache、SDWebImageDownloader、SDWebImageDownloaderOperation
    類中都有shouldDecompressImages (是否解壓圖片)
    值存在的原因

SDWebImagePrefetcher類

SDWebImagePrefetcher類主要提供startPrefetchingAtIndex方法來實現開始預取URL數組的預加載圖片,具體實現是這樣的。

/**
 *  開始預取URL數組的第幾張圖片
 *
 *  @param index index description
 */
- (void)startPrefetchingAtIndex:(NSUInteger)index {
    // 判斷index是否越界
    if (index >= self.prefetchURLs.count) return;
    // 請求個數 +1
    self.requestedCount++;
    // 用SDWebImageManager 下載圖片
    [self.manager downloadImageWithURL:self.prefetchURLs[index] options:self.options progress:nil completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
        if (!finished) return;
        // 完成個數 +1
        self.finishedCount++;
        // 有圖片
        if (image) {
            // 進度block回調
            if (self.progressBlock) {
                self.progressBlock(self.finishedCount,[self.prefetchURLs count]); 
            }
            NSLog(@"Prefetched %@ out of %@", @(self.finishedCount), @(self.prefetchURLs.count));
        }
        else {
            // 進度block回調
            if (self.progressBlock) {
                self.progressBlock(self.finishedCount,[self.prefetchURLs count]);
            }
            NSLog(@"Prefetched %@ out of %@ (Failed)", @(self.finishedCount), @(self.prefetchURLs.count));
            // 下載完成 但是沒圖片 跳過個數 +1
            // Add last failed
            // Add last failed
            self.skippedCount++;
        }
        // delegate 回調
        if ([self.delegate respondsToSelector:@selector(imagePrefetcher:didPrefetchURL:finishedCount:totalCount:)]) {
            [self.delegate imagePrefetcher:self
                            didPrefetchURL:self.prefetchURLs[index]
                             finishedCount:self.finishedCount
                                totalCount:self.prefetchURLs.count
            ];
        }
        // 如果預存完成個數大于請求的個數,則請求requestedCount最后一個預存圖片
        if (self.prefetchURLs.count > self.requestedCount) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [self startPrefetchingAtIndex:self.requestedCount];
            });
        }
        // 如果完成個數與請求個數相等 則下載已完成
        else if (self.finishedCount == self.requestedCount) {
            [self reportStatus];
            if (self.completionBlock) {
                self.completionBlock(self.finishedCount, self.skippedCount);
                self.completionBlock = nil;
            }
        }
    }];
}

知識點總結

  • PNG圖片的判斷,通過簽名字節kPNGSignatureBytes數組來判斷是否圖片。

  • 在SDWebImageDownloaderOperation類,NSURLConnectionDataDelegate相關代理方法首先確保RunLoop運行在后臺線程,當UI處于”空閑“(NSRunLoopDefault)把圖片的下載操作加入到RunLoop,這樣來保證混滑動圖片的流暢性,所以當你把快速滑動UITableView時,圖片不會立即顯示,當處于空閑狀態時圖片才顯示,原因就在這里。

先寫到這里

參考資料

SDWebImage實現分析

SDWebImage源碼淺析

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容