1. 程式人生 > >多執行緒非同步載入圖片async_pictures

多執行緒非同步載入圖片async_pictures

非同步載入圖片

  • 目標:在表格中非同步載入網路圖片
  • 目的:

    • 模擬 SDWebImage 基本功能實現
    • 理解 SDWebImage 的底層實現機制
    • SDWebImage 是非常著名的網路圖片處理框架,目前國內超過 90% 公司都在使用!
  • 要求:

    • 不要求能夠打出來
    • 需要掌握思路
    • 需要知道開發過程中,每一個細節是怎麼遞進的
    • 需要知道每一個隱晦的問題是如何發現的

搭建介面&資料準備

程式碼

資料準備

@interface AppInfo : NSObject
///  App 名稱
@property (nonatomic, copy) NSString *name;
///  圖示 URL
@property (nonatomic, copy) NSString *icon; /// 下載數量 @property (nonatomic, copy) NSString *download; + (instancetype)appInfoWithDict:(NSDictionary *)dict; /// 從 Plist 載入 AppInfo + (NSArray *)appList; @end
+ (instancetype)appInfoWithDict:(NSDictionary *)dict {
    id obj = [[self alloc] init];

    [obj setValuesForKeysWithDictionary:dict];

    return
obj; } /// 從 Plist 載入 AppInfo + (NSArray *)appList { NSURL *url = [[NSBundle mainBundle] URLForResource:@"apps.plist" withExtension:nil]; NSArray *array = [NSArray arrayWithContentsOfURL:url]; NSMutableArray *arrayM = [NSMutableArray arrayWithCapacity:array.count]; [array enumerateObjectsUsingBlock:^(id
obj, NSUInteger idx, BOOL *stop) { [arrayM addObject:[self appInfoWithDict:obj]]; }]; return arrayM.copy; }

檢視控制器資料

///  應用程式列表
@property (nonatomic, strong) NSArray *appList;
  • 懶載入
- (NSArray *)appList {
    if (_appList == nil) {
        _appList = [AppInfo appList];
    }
    return _appList;
}

表格資料來源方法

#pragma mark - 資料來源方法
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.appList.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"AppCell"];

    // 設定 Cell...
    AppInfo *app = self.appList[indexPath.row];

    cell.textLabel.text = app.name;
    cell.detailTextLabel.text = app.download;

    return cell;
}

知識點

  1. 資料模型應該負責所有資料準備工作,在需要時被呼叫
  2. 資料模型不需要關心被誰呼叫
  3. 陣列使用
    • [NSMutableArray arrayWithCapacity:array.count]; 的效率更高
    • 使用塊程式碼遍歷的效率比 for 要快
  4. @"AppCell" 格式定義的字串是儲存在常量區的
  5. 在 OC 中,懶載入是無處不在的
    • 設定 cell 內容時如果沒有指定影象,擇不會建立 imageView
      # 同步載入影象
// 同步載入影象
// 1. 模擬延時
NSLog(@"正在下載 %@", app.name);
[NSThread sleepForTimeInterval:0.5];
// 2. 同步載入網路圖片
NSURL *url = [NSURL URLWithString:app.icon];
NSData *data = [NSData dataWithContentsOfURL:url];
UIImage *image = [UIImage imageWithData:data];

cell.imageView.image = image;

注意:之前沒有設定 imageView 時,imageView 並不會被建立

存在的問題

  1. 如果網速慢,會卡爆了!影響使用者體驗
  2. 滾動表格,會重複下載影象,造成使用者經濟上的損失!

解決辦法

  • 非同步下載影象

非同步下載影象

全域性操作佇列

///  全域性佇列,統一管理所有下載操作
@property (nonatomic, strong) NSOperationQueue *downloadQueue;
  • 懶載入
- (NSOperationQueue *)downloadQueue {
    if (_downloadQueue == nil) {
        _downloadQueue = [[NSOperationQueue alloc] init];
    }
    return _downloadQueue;
}

非同步下載

// 非同步載入影象
// 1. 定義下載操作
// 非同步載入影象
NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
    // 1. 模擬延時
    NSLog(@"正在下載 %@", app.name);
    [NSThread sleepForTimeInterval:0.5];
    // 2. 非同步載入網路圖片
    NSURL *url = [NSURL URLWithString:app.icon];
    NSData *data = [NSData dataWithContentsOfURL:url];
    UIImage *image = [UIImage imageWithData:data];

    // 3. 主執行緒更新 UI
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        cell.imageView.image = image;
    }];
}];

// 2. 將下載操作新增到佇列
[self.downloadQueue addOperation:downloadOp];

執行測試

存在的問題

  • 下載完成後不現實圖片

原因分析:
* 使用的是系統提供的 cell
* 非同步方法中只設置了影象,但是沒有設定 frame
* 影象載入後,一旦與 cell 互動,會呼叫 cell 的 layoutSubviews 方法,重新調整 cell 的佈局

解決辦法

  • 使用佔位影象
  • 自定義 Cell

注意演示不在主執行緒更新影象的效果

佔位影象

// 0. 佔位影象
UIImage *placeholder = [UIImage imageNamed:@"user_default"];
cell.imageView.image = placeholder;

問題

  • 因為使用的是系統提供的 cell
  • 每次和 cell 互動,layoutSubviews 方法會根據影象的大小自動調整 imageView 的尺寸

解決辦法

  • 自定義 Cell

自定義 Cell

cell.nameLabel.text = app.name;
cell.downloadLabel.text = app.download;

// 非同步載入影象
// 0. 佔位影象
UIImage *placeholder = [UIImage imageNamed:@"user_default"];
cell.iconView.image = placeholder;

// 1. 定義下載操作
NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
    // 1. 模擬延時
    NSLog(@"正在下載 %@", app.name);
    [NSThread sleepForTimeInterval:0.5];
    // 2. 非同步載入網路圖片
    NSURL *url = [NSURL URLWithString:app.icon];
    NSData *data = [NSData dataWithContentsOfURL:url];
    UIImage *image = [UIImage imageWithData:data];

    // 3. 主執行緒更新 UI
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        cell.iconView.image = image;
    }];
}];

// 2. 將下載操作新增到佇列
[self.downloadQueue addOperation:downloadOp];

問題

  • 如果網路圖片下載速度不一致,同時使用者滾動圖片,可能會出現圖片顯示”錯行”的問題

  • 修改延時程式碼,檢視錯誤

// 1. 模擬延時
if (indexPath.row > 9) {
    [NSThread sleepForTimeInterval:3.0];
}

上下滾動一下表格即可看到 cell 複用的錯誤

解決辦法

  • MVC

MVC

在模型中新增 image 屬性

#import <UIKit/UIKit.h>

///  下載的影象
@property (nonatomic, strong) UIImage *image;

使用 MVC 更新表格影象

  • 判斷模型中是否已經存在影象
if (app.image != nil) {
    NSLog(@"載入模型影象...");
    cell.iconView.image = app.image;
    return cell;
}
  • 下載完成後設定模型影象
// 3. 主執行緒更新 UI
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    // 設定模型中的影象
    app.image = image;
    // 重新整理表格
    [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
}];

問題

  • 如果影象下載很慢,使用者滾動表格很快,會造成重複建立下載操作

  • 修改延時程式碼

// 1. 模擬延時
if (indexPath.row == 0) {
    [NSThread sleepForTimeInterval:10.0];
}

快速滾動表格,將第一行不斷“滾出/滾入”介面可以檢視操作被重複建立的問題

解決辦法

  • 操作緩衝池

操作緩衝池

緩衝池的選擇

所謂緩衝池,其實就是一個容器,能夠存放多個物件

  • 陣列:按照下標,可以通過 indexPath 可以判斷操作是否已經在進行中
    • 無法解決上拉&下拉重新整理
  • NSSet -> 無序的
    • 無法定位到快取的操作
  • 字典:按照key,可以通過下載影象的 URL(唯一定位網路資源的字串)

小結:選擇字典作為操作緩衝池

緩衝池屬性

///  操作緩衝池
@property (nonatomic, strong) NSMutableDictionary *operationCache;
  • 懶載入
- (NSMutableDictionary *)operationCache {
    if (_operationCache == nil) {
        _operationCache = [NSMutableDictionary dictionary];
    }
    return _operationCache;
}

修改程式碼

  • 判斷下載操作是否被快取——正在下載
// 非同步載入影象
// 0. 佔位影象
UIImage *placeholder = [UIImage imageNamed:@"user_default"];
cell.iconView.image = placeholder;

// 判斷操作是否存在
if (self.operationCache[app.icon] != nil) {
    NSLog(@"正在玩命下載中...");
    return cell;
}
  • 將操作新增到操作緩衝池
// 2. 將操作新增到操作緩衝池
[self.operationCache setObject:downloadOp forKey:app.icon];

// 3. 將下載操作新增到佇列
[self.downloadQueue addOperation:downloadOp];

修改佔位影象的程式碼位置,觀察會出現的問題

  • 下載完成後,將操作從緩衝池中刪除
[self.operationCache removeObjectForKey:app.icon];

迴圈引用分析!

  • 弱引用 self 的編寫方法:
__weak typeof(self) weakSelf = self;
  • 利用 dealloc 輔助分析
- (void)dealloc {
    NSLog(@"我去了");
}
  • 注意
    • 如果使用 self,檢視控制器會在下載完成後被銷燬
    • 而使用 weakSelf,檢視控制器在第一時間被銷燬

影象緩衝池

使用模型快取影象的問題

優點

  • 不用重複下載,利用MVC重新整理表格,不會造成資料混亂

缺點

  • 所有下載後的影象,都會記錄在模型中
  • 如果模型資料本身很多(2000),單純影象就會佔用很大的記憶體空間
  • 如果影象和模型繫結的很緊,不容易清理記憶體

解決辦法

  • 使用影象快取池

影象快取

  • 快取屬性
///  影象緩衝池
@property (nonatomic, strong) NSMutableDictionary *imageCache;
  • 懶載入
- (NSMutableDictionary *)imageCache {
    if (_imageCache == nil) {
        _imageCache = [[NSMutableDictionary alloc] init];
    }
    return _imageCache;
}
  • 刪除模型中的 image 屬性
  • 哪裡出錯改哪裡!

斷網測試

問題

  • image == nil 時會崩潰=>不能向字典中插入 nil
  • image == nil 時會重複重新整理表格,陷入死迴圈

解決辦法

  • 修改主執行緒回撥程式碼
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    if (image != nil) {
        // 設定模型中的影象
        [weakSelf.imageCache setObject:image forKey:app.icon];
        // 重新整理表格
        [weakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
    }
}];

程式碼重構

程式碼重構介紹

重構目的

  • 相同的程式碼最好只出現一次
  • 主次方法
    • 主方法
      • 只包含實現完整邏輯的子方法
      • 思維清楚,便於閱讀
    • 次方法
      • 實現具體邏輯功能
      • 測試通過後,後續幾乎不用維護

重構的步驟

  • 新建一個方法
    • 新建方法
    • 把要抽取的程式碼,直接複製到新方法中
    • 根據需求調整引數
  • 調整舊程式碼
    • 註釋原始碼,給自己一個後悔的機會
    • 呼叫新方法
  • 測試
  • 優化程式碼
    • 在原有位置,因為要照顧更多的邏輯,程式碼有可能是合理的
    • 而抽取之後,因為程式碼少了,可以檢查是否能夠優化
    • 分支巢狀多,不僅執行效能會差,而且不易於閱讀
  • 測試
  • 修改註釋
    • 在開發中,註釋不是越多越好
    • 如果忽視了註釋,有可能過一段時間,自己都看不懂那個註釋
    • .m 關鍵的實現邏輯,或者複雜程式碼,需要添加註釋,否則,時間長了自己都看不懂!
    • .h 中的所有屬性和方法,都需要有完整的註釋,因為 .h 檔案是給整個團隊看的

重構一定要小步走,要邊改變測試

重構後的程式碼

- (void)downloadImage:(NSIndexPath *)indexPath {

    // 1. 根據 indexPath 獲取資料模型
    AppInfo *app = self.appList[indexPath.row];

    // 2. 判斷操作是否存在
    if (self.operationCache[app.icon] != nil) {
        NSLog(@"正在玩命下載中...");
        return;
    }

    // 3. 定義下載操作
    __weak typeof(self) weakSelf = self;
    NSBlockOperation *downloadOp = [NSBlockOperation blockOperationWithBlock:^{
        // 1. 模擬延時
        NSLog(@"正在下載 %@", app.name);
        if (indexPath.row == 0) {
            [NSThread sleepForTimeInterval:3.0];
        }
        // 2. 非同步載入網路圖片
        NSURL *url = [NSURL URLWithString:app.icon];
        NSData *data = [NSData dataWithContentsOfURL:url];
        UIImage *image = [UIImage imageWithData:data];

        // 3. 主執行緒更新 UI
        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            // 將下載操作從緩衝池中刪除
            [weakSelf.operationCache removeObjectForKey:app.icon];

            if (image != nil) {
                // 設定模型中的影象
                [weakSelf.imageCache setObject:image forKey:app.icon];
                // 重新整理表格
                [weakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
            }
        }];
    }];

    // 4. 將操作新增到操作緩衝池
    [self.operationCache setObject:downloadOp forKey:app.icon];

    // 5. 將下載操作新增到佇列
    [self.downloadQueue addOperation:downloadOp];
}

記憶體警告

如果接收到記憶體警告,程式一定要做處理,日常上課時,不會特意處理。但是工作中的程式一定要處理,否則後果很嚴重!!!

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];

    // 1. 取消下載操作
    [self.downloadQueue cancelAllOperations];

    // 2. 清空緩衝池
    [self.operationCache removeAllObjects];
    [self.imageCache removeAllObjects];
}

黑名單

如果網路正常,但是影象下載失敗後,為了避免再次都從網路上下載該影象,可以使用“黑名單”

  • 黑名單屬性
@property (nonatomic, strong) NSMutableArray *blackList;
  • 懶載入
- (NSMutableArray *)blackList {
    if (_blackList == nil) {
        _blackList = [NSMutableArray array];
    }
    return _blackList;
}
  • 下載失敗記錄在黑名單中
if (image != nil) {
    // 設定模型中的影象
    [weakSelf.imageCache setObject:image forKey:app.icon];
    // 重新整理表格
    [weakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
} else {
    // 下載失敗記錄在黑名單中
    [weakSelf.blackList addObject:app.icon];
}
  • 判斷黑名單
// 2.1 判斷黑名單
if ([self.blackList containsObject:app.icon]) {
    NSLog(@"已經將 %@ 加入黑名單...", app.icon);
    return;
}

沙盒快取實現

沙盒目錄介紹

  • Documents
    • 儲存由應用程式產生的檔案或者資料,例如:塗鴉程式生成的圖片,遊戲關卡記錄
    • iCloud 會自動備份 Document 中的所有檔案
    • 如果儲存了從網路下載的檔案,在上架審批的時候,會被拒!
  • tmp

    • 臨時資料夾,儲存臨時檔案
    • 儲存在 tmp 資料夾中的檔案,系統會自動回收,譬如磁碟空間緊張或者重新啟動手機
    • 程式設計師不需要管 tmp 資料夾中的釋放
  • Caches

    • 快取,儲存從網路下載的檔案,後續仍然需要繼續使用,例如:網路下載的離線資料,圖片,視訊…
    • 快取目錄中的檔案系統不會自動刪除,可以做離線訪問!
    • 要求程式必需提供一個完善的清除快取目錄的”解決方案”!
  • Preferences

    • 系統偏好,使用者偏好
    • 操作是通過 [NSUserDefaults standardDefaults] 來直接操作

iOS 不同版本間沙盒目錄的變化

  • iOS 7.0及以前版本 bundle 目錄和沙盒目錄是在一起的
  • iOS 8.0之後,bundle 目錄和沙盒目錄是分開的

NSString+Path

#import "NSString+Path.h"

@implementation NSString (Path)

- (NSString *)appendDocumentPath {
    NSString *dir = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).lastObject;
    return [dir stringByAppendingPathComponent:self.lastPathComponent];
}

- (NSString *)appendCachePath {
    NSString *dir = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).lastObject;
    return [dir stringByAppendingPathComponent:self.lastPathComponent];
}

- (NSString *)appendTempPath {
    return [NSTemporaryDirectory() stringByAppendingPathComponent:self.lastPathComponent];
}

@end

沙盒快取

  • 將影象儲存至沙盒
if (data != nil) {
    [data writeToFile:app.icon.appendCachePath atomically:true];
}
  • 檢查沙盒快取
// 判斷沙盒檔案是否存在
UIImage *image = [UIImage imageWithContentsOfFile:app.icon.appendCachePath];
if (image != nil) {
    NSLog(@"從沙盒載入影象 ... %@", app.name);
    // 將影象新增至影象快取
    [self.imageCache setObject:image forKey:app.icon];
    cell.iconView.image = image;

    return cell;
}

iOS6 的適配問題

面試題:iOS 6.0 的程式直接執行在 iOS 7.0 的系統中,通常會出現什麼問題

  • 狀態列高度 20 個點是不包含在 view.frame 中的,self.view 的左上角原點的座標位置是從狀態列下方開始計算

    • iOS 6.0 程式直接在 iOS 7.0 的系統中執行最常見的問題,就是少了20個點
  • 如果包含有 UINavigationControllerself.view的左上角座標原點從狀態列下方開始計算

    • 因此,iOS 6.0的系統無法實現表格從導航條下方穿透的效果
  • 如果包含有 UITabBarControllerself.view的底部不包含 TabBar

    • 因此,iOS 6.0的系統無法實現表格從 TabBar 下方穿透效果

小結

程式碼實現回顧

  • tableView 資料來源方法入手
  • 根據 indexPath 非同步載入網路圖片
  • 使用操作緩衝池避免下載操作重複被建立
  • 使用影象緩衝池實現記憶體快取,同時能夠對記憶體警告做出響應
  • 使用沙盒快取實現再次執行程式時,直接從沙盒載入影象,提高程式響應速度,節約使用者網路流量

遺留問題

  • 程式碼耦合度太高,由於下載功能是與資料來源的 indexPath 繫結的,如果想將下載影象抽取到 cell 中,難度很大!

SDWebImage初體驗

簡介

  • iOS中著名的牛逼的網路圖片處理框架
  • 包含的功能:圖片下載、圖片快取、下載進度監聽、gif處理等等
  • 用法極其簡單,功能十分強大,大大提高了網路圖片的處理效率
  • 國內超過90%的iOS專案都有它的影子

演示 SDWebImage

  • 匯入框架
  • 新增標頭檔案
#import "UIImageView+WebCache.h"
  • 設定影象
[cell.iconView sd_setImageWithURL:[NSURL URLWithString:app.icon]];

思考:SDWebImage 是如何實現的?

  • 將網路圖片的非同步載入功能封裝在 UIImageView 的分類中
  • UITableView 完全解耦

要實現這一目標,需要解決以下問題:

  • UIImageView 下載影象的功能
  • 要解決表格滾動時,因為影象下載速度慢造成的圖片錯行問題,可以在給 UIImageView 設定新的 URL 時,取消之前未完成的下載操作

目標鎖定:取消正在執行中的操作!