转载: iOS 优雅的处理网络数据
原文链接:https://juejin.cn/post/6952682593372340237
相信大家平时在用 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 的总行数。
响应数据的示范如下(为清楚起见,它仅显示与分页有关的字段):
json复制代码{"has_more": true,"page": 1,"total": 84,"items": [ ......]}
下面,我就用代码来一步步的实现它吧!
模拟分页请求
由于没有找到合适的分页测试接口,于是我自己模拟一了一个分页请求接口,每次调用该接口的时候都延时 2s 来模拟网络请求的状态,代码如下:
php复制代码 func fetchImages() {guard !isFetchInProcess else {return}isFetchInProcess = true// 延时 2s 模拟网络环境print("+++++++++++ 模拟网络数据请求 +++++++++++")DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 2) {print("+++++++++++ 模拟网络数据请求返回成功 +++++++++++")DispatchQueue.main.async {self.total = 1000self.currentPage += 1self.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)}}}}
数据回调处理:
swift复制代码extension ViewController: PreloadCellViewModelDelegate {func onFetchCompleted(with newIndexPathsToReload: [IndexPath]?) {guard let newIndexPathsToReload = newIndexPathsToReload else {tableView.tableFooterView = niltableView.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() 函数中插入如下代码,并且请求第一页的数据:
scss复制代码override func viewDidLoad() {super.viewDidLoad()// Do any additional setup after loading the view....tableView.prefetchDataSource = self... // 模拟请求图片viewModel.fetchImages()}
然后,你需要实现 UITableViewDataSourcePrefetching 的协议,它的协议里包含俩个函数:
less复制代码// this protocol can provide information about cells before they are displayed on screen.@protocol UITableViewDataSourcePrefetching @required// indexPaths are ordered ascending by geometric distance from the table view- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray *)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 *)indexPaths;@end
第一个函数会基于当前滚动的方向和速度对接下来的 IndexPaths 进行 Prefetch,通常我们会在这里实现预加载数据的逻辑。
第二个函数是一个可选的方法,当用户快速滚动导致一些 Cell 不可见的时候,你可以通过这个方法来取消任何挂起的数据加载操作,有利于提高滚动性能, 在下面我会讲到。
实现这俩个函数的逻辑代码为:
swift复制代码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)}}}}
最后,再加上俩个有用的方法该功能就大功告成了:
scss复制代码// 用于计算 tableview 加载新数据时需要 reload 的 cellfunc 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 上的图片进行异步加载使用的就是它。
代码如下:
swift复制代码class DataLoadOperation: Operation {var image: UIImage?var loadingCompleteHandle: ((UIImage?) -> ())?private var _image: ImageModelprivate 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) inDispatchQueue.main.async { [weak self] inguard let ss = self else { return }if ss.isCancelled { return }ss.image = imagess.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 inguardlet 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 执行下载任务的实现代码如下:
swift复制代码 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}// 图片下载完毕后更新 celllet updateCellClosure: (UIImage?) -> () = { [unowned self] (image) incell.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 若图片还未下载好,则等待图片下载完后更新 celldataLoader.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}}}
对预加载的图片进行异步下载(预热):
swift复制代码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 任务,避免造成资源浪费
swift复制代码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 对象,来对图片做一个缓存,具体代码实现如下:
swift复制代码class ImageCache: NSObject {private var cache = NSCache<AnyObject, UIImage>()public static let shared = ImageCache()private override init() {} func getCache() -> NSCache<AnyObject, UIImage> { return cache}}
在下载开始的时候,检查有没有命中缓存,如果命中则直接返回图片,否则重新下载图片,并添加到缓存中:
swift复制代码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 inguardlet 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 上学习一下它们的源码,我在这里就不做赘述了。