UITableViewのreloadRows(at:indexPath:)などのメソッドを呼ぶとNSRangeExceptionが出る、という内容で相談を受けたのですが、解決するまでなかなか時間がかかったので詳細を書いておきます。
Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSArrayM objectAtIndex:]: index 4 beyond bounds [0 .. 3]'
問題と解決法
なにが悪かったの?
先に結論から書くと、tableView(cellForRowAt:indexPath)内で再度tableView(cellForRowAt:indexPath)を呼び出していたため… つまり、最初の呼び出しが完了してreturn cellする前に二度目の呼び出しが同じindexPathに対して行われたからでした。
もし同じ問題に直面している人は、これを回避すれば直ります。
そもそもなんでそんなコードを?
cellに表示する画像の縦横比にあわせてcellの高さを変更したいという要望があったので、画像のロードが完了した際に再度tableView(cellForRowAt:indexPath)を呼び出していました。
具体的には以下のようなコードで、画像のキャッシュがある場合は最初から高さを求められるので正しい高さを指定、キャッシュされていない場合は読み込んだ後にreloadをかけるという処理をしていました。
  private var cachedImagesAndHeights: [Int: (image: UIImage, height: CGFloat)] = [:]
  override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    if let height = cachedImagesAndHeights[indexPath.row]?.height {
      return height
    } else {
      return 80.0
    }
  }
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! TableViewCell
    
    if let image = cachedImagesAndHeights[indexPath.row]?.image {
      cell.contentImageView.image = image
    } else {
      let path = imagePaths[indexPath.row]
      let imageURL = URL(string: path)
      
      cell.contentImageView.sd_setImage(with: imageURL, completed: { [weak self] (image, error, cacheType, url) in
        guard let this = self,
          let image = image,
          error == nil
          else {
            print("Loading image failed with error: \(error)\n\(url)")
            return
        }
        
        let height = (tableView.frame.size.width * image.size.height) / image.size.width
        this.cachedImagesAndHeights[indexPath.row] = (image: image, height: height)
        
        print("\(#function) reloads [\(indexPath)]")
        tableView.reloadRows(at: [indexPath], with: .none) // ここをDispatchQueue.main.asyncで囲うと解決する
      })
    }
    
    print("\(#function) ends [\(indexPath)]")
    return cell
  }
このコードで問題となるのは、cell.contentImageView.sd_setImage(with:URL completed:)に渡しているcompletedクロージャは、画像キャッシュがないときは画像の読み込み後、つまりtableView(cellForRowAt:indexPath)の実行が終わった後に実行されるのですが、キャッシュがある場合はsd_setImage(with:URL completed:)の呼び出し時に即座に実行されるということです。
そうすると、tableView(cellForRowAt:indexPath)が多重に呼び出されることになり、内部的になんらかの不整合が起きた、ということのようです。
このコードの処理をそのままに解決するには、コメントにもあるようにtableView.reloadRows(at:indexPath with:animation)をDispatchQueue.main.async{}で囲えば良いのですが、最初はなぜそれで直るのかよくわかりませんでした。
これはログを見ると一目瞭然で、クラッシュする場合のログが
tableView(_:cellForRowAt:) reloads [[0, 0]]
tableView(_:cellForRowAt:) reloads [[0, 1]]
tableView(_:cellForRowAt:) ends [[0, 1]]
tableView(_:cellForRowAt:) reloads [[0, 2]]
tableView(_:cellForRowAt:) ends [[0, 2]]
tableView(_:cellForRowAt:) ends [[0, 0]]
tableView(_:cellForRowAt:) reloads [[0, 3]]
tableView(_:cellForRowAt:) ends [[0, 3]]
tableView(_:cellForRowAt:) reloads [[0, 4]]
tableView(_:cellForRowAt:) ends [[0, 4]]
tableView(_:cellForRowAt:) reloads [[0, 5]]
tableView(_:cellForRowAt:) ends [[0, 5]]
2017-04-17 21:36:18.124 OutOfBoundsTableView[81863:3174563] *** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSArrayM objectAtIndex:]: index 4 beyond bounds [0 .. 3]'
であるのに対し、DispatchQueue.main.async{}で囲った場合は
tableView(_:cellForRowAt:) ends [[0, 0]]
tableView(_:cellForRowAt:) ends [[0, 1]]
tableView(_:cellForRowAt:) ends [[0, 2]]
tableView(_:cellForRowAt:) ends [[0, 3]]
tableView(_:cellForRowAt:) ends [[0, 4]]
tableView(_:cellForRowAt:) ends [[0, 5]]
tableView(_:cellForRowAt:) reloads [[0, 0]]
tableView(_:cellForRowAt:) reloads [[0, 1]]
tableView(_:cellForRowAt:) reloads [[0, 2]]
tableView(_:cellForRowAt:) reloads [[0, 3]]
tableView(_:cellForRowAt:) reloads [[0, 4]]
tableView(_:cellForRowAt:) reloads [[0, 5]]
と、tableView(cellForRowAt:indexPath)の実行後にcompletedクロージャが呼ばれていることがわかります。
completedクロージャがもともとメインスレッドで呼び出されることを考えるとDispatchQueue.main.async{}は不要と感じられるのですが、今回問題を解決したのはDispatch Queue の方で、要するにメインスレッドから呼び出されたので、スレッドが空くまで処理がキューに置かれていて、その結果多重呼び出しが防がれた、というわけです。
理屈が分かってしまえば簡単なのですが、愚直に解決しようとしてえらい苦労をしました。
参照
問題が起きる処理を再現して解決までできたプロジェクトをここに上げてあります。
https://github.com/mitsuyoshi-yamazaki/OutOfBoundsTableView