原文:Advanced issues: Asynchronous UITableViewCell content loading done right
有什么问题?
你是否经常好奇,为什么你UITableView的加载往往“几乎”是完美的?我的意思是,你已经明确地告诉了iOS,所有单元格的工作(例如从远程URL加载图片或者渲染内容)都要在后台线程中异步地完成计算。但是有时候这还不够,主要有两个原因:
- 当一个cell离开了可见区域时,你所调用的异步操作仍然在工作。这通常会造成不必要的对系统资源的使用,甚至会由不知道哪个cell完成的异步任务返回而产生bug般的结果。
- UITableViewCells通常是被复用的实例。也就是说被加载到view的cell所持有的数据原本是属于另一个完全不同的cell的。这常常会造成一种令人沮丧的,被称作“Cell switching"的bug。
问题解决啦!
首先,你要明白解决这个问题的方法有很多——有些非常好,而有些则,呃,很恶心。我接下来准备介绍的方法基于一个Apple提供的demo(WWDC 2012 session 211),你知道的,这些家伙非常精通iOS。
在我们的例子里,我将使用一个简单的UITableView实例,用于显示你的Facebook好友的名字和资料照片。核心思路是:我们需要在UITableViewDataSource的tableView:cellForRowAtIndexPath:代理方法被调用时加载资料照片。如果操作成功,并且该cell依然在view当中,我们就简单地把图片添加到cell里用于显示详情照片的image view即可。(这一操作需要在主线程中完成)。否则的话,我们要确保我们并没有执行setImage操作。
在开始之前,有一些准备工作:为后台运行的operations定义一个NSOperationQueue,我们在这里称之为imageLoadingOperationQueue。同样的,为存储指向特定operations的引用对象们定义一个NSMutableDictionary——在这个示例中我们将为Facebook的唯一id和oprations对象在facebookUidToImageDownloadOperations字典建立map映射。
最重要的部分已经写在代码注释中,看完注释你就明白了:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
FacebookFriend *friend = [self.facebookFriends objectAtIndex:indexPath.row];
FacebookFriendCell *cell = [tableView dequeueReusableCellWithIdentifier:FB_CELL_IDENTIFIER forIndexPath:indexPath];
cell.lblName.text = friend.name;
//为加载图片到资料image view的操作创建一个block operation
NSBlockOperation *loadImageIntoCellOp = [[NSBlockOperation alloc] init];
//定义一个weak operation,以便在block内引用而不用产生引用循环
__weak NSBlockOperation *weakOp = loadImageIntoCellOp;
[loadImageIntoCellOp addExecutionBlock:^(void){
//一些异步任务。一旦Image下载完成,它将会在主线程被加载到view当中
UIImage *profileImage = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:friend.imageUrl]]];
[[NSOperationQueue mainQueue] addOperationWithBlock:^(void) {
//在执行操作前检查操作是否已经取消。我们使用cellForRowAtIndexPath来确保对于不可见cell会返回nil
if (!weakOp.isCancelled) {
FacebookFriendCell *theCell = (FacebookFriendCell *)[tableView cellForRowAtIndexPath:indexPath];
[theCell.ivProfile setImage:profileImage];
[self.facebookUidToImageDownloadOperations removeObjectForKey:friend.uid];
}
}];
}];
//在NSMutableDictionary中保存一个指向operation的引用,以便稍后可以取消
if (friend.uid) {
[self.facebookUidToImageDownloadOperations setObject:loadImageIntoCellOp forKey:friend.uid];
}
//将operation添加到指定的后台队列
if (loadImageIntoCellOp) {
[self.imageLoadingOperationQueue addOperation:loadImageIntoCellOp];
}
//确保cell不会持有任何来自重用的数据的痕迹-
//这里是一个assign placeholder的好地方
cell.ivProfile.image = nil;
return cell;
}
现在剩下的事情就是利用iOS 6.0中引入的新的UITableViewDelegate方法:tableView:didEndDisplayingCell:forRowAtIndexPath:,在我们不再需要加载数据到单元格时调用它。
下面的代码似乎是获取相关的operation并且取消它的最佳位置:
- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
FacebookFriend *friend = [self.facebookFriends objectAtIndex:indexPath.row];
//Fetch operation that doesn't need executing anymore
NSBlockOperation *ongoingDownloadOperation = [self.facebookUidToImageDownloadOperations objectForKey:friend.uid];
if (ongoingDownloadOperation) {
//Cancel operation and remove from dictionary
[ongoingDownloadOperation cancel];
[self.facebookUidToImageDownloadOperations removeObjectForKey:friend.uid];
}
}
同样的,当table不再被需要时,不要忘记利用*** NSOperationQueue 并且调用cancelAllOperations***:
- (void)viewDidDisappear:(BOOL)animated {
[self.imageLoadingOperationQueue cancelAllOperations];
}
搞定!现在你自己也拥有了流畅如法拉利般的UITableView啦,不用谢~