危险的 UITableView

2,431 阅读5分钟
原文链接: mp.weixin.qq.com

如果把我们所做的UI做个简单分类,大致上可以分为列表界面和非列表界面。对于列表类UI,我们可以选择UITableView或者UICollectionView来实现。UICollectionView出现之前,UITableView几乎是唯一的选择,这每日可见人人都用的UITableView里隐藏着容易忽视的危险。

同步VS异步

同步和异步是基础的编程概念,也是贯穿于我们日常的两种代码书写方式。理解sync和async不仅仅在于明白代码执行顺序上的差异,更重要的是理解这两种方式的差异对我们代码健壮性的影响。

同步的代码书写方式很直观,也是大部分初学者潜意识所选择的方式。我们只需要把心中的思路按部就班的转换成代码,就形成了一段同步的逻辑,比如下面一段同步代码:

self.arr = @[].mutableCopy;
for (int i = 0; i < 1000; i ++) {
  [self.arr addObject:@(i)];
}

同步强调的流程是:我此刻拥有哪些数据,此刻对这些数据进行一些计算,进而利用计算结果在此刻产生更多的行为。同步意味着在当下一步一步按顺序的完成逻辑。

当我们代码越写越多,手感变好之后,我们会写更多的异步代码。异步在执行的时间上和同步刚好相反,异步强调的是代码当下并不执行,而是等待未来某个时机到来之后再发生。比如下面一段异步代码:

self.arr = @[].mutableCopy;
//async
[operation setCompletionBlockWithSuccess:^(AFHTTPRequestOperation *operation, id responseObject) {  
  for (int i = 0; i < 1000; i ++) {
    [wself.arr addObject:@(i)];
  }
} failure:nil];

对于_arr的修改操作,是在网络请求完成之后再执行的。

异步的好处在于表达能力更灵活更强,我们对于跨越某个时间段的流程,有了更好的表达方式。这几年很受技术圈热捧的Reactive Programming,精髓之一就在于异步。

Wild beast is dangerous,异步的缺点也很明显,由于跨域了一定的时间区域,在异步操作真正发生的时候,我们程序所依赖的状态很有可能在这一时间跨度内发生了意料之外的变化,进而导致奇奇怪怪的bug。具体到上面这段代码,很有可能在执行[_arr addObject:@(i)];的时候, self.arr已经被某处代码改为nil了。我在之前介绍函数式编程的文章中也提到了这点,赋值操作会随着时间的变化而危险起来,而Functional Programming恰好可以帮助我们解决状态维护在时间维度上的问题,这也是为什么异步的响应式编程总是和无状态的函数式编程结对出现,双剑合并成FRP。

我们再来看看UITableView中的同步与异步。

UITableView

标题中所说的危险之处正是在于异步。更具体点来说,是reloadData这个调用中所包含的异步操作。先来看看执行 reloadData都发生了什么。

当我们reloadData的时候,我们本意是刷新 UITableView,随后会进入一系列UITableViewDataSourceUITableViewDelegate的回调,其中有些是和reloadData同步发生的,有些则是异步发生的。

我们熟悉的下面两个回调是同步的:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{  
  return 20;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{  
  return _arr.count;
}

而另一个最常使用的回调则是异步的:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{  
  //...
  NSNumber* content = _arr[indexPath.row];  
   //...
  }

经过上面的分析,我们不难UITableView的危险之处在于哪了,在于异步执行cellForRowAtIndexPath的时候,我们所依赖的状态可能会发生变化,上面代码中的_arr如果元素被修改过,极有可能发生数组越界的异常。

当列表界面数据不怎么变化的时候,几乎感知不到这种异常的存在,因为reloadData返回之后,下一次loop就开始执行异步的操作了。但是当列表界面的数据有可能经常变化的时候,尤其是在多线程的场景下,就会出现偶现的bug了。

实际上,所有在函数内部处理外部状态的场景,我们都需要假设状态是不安全的,有可能被修改过了,使用前尽可能的检查各种边界条件,这样的代码才足够robust,当然啦,能不依赖外部状态是最好不过了。

如何解决

解决的方式五花八门,我相信很多人都有自己的独门秘技,不过关键应该都在于消除异步带来的状态不稳定。

方式一:

最直观的,我们可以在执行_arr[indexPath.row];的时候,做下长度检查,如果越界则返回空:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{  
  if (indexPath.row > self.arr.count - 1 || indexPath.row == 0) {   
   return [UITableViewCell new];
  }  
   //...
  NSNumber* content = _arr[indexPath.row];  
   //...
  }

这种方式粗暴有效,可以避免crash。

方式二:

或者我们可以采用前面文章中提到的throttle机制,控制刷新事件的产生频率,建立一个Queue以一定的时间间隔来调用reloadData。事实上这是一种很常见的界面优化机制,对于一些刷新频率可能很高的列表界面,比如微信的会话列表界面,如果很长时间没有登录了,打开App时,堆积了很久的离线消息会在短时间内,导致大量的界面刷新请求,频繁的调用reloadData还会造成界面的卡顿,所以此时建立一个FIFO的Queue,以一定的间隔来刷新界面就很有必要了,这种做法代码量会多一些,但体验更好更安全,具体代码我就不展示了,实现起来也不难。

总结

这篇文章虽然是分析UITableView,但本意其实是想和大家分享异步代码中所存在的隐患。无论是系统API调用,还是平常做业务时所写的异步流程,异步的思想随处可见,我们要极其小心当中可能存在的状态维护上的坑。

欢迎关注公众号:MrPeakTech