写点什么

iOS 优雅的处理网络数据,你真的会吗?不如看看这篇.

发布于: 2021 年 09 月 24 日
iOS 优雅的处理网络数据,你真的会吗?不如看看这篇.

相信大家平时在用 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

发布于: 2021 年 09 月 24 日阅读数: 8
用户头像

佛系编码 2019.05.13 加入

红鲤鱼与绿鲤鱼与驴。

评论

发布
暂无评论
iOS 优雅的处理网络数据,你真的会吗?不如看看这篇.