Swift 踩坑笔记 —— UITableView Cell初始化和刷新的问题探讨

6,173 阅读6分钟

综述

讲到 UITableView,大家一定都不陌生。有一个相对夸张的说法,叫做学好 UITableView,你就是一名合格的iOS 工程师

闲话少说,最近在写 Swift 的过程中碰到了以下几个问题,特别在此记录。

遇到的问题

  • cellForRowAtIndexPath 代理中,对 cell(尤其是自定义cell) 的初始化异同
    • OC的区别 —— 不能使用OC的那种判空方式来初始化
    • 初始化不能使用自定义的方法 —— 通过dequeue方法得到的cell 永远都是非空的,换言之,即便你自定义了一个初始化方法,它也不会被执行到。
    • 通过渲染方式(render)来绘制图像,赋值
    • 理解cell的复用机制
  • 刷新的问题
    • 使用 reloadData时候,在iOS 11 上会产生抖动
    • insertRowdeleteRowreloadRows 一样都属于局部刷新的范畴,局部刷新时,系统会创建一个新的cell来,并和旧的cell在刷新时来回切换。

先明确几个概念

  • 代码中的 setup 表示只会执行一次,而且在 cell 的初始化中表示他的绘图(不带数据)也只会执行一次
  • 代码中的render 表示渲染,实际上是意味着setup已经完成了绘图,我要在每次重用时把数据传进去渲染

重申 Cell 的复用机制和使用

简单的来说,tableview 的复用机制是我们在 cellForRowAtIndexPath 的一系列操作。

  • CellUI 一旦被创建,系统就会存放在复用池中等待复用。
  • Cell 的可变内容(通常是labeltextimage的内容,选中的背景色等),是不会记录的。
  • 删除某个 Cell 后再创建一个新的 Cell, 实际上你会发现新的 Cell 中有部分 UI 时旧 Cell中的
  • reloadRows 局部刷新时会创建新的 Cell,再刷新时会和旧的Cell来回切换

很简单的情况是,如果我们不每次滚动的时候去dataSource数组中把对应index的数值取出来,只管的感受就是UI虽然固定,但是数据和图片一直在乱跑

鉴于Swift 无法自定义cell的初始化,那么上下滚动时,怎么重新赋值而不重复绘制就显得格外重要。

关于 cellForRowAtIndexPath 的初始化问题其实在这篇文章中已经讨论过,这里不作赘述 Swift 踩坑笔记(二)—— 初始化Tableview 及自定义 TableviewCell

我们要讨论的是在Cell复用过程中的赋值和 UI 重叠的问题。

典型案例 —— Cell 的 UI 内容根据数据而定

描述

根据上面所说的,CellUI 在被创建后,就会被放进复用池中,等待被重用。但是如果像下面这种情况:

一个TableView 中每个Cell 的内容是根据数据中数组的个数来渲染的,就会出问题:

image.png
我们这里的 Cell 分了很多层级,

除了顶部的 Header区域是固定知道的高度外,下面的 区域 InfoA, InfoB, InfoC ...等等,都是根据具体的信息去绘制的。 换言之,我不知道每个 Cell 具体要画几个 InfoX

这样会造成一个很大的问题:

  • 因为根据复用机制,数据是每次都有可能不同的,而根据数据创建的 UI 一旦被创建,就会一直存在于复用池中。
  • 如果 Cell 发生了删除,再添加,就有可能将那些不用的Cell UI 复用进来。
  • 局部刷新时会创建新的 Cell,这时候叠加在旧的UI上切换时,就会造成视图的重叠

来看下错误的现象图

局部刷新的效果

局部刷新的效果.gif

使用 reveal 查看,发现多了一个层级UI,盖在应该有的位置()

image.png

正确的代码

为了避免混淆,我这里就不贴原来错误的代码了。

来看下面正确的代码

// tableview 代理
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: someCellID, for: indexPath) as! MyCell
    cell.renderCell(info: dataSource[indexPath.row])
    return cell
}

思路:

  • 上面的图中,Header的部分是固定的,也就是不是动态变化的 UI,因此每次render的时候只要重新赋值即可
  • 而下面的infoA, infoB, infoC...是根据数值来变化的。我们现在能做的就是对于动态的 Cell UI,先把这几个 subViewremoveFromSuperView 避免干扰,然后setUp重绘一次,再render进赋值。

再来看下面的这段 自定义 Cell 的代码

  // 略去类的初始化,这里为了  render ,去持有静态的 UI
    private var headerBaseInfoView: BaseInfoView = BaseInfoView()

    public func renderCell(info: accountModel) {
    // 除了静态的 UI,剩下的都remove 掉,避免重用时的干扰
        for view in contentView.subviews {
            guard view != headerBaseInfoView else {
                continue
            }
            view.removeFromSuperview()
        }
        
        headerBaseInfoView.render(renderInfo: info.baseInfo!)
        setupAndRenderInfoViews(bindInfos)
    }
    
    private func setupAndRenderInfoViews(_ bindInfos: [infoModel]) {
        var infoViews: [infoView] = []
        for (index, bindInfo) in bindInfos.enumerated() {
            // 创建后渲染数据
            let bindInfoView = InfoView()
            bindInfoView.render(bindInfo: bindInfo)
            
            // 布局 (也可以先布局再渲染数据,这无所谓)
            contentView.addSubview(bindInfoView)
            bindInfoView.snp.makeConstraints { (make) in
                //这里略去约束的部分
            }
            infoViews.append(bindInfoView)
        }
    }

下面是讲解:

  • 类中要去持有静态的视图,作为属性内容。
  • headerBaseInfoView 是固定的内容,所以实际上我们在重写他的初始化方法的时候,直接就把 setupUI()(只会执行一次)这个绘图的工作做掉了
  • infoViews 属于我一开始没办法知道你有几个,所以我无法初始化。只在每次渲染数据的时候:
    • 先将所有动态视图remove
    • 根据数据内容重新渲染视图并赋值(也可以先赋值再渲染数据,不影响)

刷新的问题

先来说说 reloadData的缺点

  • 性能问题 我们都知道,UITableviewreloadData 是需要慎用的。因为他会将整个tableview 都刷新一遍。这意味着也许我只需要刷新2个cell,你却让所有的cell都重渲染了一遍。从性能而言这显然是不可取的。 所以我们才会想到去用局部刷新。

  • reloadData 无法像系统提供的其他刷新方法一样,带有animate参数,这让刷新时,整个页面看起来非常突兀。如果你不自己加动画,那么体验真的不太好

  • iOS 11 上会有一个问题,就是重载之后页面会乱跑:

    页面乱跑.gif

    • 解决办法: google后,得到的内容是说 Self-Sizing在iOS11下是默认开启的,Headers, footers, and cells都默认开启Self-Sizing,所有estimated 高度默认值从iOS11之前的 0 改变为UITableViewAutomaticDimension

      if #available(iOS 11.0, *) {
        taleview.estimatedRowHeight = 0
        taleview.estimatedSectionHeaderHeight = 0
        taleview.estimatedSectionFooterHeight = 0
      }
      

局部刷新的问题

鉴于上面讲的reloadData,我们很自然的就会想到使用局部刷新来做。

tableview.beginUpdates()
tableview.reloadRows(at: tableview.indexPathsForVisibleRows!, with: .none)
tableview.endUpdates()

实际上和 reload 没有太多的差异,只是注意局部刷新,会创建新的Cell

下面两篇文章也提到了类似的问题。 参考文章一 慎用局部刷新


因为之前对重用机制的理解存在误区,所以文章内容更新了。