TMCache源碼分析(二)---TMDiskCache磁盤緩存

原文在這里

上篇分析TMCache中內存緩存TMMemoryCache的實現(xiàn)原理, 這篇文章將詳細分析磁盤緩存的實現(xiàn)原理.

磁盤緩存,顧名思義:將數(shù)據(jù)存儲到磁盤上,由于需要儲存的數(shù)據(jù)量比較大,所以一般讀寫速度都比內存緩存慢, 但也是非常重要的一項功能, 比如能夠實現(xiàn)離線瀏覽等提升用戶體驗.

磁盤緩存的實現(xiàn)形式大致分為三種:

  • 基于文件讀寫.
  • 基于數(shù)據(jù)庫.
  • 基于 mmap 文件內存映射.

前面兩種使用的比較廣泛, SDWebImageTMDiskCache都是基于文件 I/O 進行存儲的, 也就是一個 value 對應一個文件, 通過讀寫文件來緩存數(shù)據(jù). 根據(jù)上篇可以知道TMMemoryCache內存緩存的主線是按照 key-value的形式把數(shù)據(jù)存進可變字典中, 那么磁盤緩存的主線也是按照 key-value的形式進行對應的, 只不過 value 對應的是一個文件, 換湯不換藥.

通過TMDiskCache的接口 API 可以看到, TMDiskCache提供以下功能:

  • 同步/異步的進行讀寫數(shù)據(jù).
  • 同步/異步的進行刪除數(shù)據(jù).
  • 同步/異步的獲取緩存路徑.
  • 同步/異步的根據(jù)緩存時間或者緩存大小來削減磁盤空間.
  • 設置磁盤緩存空間上限, 磁盤緩存時間上限.
  • 各類 will / did block, 以及監(jiān)聽后臺操作.
  • 清空臨時存儲區(qū).

TMDiskCache的同步操作是跟TMMemoryCache操作一樣,都是采用dispatch_semaphore_t信號量的形式來強制把異步轉成同步操作,后面同步操作就一步帶過,除非特別說明. 其實TMDiskCache的難點不在于線程安全,因為它所有的操作都在一個 serial queue 串行隊列中, 不存在競態(tài)情況, 難點在于文件的操作, 了解 Linux 文件系統(tǒng)操作的同學應該知道文件 I/O 的概念, iOS 封裝了操作文件的類, 使用這些高級 API 能更好的操作文件.

初始化方法

在操作之前先看一下TMDiskCache的初始化方法, 提供一個類方法, 兩個實例方法:

+ (instancetype)sharedCache;

- (instancetype)initWithName:(NSString *)name;
- (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath;

從名字應該能猜測出最終調用的方法應該是- (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath, 傳磁盤緩存所在目錄的名字和絕對路徑, 如果調用前兩個方法,在方法內部將默認設置好路徑或者緩存文件夾名字. 我們主要看終極方法主要做了幾件事:

  • 創(chuàng)建串行隊列,是單例,即一個單例緩存對象有一個單例串行隊列.
  • 初始化兩個可變字典_dates, _sizes, 分別用于存數(shù)據(jù)最后操作時間和數(shù)據(jù)占用磁盤空間大小.
  • 創(chuàng)建緩存文件, 設置緩存文件操作時間.

其余的比較簡單, 這里主要說一下設置緩存文件操作時間的相關 API, 首先是處理 key 的方法, 這兩個方法分別對傳入的 key 進行編碼和解碼, 比如在調用setObject:forKey:的時候 key 值傳入了中文字符, 就會調用encodedStringdecodedString來編解碼, 可以進入沙盒中看到對應的緩存文件名字是這類編碼后的字符, 形如:%E7%A8%8B%E5%85%88%E7%94%9F.

- (NSString *)encodedString:(NSString *)string {
    if (![string length])
        return @"";
    
    CFStringRef static const charsToEscape = CFSTR(".:/");
    CFStringRef escapedString = CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault,
                                                                        (__bridge CFStringRef)string,
                                                                        NULL,
                                                                        charsToEscape,
                                                                        kCFStringEncodingUTF8);
    return (__bridge_transfer NSString *)escapedString;
}

- (NSString *)decodedString:(NSString *)string {
    if (![string length])
        return @"";
    
    CFStringRef unescapedString = CFURLCreateStringByReplacingPercentEscapesUsingEncoding(kCFAllocatorDefault,
                                                                                          (__bridge CFStringRef)string,
                                                                                          CFSTR(""),
                                                                                          kCFStringEncodingUTF8);
    return (__bridge_transfer NSString *)unescapedString;
}

下面這個初始化設置方法, 只做了一件事:

遍歷緩存文件夾下面所有的已緩存的文件, 更新的操作時間數(shù)組_dates, 文件大小數(shù)組_sizes以及更新磁盤總使用大小.

這么做的目的是什么呢?第一次創(chuàng)建磁盤緩存目錄肯定是空的文件夾, 里面鐵定沒有緩存文件, 那為什么要遍歷一次所有的緩存文件并更新其操作時間和大小呢? 其實是為了防止不小心再次調用- (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath創(chuàng)建了一個名字和路徑都相同的緩存目錄, 避免里面已經(jīng)緩存的數(shù)據(jù)脫離控制. 用心良苦呀!

- (void)initializeDiskProperties {
    NSUInteger byteCount = 0;
    NSArray *keys = @[ NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey ];
    
    NSError *error = nil;
    NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:_cacheURL
                                                   includingPropertiesForKeys:keys
                                                                      options:NSDirectoryEnumerationSkipsHiddenFiles
                                                                        error:&error];
    TMDiskCacheError(error);
    
    for (NSURL *fileURL in files) {
        NSString *key = [self keyForEncodedFileURL:fileURL];
        
        error = nil;
        NSDictionary *dictionary = [fileURL resourceValuesForKeys:keys error:&error];
        TMDiskCacheError(error);
        
        NSDate *date = [dictionary objectForKey:NSURLContentModificationDateKey];
        if (date && key)
            [_dates setObject:date forKey:key];
        
        NSNumber *fileSize = [dictionary objectForKey:NSURLTotalFileAllocatedSizeKey];
        if (fileSize) {
            [_sizes setObject:fileSize forKey:key];
            byteCount += [fileSize unsignedIntegerValue];
        }
    }
    
    if (byteCount > 0)
        self.byteCount = byteCount; // atomic
}

- (NSString *)keyForEncodedFileURL:(NSURL *)url {
    NSString *fileName = [url lastPathComponent];
    if (!fileName)
        return nil;

    return [self decodedString:fileName];
}

由此看出, 對于緩存數(shù)據(jù)來說, key 經(jīng)過編碼后設為緩存文件名, value 經(jīng)過歸檔后寫入文件.

至此, 所有的準備工作都基本做完, 下面開始存取數(shù)據(jù)了.

同步/異步的進行讀寫數(shù)據(jù)

異步的進行讀寫數(shù)據(jù)

相關 API:

- (void)objectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
- (void)setObject:(id <NSCoding>)object forKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;

先來看看寫操作如何實現(xiàn)的, 我就不貼源碼具體實現(xiàn)了, 省的看的費勁, 只看關鍵部位吧~~~你懂的, 嘻嘻.

寫入緩存
  1. 寫操作被 commit 到串行隊列中, 保證了寫緩存的時候線程安全:
dispatch_async(_queue, ^{ 
    // 寫操作
    // ...
}
  1. 將傳入的對象進行歸檔處理, 所以要緩存的對象一定要遵守NSCoding協(xié)議, 并實現(xiàn)相關方法:
BOOL written = [NSKeyedArchiver archiveRootObject:object toFile:[fileURL path]];
  1. 更新緩存文件的修改時間, 不管是新加入的緩存數(shù)據(jù)還是已有的緩存數(shù)據(jù)進行更新, 都會修改對應的時間為當前時間:
[strongSelf setFileModificationDate:now forURL:fileURL];
  1. 下面是針對緩存空間大小的處理, 比較重要的一步, 根據(jù)最新緩存的數(shù)據(jù)更新總共已經(jīng)使用的磁盤空間大小, 如果超過預設磁盤空間上限, 則需要刪除一些數(shù)據(jù)以達到不超過上限的目的, 那以什么規(guī)則來刪除超過緩存上限的部分數(shù)據(jù)呢? TMMemoryCache的優(yōu)化策略是根據(jù)操作時間的先后順序, 即操作時間早的數(shù)據(jù), 認為你使用的概率比較低, 所以就優(yōu)先刪除掉, TMDiskCache優(yōu)化策略跟TMMemoryCache相同, 先刪除最早的數(shù)據(jù). 這也是以文件系統(tǒng)的形式緩存數(shù)據(jù)的缺點, 不能進行有效的算法.
  • 更新緩存空間大小.
   NSNumber *oldEntry = [strongSelf->_sizes objectForKey:key];
   
   if ([oldEntry isKindOfClass:[NSNumber class]]){
       strongSelf.byteCount = strongSelf->_byteCount - [oldEntry unsignedIntegerValue];
   }
   
   [strongSelf->_sizes setObject:diskFileSize forKey:key];
   strongSelf.byteCount = strongSelf->_byteCount + [diskFileSize unsignedIntegerValue]; // atomic
  • 刪除超出部分空間的緩存數(shù)據(jù).
if (strongSelf->_byteLimit > 0 && strongSelf->_byteCount > strongSelf->_byteLimit)
               [strongSelf trimToSizeByDate:strongSelf->_byteLimit block:nil];

至此異步寫入緩存數(shù)據(jù)完成, 注意:

_dates, _sizes中的 key 并沒有經(jīng)過編碼, 只有緩存文件名才是經(jīng)過編碼的.

讀取緩存

相關 API:

- (id <NSCoding>)objectForKey:(NSString *)key;
- (void)objectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;

也是看看異步的讀取緩存, 根據(jù)上面寫入緩存的步驟可以推測讀取的步驟, 無非就是把 key 進行編碼, 找到緩存文件, 再解檔緩存文件內容, 最后更新操作時間, 主線就這幾步, 其余的就是加點"配料" - will / did block 之類的時序控制類操作.

dispatch_async(_queue, ^{
        TMDiskCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;
        
        NSURL *fileURL = [strongSelf encodedFileURLForKey:key];
        id <NSCoding> object = nil;
        
        if ([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) {
            @try {
                object = [NSKeyedUnarchiver unarchiveObjectWithFile:[fileURL path]];
            }
            @catch (NSException *exception) {
                NSError *error = nil;
                [[NSFileManager defaultManager] removeItemAtPath:[fileURL path] error:&error];
                TMDiskCacheError(error);
            }
            
            [strongSelf setFileModificationDate:now forURL:fileURL];
        }
        
        block(strongSelf, key, object, fileURL);
    });

代碼中通過@ try, @catch拋出異常, 如果解檔緩存文件內容失敗, 直接刪除該緩存文件, 簡單不做作, 直接了當! 額, 也許不近人情, 好歹你告訴我錯誤信息是什么, 讓我來決定刪不刪嘛.

同步的寫入/讀取緩存

都是采用dispatch_semaphore_t信號量的形式來實現(xiàn)的.

同步/異步的進行刪除數(shù)據(jù)

相關 API:

- (void)removeObjectForKey:(NSString *)key;
- (void)removeObjectForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;

我們只分析異步的刪除緩存數(shù)據(jù), 同步的跟其它同步操作一樣.
既然知道怎么寫入緩存, 那刪除應該也沒什么問題了, 找到要刪除的文件路徑, 刪除該緩存文件即可. 所以步驟應該是:

  1. key 進行編碼, 再拼接成完整的緩存文件的絕對路徑.
  2. 刪除文件, 其中刪除文件做了特殊的步驟, 但是不影響整個刪除流程, 后面會講解.
  3. 刪除_dates,_sizes中的鍵值對, 更新總用使用的緩存空間大小.

注意刪除文件的時候并沒有直接刪除, 而是把待刪除文件移到臨時目錄 tmp下的緩存目錄里, 創(chuàng)建了一個新的串行隊列進行刪除操作.

BOOL trashed = [TMDiskCache moveItemAtURLToTrash:fileURL];
if (!trashed)
     return NO;

[TMDiskCache emptyTrash];

同步/異步的獲取緩存路徑

相關 API:

- (void)fileURLForKey:(NSString *)key block:(TMDiskCacheObjectBlock)block;
- (NSURL *)fileURLForKey:(NSString *)key;

實現(xiàn)非常簡單:

  1. 對 key 進行編碼, 拼接完整緩存文件路徑.
  2. 更新緩存文件操作時間.
    NSURL *fileURL = [strongSelf encodedFileURLForKey:key];
    
    if ([[NSFileManager defaultManager] fileExistsAtPath:[fileURL path]]) {
        [strongSelf setFileModificationDate:now forURL:fileURL];
    } else {
        fileURL = nil;
    }

同步/異步的根據(jù)緩存時間或者緩存大小來削減磁盤空間

這部分操作跟TMMemoryCache的實現(xiàn)類似, 相關 API:

- (void)trimToDate:(NSDate *)date;
- (void)trimToDate:(NSDate *)date block:(TMDiskCacheBlock)block;

- (void)trimToSize:(NSUInteger)byteCount;
- (void)trimToSize:(NSUInteger)byteCount block:(TMDiskCacheBlock)block;

- (void)trimToSizeByDate:(NSUInteger)byteCount;
- (void)trimToSizeByDate:(NSUInteger)byteCount block:(TMDiskCacheBlock)block;

第一組, 根據(jù)緩存時間來削減緩存空間, 如果緩存數(shù)據(jù)的緩存時間超過了設置的date, 則會被刪除.
第二組, 根據(jù)緩存大小來削減緩存空間, 如果緩存數(shù)據(jù)的緩存大小超過了指定的byteCount, 則會被刪除.
第三組, 根據(jù)操作時間的先后順序, 來削減超過了指定緩存大小的空間.

實現(xiàn)大致都相同, 無非就是對時間進行排序, 然后把 key 進行編碼, 拼接路徑, 移動緩存文件到 tmp目錄下, 再清空 tmp 目錄. 注意一點, 無論是按照緩存時間還是緩存大小, 都是升序排序, 最先刪除的都是最早的或最小的數(shù)據(jù).

設置磁盤緩存空間上限, 磁盤緩存時間上限

源碼實現(xiàn):

- (NSUInteger)byteLimit {
    __block NSUInteger byteLimit = 0;
    
    dispatch_sync(_queue, ^{
        byteLimit = _byteLimit;
    });
    
    return byteLimit;
}

- (void)setByteLimit:(NSUInteger)byteLimit {
    __weak TMDiskCache *weakSelf = self;
    
    dispatch_barrier_async(_queue, ^{
        TMDiskCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;
        
        strongSelf->_byteLimit = byteLimit;
        
        if (byteLimit > 0)
            [strongSelf trimDiskToSizeByDate:byteLimit];
    });
}

設置緩存空間上限的時候采用dispatch_barrier_async柵欄方法, 我不知道作者為何這么寫, 多此一舉! 本來就是串行隊列了, 就能夠保證線程安全, 加柵欄方法沒什么意義. 現(xiàn)在應該注意的不是線程安全, 而是線程死鎖的問題. 所以在 API 接口中有個??警告

@warning Do not read this property on the <sharedQueue> (including asynchronous method blocks).

意思是不要在 shareQueue 和接口里面的任何 API 的異步 block 中去讀這個屬性, 為什么呢? 因為TMDiskCache所有的讀寫刪除操作都是放在Serial Queue串行隊列中的, 也就是shareQueue隊列, 天啦嚕...這不造成死鎖才怪呢! 警告還寫這么不明顯.形如下面的是錯誤?的用法:

[diskCache removeObjectForKey:@"profileKey" block:^(TMDiskCache *cache, NSString *key, id<NSCoding> object, NSURL *fileURL) {
        NSLog(@"%ld", diskCache.byteLimit);
 }];

因為在removeObjectForKey之類的方法中會同步執(zhí)行傳入的 block 操作, 如果在 block 里面再提交新的任務到串行隊列中, 再同步執(zhí)行, 必然死鎖. 因為外層的 block 需要等待新提交的 block 執(zhí)行完畢才能執(zhí)行完成, 然而新提交的 block 需要等待外層 block 執(zhí)行完才能執(zhí)行, 兩者相互依賴對方執(zhí)行完才能執(zhí)行完成, 就造成死鎖了.

if (block)
    block(strongSelf, key, nil, fileURL);

上一篇分析了 TMMemoryCache 容易造成性能消耗嚴重, 而TMDiskCache使用不當容易造成死鎖.

各類 will / did block, 以及后臺操作

will / did block 穿插在各類異步操作中, 非常簡單, 看看即可.

if (strongSelf->_willAddObjectBlock)
    strongSelf->_willAddObjectBlock(strongSelf, key, object, fileURL);

其中后臺操作有點意思, 創(chuàng)建一個全局的后臺管理者遵守TMCacheBackgroundTaskManager協(xié)議, 實現(xiàn)其中的兩個方法:

- (UIBackgroundTaskIdentifier)beginBackgroundTask;
- (void)endBackgroundTask:(UIBackgroundTaskIdentifier)identifier;

然后調用設置方法, 給 TMDiskCache對象設置后臺管理者.

+ (void)setBackgroundTaskManager:(id <TMCacheBackgroundTaskManager>)backgroundTaskManager;

在后臺任務開始之前調用 beginBackgroundTask 方法, 結束后臺任務之前調用 endBackgroundTask, 就能在后臺管理者里面監(jiān)聽到什么時候進入后臺操作, 什么時候結束后臺操作了.
具體做法:

UIBackgroundTaskIdentifier taskID = [TMCacheBackgroundTaskManager beginBackgroundTask];

dispatch_async(_queue, ^{ 
      TMDiskCache *strongSelf = weakSelf;
        if (!strongSelf) {
            [TMCacheBackgroundTaskManager endBackgroundTask:taskID];
            return;
        }

      // 執(zhí)行后臺任務
      // 比如: 寫緩存, 取緩存, 刪除緩存等等.

     [TMCacheBackgroundTaskManager endBackgroundTask:taskID];
}

因為磁盤的操作可能耗時非常長, 不可能一直等待, 因此通過這種全局的方式來感知異步操作的開始和結束, 從而執(zhí)行響應事件.

清空臨時存儲區(qū)

根據(jù)上面可以知道, 刪除緩存文件的時候, 先會在tmp下創(chuàng)建"回收目錄", 需要刪除的緩存文件統(tǒng)一放進回收目錄下, 下面是獲取回收目錄的URL 路徑, 沒有就創(chuàng)建, 有則返回, 只創(chuàng)建一次:

+ (NSURL *)sharedTrashURL {
    static NSURL *sharedTrashURL;
    static dispatch_once_t predicate;
    
    dispatch_once(&predicate, ^{
        sharedTrashURL = [[[NSURL alloc] initFileURLWithPath:NSTemporaryDirectory()] URLByAppendingPathComponent:TMDiskCachePrefix isDirectory:YES];
        
        if (![[NSFileManager defaultManager] fileExistsAtPath:[sharedTrashURL path]]) {
            NSError *error = nil;
            [[NSFileManager defaultManager] createDirectoryAtURL:sharedTrashURL
                                     withIntermediateDirectories:YES
                                                      attributes:nil
                                                           error:&error];
            TMDiskCacheError(error);
        }
    });
    
    return sharedTrashURL;
}

創(chuàng)建一個清空操作專屬的串行隊列TrashQueue, 并且使用dispatch_set_target_queue方法修改TrashQueue的優(yōu)先級, 并與全局并發(fā)隊列global_queue 的后臺優(yōu)先級一致. 因為tmp目錄的情況操作不是那么的重要, 即使我們不手動清除, 系統(tǒng)也會在恰當?shù)臅r候清除, 所以這里把TrashQueue隊列的優(yōu)先級降低.

+ (dispatch_queue_t)sharedTrashQueue {
    static dispatch_queue_t trashQueue;
    static dispatch_once_t predicate;
    
    dispatch_once(&predicate, ^{
        NSString *queueName = [[NSString alloc] initWithFormat:@"%@.trash", TMDiskCachePrefix];
        trashQueue = dispatch_queue_create([queueName UTF8String], DISPATCH_QUEUE_SERIAL);
        dispatch_set_target_queue(trashQueue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0));
    });
    
    return trashQueue;
}

類方法, 把原本在Caches下的緩存文件移動進tmp目錄下的回收目錄.

+ (BOOL)moveItemAtURLToTrash:(NSURL *)itemURL {
    if (![[NSFileManager defaultManager] fileExistsAtPath:[itemURL path]])
        return NO;
    
    NSError *error = nil;
    NSString *uniqueString = [[NSProcessInfo processInfo] globallyUniqueString];
    NSURL *uniqueTrashURL = [[TMDiskCache sharedTrashURL] URLByAppendingPathComponent:uniqueString];
    BOOL moved = [[NSFileManager defaultManager] moveItemAtURL:itemURL toURL:uniqueTrashURL error:&error];
    TMDiskCacheError(error);
    return moved;
}

把清除操作添加到TrashQueue中異步執(zhí)行, 在該方法中遍歷回收目錄下所有的緩存文件, 依次進行刪除:

+ (void)emptyTrash {
    UIBackgroundTaskIdentifier taskID = [TMCacheBackgroundTaskManager beginBackgroundTask];
    
    dispatch_async([self sharedTrashQueue], ^{
        NSError *error = nil;
        NSArray *trashedItems = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:[self sharedTrashURL]
                                                              includingPropertiesForKeys:nil
                                                                                 options:0
                                                                                   error:&error];
        TMDiskCacheError(error);
        
        for (NSURL *trashedItemURL in trashedItems) {
            NSError *error = nil;
            [[NSFileManager defaultManager] removeItemAtURL:trashedItemURL error:&error];
            TMDiskCacheError(error);
        }
        
        [TMCacheBackgroundTaskManager endBackgroundTask:taskID];
    });
}

其實我們只要看一下刪除操作在哪里執(zhí)行的, 就能明白為何作者要創(chuàng)建一個專門用于刪除數(shù)據(jù)的串行隊列了. emptyTrash方法調用是在讀寫操作的串行隊列queue中, 方法調用后面還有_didRemoveObjectBlock等待執(zhí)行, 如果刪除數(shù)據(jù)量比較大且刪除操作在queue中, 將阻塞當前線程, 那么_didRemoveObjectBlock會等待許久才能回調, 況且刪除操作對于響應用戶事件而言不是那么的重要, 所以把需要刪除的緩存文件放進tmp目錄下, 創(chuàng)建新的低優(yōu)先級的串行隊列來進行刪除操作. 這點值得學習!

[TMDiskCache emptyTrash];

總結

  1. 使用TMDiskCache姿勢要正確, 否則容易造成死鎖.
  2. 刪除緩存的思路值得借鑒.

歡迎大家斧正!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • 把網(wǎng)上的一些結合自己面試時遇到的面試題總結了一下,以后有新的還會再加進來。 1. OC 的理解與特性 OC 作為一...
    AlaricMurray閱讀 2,666評論 0 20
  • 圖片下載的這些回調信息存儲在SDWebImageDownloader類的URLOperations屬性中,該屬性是...
    怎樣m閱讀 2,681評論 0 1
  • 第一篇第二篇大概是把下載圖片緩存圖片的這個邏輯走完了,里面涉及好多類。 羅列一下 UIView+WebCache ...
    充滿活力的早晨閱讀 846評論 0 1
  • SDWebImage是一個開源的第三方庫,它提供了UIImageView的一個分類,以支持從遠程服務器下載并緩存圖...
    devning閱讀 479評論 0 0
  • 時間管理講:第15講 習慣,是由潛意識來控制的行為。 人的行為70%-80%受到習慣的控制。 習慣和時間管理有什么...
    CCBS閱讀 464評論 0 0

友情鏈接更多精彩內容