TableView 图片加载逻辑优化
table view 是 scroll view 的子类,我们先来研究一下 scroll view。
当 scroll view 开始滚动时,scroll view 的 delegate 会不断的收到很多回调消息,这些消息告诉我们 scroll view 处于什么状态。通过官方的文档,我们知道用户在和 scroll view 交互时有两种手势,一种是 flick(轻扫),一种是 drag(拖动)。
但是在实际的操作过程中,用户很可能会带来更复杂的交互,经过实践归纳总结,我将它们大概分成四种情况,如下图:
ScrollView.png- 橙色线,用户用手指轻扫一下屏幕,然后释放手指。
- 橙色线->绿色线->蓝色线,用户拖动 scroll view,并在 scroll view 处于静止时释放手指。
- 橙色线->红色线->…->橙色线,用户在 scroll view 停止滚动前,不断的轻扫屏幕,最后释放手指。
- 橙色线->红色线->…->橙色线->绿色线->紫色线,用户在轻扫屏幕后,手指突然定住 scroll view,最后释放手指。
scroll view 还有三个属性:tracking、dragging 和 decelerating。这三个属性在 scroll view 的滚动中不断变化,但是它们的功能却和他们的名字不太一样。
首先当用户触摸屏幕时,tracking 和 dragging 的值都为 true,decelerating 的值为 false。
一旦用户的手指脱离屏幕,如果此时有动量,scroll view 开始减速,tracking 的值会变为 false,而 dragging 的值要等到 scroll view 停止滚动后才变为 false。所以 tracking 更像是记录拖动的属性,dragging 更像是记录滚动的属性。
至于 decelerating,当然是在减速过程中为 true,值得注意的是,如果 scrollViewDidEndDecelerating 一直未被调用,对应上面的情况三,此时 decelerating 一直为 true。
知道这些基础的东西之后,我们来尝试优化 table view 加载图片的逻辑。
Glow 的这篇博文中提到了当年 Tweetie 的方案以及自己的优化方案,大概的思路就是,当用户在快速滑动 table view 时,cell 不加载图片,对应上面的情况三。那么如何定义这个快速滑动的情况呢?
快速滑动就是用户不断的 flick 屏幕,也就是在上一次 scroll view 还未停止的时候开始下一次短暂拖动,这就导致 scroll view 一直处于滚动状态。
我们先来看看快速滑动过程中,scroll view 三个状态量的值,通过上图我们知道,tracking 只在每次用户触摸的时候才为 true,而 dragging 全程都为 true,decelerating 除了第一次拖动的过程中为 false,其余时间只要 scroll view 未停止滚动,都为 true。
如果我们想单纯的让 table view 在快速滚动的过程中不加载图片的话,我们会写出这样的代码:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath)
if tableView.decelerating {
cell.imageView?.image = placeholder
} else {
setImageFor(cell, forRowAtIndexPath: indexPath)
}
return cell
}
Glow 在这里还用了一个 userDragging 的变量来表明用户是否在拖拽 scroll view,方法是在 willBeginDragging 中设为 YES
,didEndDragging 中设为 NO
。我们通过上面的分析知道,tracking 属性貌似也有同样的作用。经过测试,唯一的区别是,tracking 在手指触摸后立即变为 true,等到开始减速时才变为 false,不过这个时间相差的简直太短,基本可以忽略不计了。
继续谈到这个变量的作用,Glow 在这里的判断是这样写的:
if !tableView.tracking && tableView.decelerating {
cell.imageView?.image = placeholde
} else {
setImageFor(cell, forRowAtIndexPath: indexPath)
}
判断的意思是,如果用户没有拖拽并且 table view 处于减速中的话就不加载图片,反之加载图片。
但是这样做有一个问题,用户在快速滑动的过程中会不时的拖动屏幕一小段距离,这段距离过程中出现的 cell 会加载图片,这样看来是比较奇怪的,不太符合在快速滑动的过程中不在加载图片的设想。
只做 decelerating 的判断可以让 table view 在快速滚动的过程中做到完全不加载图片。
但是这样做还是会产生两个问题,
1,当 table view 在停止快速滑动正常减速结束后,当前屏幕中的 cell 不会被加载。
这个问题比较好解决,Glow 提到的解决方法是在 scrollViewDidEndDecelerating
里面加载当前可见 cell 的图片,代码如下:
func loadVisibleCellsImage() {
for cell in tableView.visibleCells {
let indexPath = tableView.indexPathForCell(cell)!
setImageFor(cell, forRowAtIndexPath: indexPath)
}
}
这样确实解决了问题,但是却带来了新的问题,必须要等到 scroll view 完全减速完成后才会加载图片,但是 scroll view 减速的那最后一段距离实在让人难等。Glow 通过 scrollViewWillEndDragging: withVelocity: targetContentOffset:
方法的 targetContentOffset 计算出 targetRect 来加载最后可见的那些 cell。
具体来说,就是用一个变量 targetRect 在 scrollViewWillEndDragging: withVelocity: targetContentOffset:
方法里面计算出值,在 scrollViewWillBeginDragging
和 scrollViewDidEndDecelerating
重置为 nil。这样一来,targetRect 还起到了之前 userDragging 的作用,可谓一举两得。
这里根据这个思路我也想到一个办法,通过构造一个 scrollViewWillEndDecelerating:scrollView:withTargetContentOffset
方法来让 table view 在最后的减速过程中提前加载图片,代码如下:
var targetContentOffset: CGPoint?
var space: CGFloat = 20
override func scrollViewDidScroll(scrollView: UIScrollView) {
if let targetContentOffset = self.targetContentOffset where abs(scrollView.contentOffset.y - targetContentOffset.y) < space {
scrollViewWillEndDecelerating(scrollView, withTargetContentOffset: targetContentOffset)
self.targetContentOffset = nil
}
}
override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
if velocity != CGPoint.zero {
self.targetContentOffset = targetContentOffset.memory
}
}
func scrollViewWillEndDecelerating(scrollView: UIScrollView, withTargetContentOffset targetContentOffset: CGPoint) {
// 快要结束减速
}
通过调整 space 的值,你可以调整方法的调用时机,来获得更好的体验。
问题 1 到这里算是解决了。
问题 2 是这样的,当 table view 在快速滑动中被用户手指点击强制结束滑动后,当前屏幕的 cell 不会被加载。
这个问题一开始看起来貌似很简单,因为点击滑动中的 scroll view,scrollViewWillBeginDragging 会被调用一次,直接在这里面调用之前的 loadVisibleCellsImage,不就好了吗?但问题是只有当你抬起手指的时候才会调用,如果用户一直不抬起手指,这个方法就一直不会被调用。况且,这个函数在你快速滑动 scroll view 的时候也会被调用,那么之前所做的工作完全就无用了。
我把问题集中在如何判断 table view 被点击上来,这里可以通过重写 table view 的 hitTest 来实现,尽管这样,还是会遇到快速滑动中被不断调用的问题。
后来我又想是否有方法判断 scroll view 停止滚动,速度为 0 的方法,找了很久,无一成功。
问题二到现在没办法解决。
当然,我这是极致强调 scroll view 只要在减速过程中就不允许加载图片的时候才会导致问题二没法解决,如果按照 Glow 的方法,虽然在滚动的过程中不会加载图片,但是每次拖动都会加载可见的图片。
到这里,Glow 的处理方法感觉还是值得一用,而且配合 SDWebImage 的图片缓存,感觉上还是会有一些提升。
后记
提到 SDWebImage,我突然想到在下载图片的时候,它首先会取消 imageView 还未完成的下载,然后再从缓存或从网络中加载图片。
我们知道 table view cell 的重用机制,cell 移出屏幕会加入到重用池,等到需要时,重用池的 cell 会被取出重用。我想这会不会已经解决了快速滑动过程中图片的加载问题呢?
思考一下,在快速滑动中,cell 不断的被重用,也就是 imageView 可能还没加载完图片当前的加载就被取消了,那不正好解决了问题吗?
如果这样可行,不再去管 scroll view 滚动过程中的各种回调,还可以值得优化的地方是,刚刚说的 imageView 的图片加载被取消,注意这里的取消是不管有没有图片的缓存都会被取消,所以在快速滚动的过程中之前有缓存的 cell 还是没有图片,所以最好的是先从缓存中尝试取图片,如果实在没有再加载图片。
结语
上面的所有文字仅仅基于我自己的思考和实践,但是没有在具体的项目工程中运用过,并且这里只是讨论了基于 scroll view 的优化,没涉及绘制方面的内容。如果你有更好的方法,欢迎评论分享。
更新计划
Runloop 貌似还可以在做很多事情,慢慢研究。