本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 HelloWorld杰少 即可关注。
相信大家平时在用 App 的时候, 往往有过这样的体验,那就是加载网络数据等待的时间过于漫长,滚动浏览时伴随着卡顿,甚至在没有网络的情况下,整个应用处于不可用状态。那么我们该怎么去提高用户体验,保证用户没有漫长的等待感,还可以轻松自在的享受等待,对加载后的内容有明确的预期呢?
案例分享
在现代的工作生活中,手机早已不是单纯的通信工具了,它更像是一个集办公,娱乐,消费的终端,潜移默化的成为了我们生活的一部分。所以作为 iOS 开发者的我们,在日常的开发中,也早已不是处理显示零星的数据这么简单,为了流量往往我们需要在 App 里显示大量有价值的信息来吸引用户,如何优雅的显示这些海量的数据,考量的就是你的个人经验了。
正如大多数 iOS 开发人员所知,显示滚动数据是构建移动应用中常见的任务,Apple 的 SDK 提供了 UITableView 和 UICollectionVIew 这俩大组件来帮助执行这样的任务。但是,当需要显示大量数据时,确保平滑如丝的滚动可能会非常的棘手。所以今天正好趁这个机会,和大家分享一下处理大量可滚动数据方面的个人经验。
在这篇文章中,你将会学到以下内容:
1.让你的 App 可以无限滚动(infinite scrolling),并且滚动数据无缝加载
2.让你的 App 数据滚动时避免卡顿,实现平滑如丝的滚动
3.异步存储(Cache)和获取图像,来使你的 App 具有更高的响应速度
无限滚动,无缝加载
提到列表分页,相信大家第一个想到的就是 MJRefresh,用于上拉下拉来刷新数据,当滚动数据到达底部的时候向服务器发送请求,然后在控件底部显示一个 Loading 动画,待请求数据返回后,Loading 动画消失,由 UITableView 或者 UICollectionView 控件继续加载这些数据并显示给用户,效果如下图所示:
在这种情况下就造成了一种现象,那就是 App 向服务器请求数据到数据返回这段时间留下了一个空白,如果在网络差的情况下,这段空白的时间将会持续,这给人的体验会很不好。那该如何去避免这种现象呢!或者说我们能否去提前获取到其余的数据,在用户毫无感知的情况下把数据请求过来,看上去就像无缝加载一样呢!
答案当然是肯定的!
为了改善应用程序体验,在 iOS 10 上,Apple 对 UICollectionView 和 UITableView 引入了 Prefetching API,它提供了一种在需要显示数据之前预先准备数据的机制,旨在提高数据的滚动性能。
首先,我先和大家介绍一个概念:无限滚动,无限滚动是可以让用户连续的加载内容,而无需分页。在 UI 初始化的时候 App 会加载一些初始数据,然后当用户滚动快要到达显示内容的底部时加载更多的数据。
多年来,像 Instagram, Twitter 和 Facebook 这样的社交媒体公司都使这种技术。如果查看他们的 App ,你就可以看到无限滚动的实际效果,这里我就给大伙展示下 Instagram 的效果吧!
如何实现
由于 Instagram 的 UI 过于复杂,在这我就不去模仿实现了,但是我模仿了它的加载机制,同样的实现了一个简单的数据无限滚动和无缝加载的效果。
简单的说下我的思路:
先自定义一个 Cell 视图,这个视图由一个 UILabel 和 一个 UIImageView 构成,用于显示文本和网络图片;然后模拟网络请求来获取数据,注意该步骤一定是异步执行的;最后用 UITableView 来显示返回的数据,在 viewDidLoad 中先请求网络数据来获取一些初始化数据,然后再利用 UITableView 的 Prefetching API 来对数据进行预加载,从而来实现数据的无缝加载。
那关于无限滚动该如何实现呢!其实这个无限滚动并不是真正意义上的永无止尽,严格意义上来讲它是有尽头的,只不过这个功能背后的数据是不可估量的,只有大量的数据做支持才能让应用一直不断的从服务端获取数据。
正常情况下,我们在构建 UITableView 这个控件的时候,需要对它的行数(numsOfRow)做一个初始化,这个行数对我们实现无限加载和无缝加载是一个很关键的因素,假设我们每次根据服务端返回的数据量去更新 UITableView 的行数并 Reload,那我之前说的 Prefetching API 在这种情况下就失去作用了,因为它起作用的前提是要保证预加载数据时 UITableView 当前的行数要小于它的总行数。当然前者也可以实现数据加载,但它的效果就不是无缝加载,它在每次加载数据的时候都会有一个 Loading 等待的时间。
回到我上面所说的无限滚动, 其实实现起来并不难,正常情况下,我们向服务端请求大量相同类型的数据的时候,都会提供一个接口,我称之为分页请求接口,该接口在每次数据返回的时候,都会告诉客户端总共有多少页数据,每页的数据量是多少,当前是第几页,这样我们就能计算出来总共的数据有多少,而这就是 UITableView 的总行数。
响应数据的示范如下(为清楚起见,它仅显示与分页有关的字段):
{
"has_more": true,
"page": 1,
"total": 84,
"items": [
...
...
]
}
下面,我就用代码来一步步的实现它吧!
模拟分页请求
由于没有找到合适的分页测试接口,于是我自己模拟一了一个分页请求接口,每次调用该接口的时候都延时 2s 来模拟网络请求的状态,代码如下:
func fetchImages() {
guard !isFetchInProcess else {
return
}
isFetchInProcess = true
// 延时 2s 模拟网络环境
print("+++++++++++ 模拟网络数据请求 +++++++++++")
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 2) {
print("+++++++++++ 模拟网络数据请求返回成功 +++++++++++")
DispatchQueue.main.async {
self.total = 1000
self.currentPage += 1
self.isFetchInProcess = false
// 初始化 30个 图片
let imagesData = (1...30).map {
ImageModel(url: baseURL+"\($0).png", order: $0)
}
self.images.append(contentsOf: imagesData)
if self.currentPage > 1 {
let newIndexPaths = self.calculateIndexPathsToReload(from: imagesData)
self.delegate?.onFetchCompleted(with: newIndexPaths)
} else {
self.delegate?.onFetchCompleted(with: .none)
}
}
}
}
数据回调处理:
extension ViewController: PreloadCellViewModelDelegate {
func onFetchCompleted(with newIndexPathsToReload: [IndexPath]?) {
guard let newIndexPathsToReload = newIndexPathsToReload else {
tableView.tableFooterView = nil
tableView.reloadData()
return
}
let indexPathsToReload = visibleIndexPathsToReload(intersecting: newIndexPathsToReload)
indicatorView.stopAnimating()
tableView.reloadRows(at: indexPathsToReload, with: .automatic)
}
func onFetchFailed(with reason: String) {
indicatorView.stopAnimating()
tableView.reloadData()
}
}
预加载数据
首先如果你想要 UITableView 预加载数据,你需要在 viewDidLoad() 函数中插入如下代码,并且请求第一页的数据:
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
...
tableView.prefetchDataSource = self
...
// 模拟请求图片
viewModel.fetchImages()
}
然后,你需要实现 UITableViewDataSourcePrefetching 的协议,它的协议里包含俩个函数:
// this protocol can provide information about cells before they are displayed on screen.
@protocol UITableViewDataSourcePrefetching <NSObject>
@required
// indexPaths are ordered ascending by geometric distance from the table view
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;
@optional
// indexPaths that previously were considered as candidates for pre-fetching, but were not actually used; may be a subset of the previous call to -tableView:prefetchRowsAtIndexPaths:
- (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;
@end
第一个函数会基于当前滚动的方向和速度对接下来的 IndexPaths 进行 Prefetch,通常我们会在这里实现预加载数据的逻辑。
第二个函数是一个可选的方法,当用户快速滚动导致一些 Cell 不可见的时候,你可以通过这个方法来取消任何挂起的数据加载操作,有利于提高滚动性能, 在下面我会讲到。
实现这俩个函数的逻辑代码为:
extension ViewController: UITableViewDataSourcePrefetching {
// 翻页请求
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}
if needFetch {
// 1.满足条件进行翻页请求
indicatorView.startAnimating()
viewModel.fetchImages()
}
for indexPath in indexPaths {
if let _ = viewModel.loadingOperations[indexPath] {
return
}
if let dataloader = viewModel.loadImage(at: indexPath.row) {
print("在 \(indexPath.row) 行 对图片进行 prefetch ")
// 2 对需要下载的图片进行预热
viewModel.loadingQueue.addOperation(dataloader)
// 3 将该下载线程加入到记录数组中以便根据索引查找
viewModel.loadingOperations[indexPath] = dataloader
}
}
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){
// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费
indexPaths.forEach {
if let dataLoader = viewModel.loadingOperations[$0] {
print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")
dataLoader.cancel()
viewModel.loadingOperations.removeValue(forKey: $0)
}
}
}
}
最后,再加上俩个有用的方法该功能就大功告成了:
// 用于计算 tableview 加载新数据时需要 reload 的 cell
func visibleIndexPathsToReload(intersecting indexPaths: [IndexPath]) -> [IndexPath] {
let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows ?? []
let indexPathsIntersection = Set(indexPathsForVisibleRows).intersection(indexPaths)
return Array(indexPathsIntersection)
}
// 用于确定该索引的行是否超出了目前收到数据的最大数量
func isLoadingCell(for indexPath: IndexPath) -> Bool {
return indexPath.row >= (viewModel.currentCount)
}
见证时刻的奇迹到了,请看效果:
通过日志,我们也可以清楚的看到,在滚动的过程中是有 Prefetch 和 CancelPrefetch 操作的:
好了,到这里我就简单的实现了 UITableView 无尽滚动和对数据无缝加载的效果,你学会了吗?
如何避免滚动时的卡顿
当你遇到滚动卡顿的应用程序时,通常是由于任务长时间运行阻碍了 UI 在主线程上的更新,想让主线程有空来响应这类更新事件,第一步就是要将消耗时间的任务交给子线程去执行,避免在获取数据时阻塞主线程。
苹果提供了很多为应用程序实现并发的方式,例如 GCD,我在这里对 Cell 上的图片进行异步加载使用的就是它。
代码如下:
class DataLoadOperation: Operation {
var image: UIImage?
var loadingCompleteHandle: ((UIImage?) -> ())?
private var _image: ImageModel
private let cachedImages = NSCache<NSURL, UIImage>()
init(_ image: ImageModel) {
_image = image
}
public final func image(url: NSURL) -> UIImage? {
return cachedImages.object(forKey: url)
}
override func main() {
if isCancelled {
return
}
guard let url = _image.url else {
return
}
downloadImageFrom(url) { (image) in
DispatchQueue.main.async { [weak self] in
guard let ss = self else { return }
if ss.isCancelled { return }
ss.image = image
ss.loadingCompleteHandle?(ss.image)
}
}
}
// Returns the cached image if available, otherwise asynchronously loads and caches it.
func downloadImageFrom(_ url: NSURL, completeHandler: @escaping (UIImage?) -> ()) {
// Check for a cached image.
if let cachedImage = image(url: url) {
DispatchQueue.main.async {
print("命中缓存")
completeHandler(cachedImage)
}
return
}
URLSession.shared.dataTask(with: url as URL) { data, response, error in
guard
let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let _image = UIImage(data: data)
else { return }
// Cache the image.
self.cachedImages.setObject(_image, forKey: url, cost: data.count)
completeHandler(_image)
}.resume()
}
}
那具体如何使用呢!别急,听我娓娓道来,这里我再给大家一个小建议,大家都知道 UITableView 实例化 Cell 的方法是:tableView:cellForRowAtIndexPath: ,相信很多人都会在这个方法里面去进行数据绑定然后更新 UI,其实这样做是一种比较低效的行为,因为这个方法需要为每个 Cell 调用一次,它应该快速的执行并返回重用 Cell 的实例,不要在这里去执行数据绑定,因为目前在屏幕上还没有 Cell。我们可以在 tableView:willDisplayCell:forRowAtIndexPath: 这个方法中进行数据绑定,这个方法在显示cell之前会被调用。
为每个 Cell 执行下载任务的实现代码如下:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "PreloadCellID") as? ProloadTableViewCell else {
fatalError("Sorry, could not load cell")
}
if isLoadingCell(for: indexPath) {
cell.updateUI(.none, orderNo: "\(indexPath.row)")
}
return cell
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// preheat image ,处理将要显示的图像
guard let cell = cell as? ProloadTableViewCell else {
return
}
// 图片下载完毕后更新 cell
let updateCellClosure: (UIImage?) -> () = { [unowned self] (image) in
cell.updateUI(image, orderNo: "\(indexPath.row)")
viewModel.loadingOperations.removeValue(forKey: indexPath)
}
// 1. 首先判断是否已经存在创建好的下载线程
if let dataLoader = viewModel.loadingOperations[indexPath] {
if let image = dataLoader.image {
// 1.1 若图片已经下载好,直接更新
cell.updateUI(image, orderNo: "\(indexPath.row)")
} else {
// 1.2 若图片还未下载好,则等待图片下载完后更新 cell
dataLoader.loadingCompleteHandle = updateCellClosure
}
} else {
// 2. 没找到,则为指定的 url 创建一个新的下载线程
print("在 \(indexPath.row) 行创建一个新的图片下载线程")
if let dataloader = viewModel.loadImage(at: indexPath.row) {
// 2.1 添加图片下载完毕后的回调
dataloader.loadingCompleteHandle = updateCellClosure
// 2.2 启动下载
viewModel.loadingQueue.addOperation(dataloader)
// 2.3 将该下载线程加入到记录数组中以便根据索引查找
viewModel.loadingOperations[indexPath] = dataloader
}
}
}
对预加载的图片进行异步下载(预热):
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}
if needFetch {
// 1.满足条件进行翻页请求
indicatorView.startAnimating()
viewModel.fetchImages()
}
for indexPath in indexPaths {
if let _ = viewModel.loadingOperations[indexPath] {
return
}
if let dataloader = viewModel.loadImage(at: indexPath.row) {
print("在 \(indexPath.row) 行 对图片进行 prefetch ")
// 2 对需要下载的图片进行预热
viewModel.loadingQueue.addOperation(dataloader)
// 3 将该下载线程加入到记录数组中以便根据索引查找
viewModel.loadingOperations[indexPath] = dataloader
}
}
}
取消 Prefetch 时,cancel 任务,避免造成资源浪费
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){
// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费
indexPaths.forEach {
if let dataLoader = viewModel.loadingOperations[$0] {
print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")
dataLoader.cancel()
viewModel.loadingOperations.removeValue(forKey: $0)
}
}
}
经过这般处理,我们的 UITableView 滚动起来肯定是如丝般顺滑,小伙伴们还等什么,还不赶紧试一试。
图片缓存
虽然我在上面对我的应用增加了并发操作,但是我一看 Xcode 的性能分析,我不禁陷入了沉思,我的应用程序太吃内存了,假如我不停的刷,那我的手机应该迟早会把我的应用给终止掉,下图是我刷到 200 行的时候的性能分析图:
内存
磁盘
可以看到我的应用的性能分析很不理想,究其原因在于我的应用里显示了大量的图片资源,每次来回滚动的时候,都会重新去下载新的图片,而没有对图片做缓存处理。
所以,针对这个问题,我为我的应用加入了缓存 NSCache 对象,来对图片做一个缓存,具体代码实现如下:
class ImageCache: NSObject {
private var cache = NSCache<AnyObject, UIImage>()
public static let shared = ImageCache()
private override init() {}
func getCache() -> NSCache<AnyObject, UIImage> {
return cache
}
}
在下载开始的时候,检查有没有命中缓存,如果命中则直接返回图片,否则重新下载图片,并添加到缓存中:
func downloadImageFrom(_ url: URL, completeHandler: @escaping (UIImage?) -> ()) {
// Check for a cached image.
if let cachedImage = getCacheImage(url: url as NSURL) {
print("命中缓存")
DispatchQueue.main.async {
completeHandler(cachedImage)
}
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
guard
let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let _image = UIImage(data: data)
else { return }
// Cache the image.
ImageCache.shared.getCache().setObject(_image, forKey: url as NSURL)
completeHandler(_image)
}.resume()
}
有了缓存的加持后,再用 Xcode 来查看我的应用的性能,就会发现内存和磁盘的占用已经下降了很多:
内存
磁盘
关于图片缓存的技术,这里只是用了最简单的一种,外面很多开源的图片库都有不同的缓存策略,感兴趣的可以去 GitHub 上学习一下它们的源码,我在这里就不做赘述了。
最后
终于写完了,长舒了一口气,这篇文章的篇幅有点长,主要是我花了一点时间做调研,然后看到这个知识点想给大家讲一下,看到那个知识点也想给大家讲一下,最终引经据典(东拼西凑)完成了这篇文章,希望大家能够喜欢 :)。
按照国际惯例,最后附上项目工程地址:https://github.com/ShenJieSuzhou/ProloadDemo
相关阅读:
Swift 探索 UICollectionView 之 SupplementaryView 和 Decoration View
使用 UICollectionView 实现首页卡片轮播效果
关注我的技术公众号 " HelloWorld杰少 " ,获取更多优质技术文章。