前言:
相信大家在网络不好的时候使用列表分页的App会获得非常不好的体验,由于网络的问题,会有明显的卡顿,就像抖音等App,那么我们是否能使用一些手段来优化这个体验呢?这里可以用到UITableView中另一个协议:UITableViewDataSourcePrefetching。从而实现平滑如斯的无限滚动。
什么是UITableViewDataSourcePrefetching?
Prefetching API提供了一种在需要显示数据之前预先准备数据的机制,旨在提高数据的滚动性能。
首先,先和大家介绍一个概念:无限滚动,无限滚动是可以让用户连续的加载内容,而无需分页。在 UI 初始化的时候 App 会加载一些初始数据,然后当用户滚动快要到达显示内容的底部时加载更多的数据。
如何实现?
这里简单说一下思路:
先自定义一个 Cell 视图,这个视图由一个 UILabel 和 一个 UIImageView 构成,用于显示猫咪品种文本和猫咪网络图片;然后网络请求来获取数据,注意该步骤一定是异步执行的;最后用 UITableView 来显示返回的数据,在 viewDidLoad 中先请求网络数据来获取一些初始化数据,然后再利用 UITableView 的 Prefetching API 来对数据进行预加载,从而来实现数据的无缝加载。
其次,这里由于本地运行的局限性,无限滚动并不是可以真正的加载无限的数据,我这里设置的数据最大值为1000,也就是说最多能加载1000个cell,事实上想要实现无限滚动需要一个庞大的服务器数据做支撑。
具体步骤:
我找到的一个免费的分页接口测试网站,在这个网站上注册后可以获取一定额度的次数,已经足够了。网站如下:https://portal.thatapicompany.com/the-api-marketplace/the-cat-api/
OK接下来就是利用这个网站请求网络数据了,不熟悉网络请求的可以看我这一篇博客:iOS--NSURLSession && Alamofire流程源码解析(万字详解版)-CSDN博客
我这里同样是用的第三方库Alamofire。
HTTP demo如下:
import Foundation
import Alamofire
//网球请求 每次获取30张猫咪照片和名字数据
extension ViewController{
func CatRequest(){
// 请求猫数据
guard !isFetching else { return }
isFetching = true
let catUrl = "https://api.thecatapi.com/v1/images/search?size=med&mime_types=jpg&format=json&has_breeds=true&order=RANDOM&page=\(1)&limit=\(limit)"
DispatchQueue.main.async {
AF.request(catUrl, method: .get, headers: headers).responseDecodable(of: [CatImage].self) { response in
switch response.result {
case .success(let catImages):
print(catImages)
catImagesArray.append(contentsOf: catImages)
isFetching = false
if(self.count >= 1){
//获取将要加载的区间,利用tableview.reloadRows加载的时候只加载这一区间即可,防止整体重新加载导致性能不佳
let statrIndex = catImagesArray.count - catImages.count
let endIndex = catImagesArray.count + catImages.count - 1
var newIndexPaths : [IndexPath] = []
for i in statrIndex...endIndex{
newIndexPaths.append(IndexPath(row: i, section: 0))
}
let indexPathsToReload = self.visibleIndexPathsToReload(intersecting: newIndexPaths)
self.tableView.beginUpdates()
self.tableView.reloadRows(at: indexPathsToReload, with: .automatic)
self.tableView.endUpdates()
}else{
//第一次加载的时候只有30个cell 直接全部加载
self.tableView.reloadData()
self.tableView.tableFooterView = nil
}
self.count += 1
case .failure(let error):
print("Error fetching data: \(error)")
isFetching = false
}
}
}
}
}
这里有个优化的思想,就是先获取需要加载的那部分区间,然后tableview直接加载那部分区间即可,防止整体重新加载导致性能不佳。
接着我定义了一些model:
//定义猫咪模型
struct CatImage : Codable {
let url : String
let breeds : [BREEDS]
}
struct BREEDS : Codable{
let name : String
}
let headers : HTTPHeaders = [
"Content-Type" : "application/json",
"x-api-key" : "YOUR_API_KEY"
]
let tableID = "catTableID"
var catImagesArray : [CatImage] = []
let limit: Int = 30
var isFetching: Bool = false
//缓存 避免重复下载
let imageCache = NSCache<NSURL, UIImage>()
其中imageCache是用来缓存的,至于为什么要缓存,后面会讲到的。
以下是我自定义的cell:
class CatTableViewCell: UITableViewCell {
let catImageView = UIImageView()
let nameLabel = UILabel()
var loadingIndicator: UIActivityIndicatorView?
override func prepareForReuse() {
super.prepareForReuse()
// 避免 cell 重用导致数据重叠
nameLabel.text = ""
catImageView.image = .none
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.contentView.backgroundColor = .darkGray
// 配置猫咪图片
catImageView.contentMode = .scaleAspectFill
catImageView.layer.cornerRadius = 10
catImageView.clipsToBounds = true
catImageView.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview(catImageView)
// 配置名字标签
nameLabel.font = UIFont.systemFont(ofSize: 18, weight: .medium)
nameLabel.textColor = .white
nameLabel.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview(nameLabel)
// 添加 AutoLayout 约束
NSLayoutConstraint.activate([
// 猫咪图片约束
catImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
catImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
catImageView.widthAnchor.constraint(equalToConstant: 60),
catImageView.heightAnchor.constraint(equalToConstant: 60),
// 猫咪名字标签约束
nameLabel.leadingAnchor.constraint(equalTo: catImageView.trailingAnchor, constant: 15),
nameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
nameLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
])
loadingIndicator = UIActivityIndicatorView(frame: self.frame)
loadingIndicator?.center = self.contentView.center
loadingIndicator?.color = .white
self.contentView.addSubview(loadingIndicator!)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension CatTableViewCell{
// 配置单元格内容
func configure(with catImage: CatImage, indexPath: IndexPath) {
// 先显示加载指示器
loadingIndicator?.startAnimating()
catImageView.image = nil
nameLabel.text = ""
// 异步加载图片
DispatchQueue.main.async {
if let imageUrl = URL(string: catImage.url) {
if catImagesArray[indexPath.row].url == catImage.url {
self.loadImage(from: imageUrl) { [weak self] image in
DispatchQueue.main.async {
// 确保此时 indexPath 没有被重用
if catImagesArray[indexPath.row].url == catImage.url {
self?.catImageView.image = image
self?.loadingIndicator?.stopAnimating()
}
}
}
}
}
// 设置名字标签
if let breed = catImage.breeds.first {
self.nameLabel.text = breed.name
} else {
self.nameLabel.text = ""
}
}
}
}
extension CatTableViewCell{
//URL 转 image
func loadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
// 检查缓存,避免重复下载
if let cachedImage = imageCache.object(forKey: url as NSURL) {
completion(cachedImage)
return
}
// 异步加载图片
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data, let image = UIImage(data: data) {
// 缓存图片
imageCache.setObject(image, forKey: url as NSURL)
completion(image)
} else {
completion(nil)
}
}
task.resume()
}
}
这里获取到的猫咪图片是URL格式,需要转成UIImage,configure()中需要注意一个点:就是不能用弱引用,如果 self 被弱引用,可能会导致在任务完成之前 self 被释放,导致界面无法正确更新,就会造成猫咪图片时常更换刷新的问题。
此外,loadImage()这里用到了缓存机制,如果在缓存中找到了之前加载的图片的话,就不用在重复下载图片,节省了开销。
网络数据回调处理:
extension ViewController : UITableViewDelegate,UITableViewDataSource{
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1000
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: tableID, for: indexPath) as! CatTableViewCell
// 重置单元格内容,避免重用导致错位问题
cell.prepareForReuse()
//先不加载图片 而是先加载cell 哪怕没有返回图片 保持一直滚动的状态
if(indexPath.row < catImagesArray.count){
cell.configure(with: catImagesArray[indexPath.row],indexPath: indexPath)
}else{
cell.configure(with: .init(url: "", breeds: []),indexPath: .init())
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 100.0
}
}
因为是图片加载,因为tableview的复用机制会导致前面的cell再次被复用,这里用重制单元格内容和直接赋值的方法处理。此外,由于网络请求的滞后性,我们先加载cell,这个cell是没有内容的,然后等返回网络数据的时候再将这个区间的cell刷新一遍即可。从而实现一直滚动的功能。
那么,如何实现预加载呢?
这里的预加载,其实换句话说就是,如果在适当的时候进行网络呢?网络请求早了不行,会导致用户和数据的“供过于求”,网络请求晚了又会造成“供不应求”。恰当的利用prefetchDataSource可以解决这个问题。
首先让当前的 ViewController 遵循 UITableViewDataSourcePrefetching 协议:
class ViewController: UIViewController,UITableViewDataSourcePrefetching{
var count : Int = 0
lazy var tableView : UITableView = {
let tableview = UITableView(frame: self.view.bounds)
/...
tableview.prefetchDataSource = self
/...
return tableview
}()
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .blue
self.view.addSubview(tableView)
CatRequest()
}
}
然后我们来了解下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
- prefetchRowsAtIndexPaths函数会基于当前滚动的方向和速度对接下来的 IndexPaths 进行 Prefetch,通常我们会在这里实现预加载数据的逻辑。
- cancelPrefetchingForRowsAtIndexPaths函数是一个可选的方法,当用户快速滚动导致一些 Cell 不可见的时候,你可以通过这个方法来取消任何挂起的数据加载操作,有利于提高滚动性能, 这里我没有使用过,但它是很好的性能优化帮手。
UITableViewDataSourcePrefetching如何工作:
1. 当用户快速滚动表格时,表格会调用 tableView(_:prefetchRowsAt:) 方法,它提供了一个即将显示的 IndexPath 列表。
2. 你可以在这个方法中,提前为这些 IndexPath 加载数据,比如发起网络请求、从缓存中获取数据、处理图片等。
3. 当用户真正滚动到这些单元格时,数据就已经准备好了,因此能立即显示,而不会造成卡顿或延迟。
实现逻辑:
// Prefetching: 提前加载即将显示的数据
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
//返回一个布尔值,表示序列是否包含满足给定谓词的元素。这里是查看是否有创建的cell没有获取加载的猫咪图片
let needFetch = indexPaths.contains { isLoadingCell(for: $0)}
if needFetch {
// 1.满足条件进行网络请求
CatRequest()
}
}
//你获取到新的数据,并且有部分数据需要更新时,你只会刷新那些当前在屏幕上可见的单元格,避免刷新不在屏幕上可见的单元格,提升性能。
func visibleIndexPathsToReload(intersecting indexPaths: [IndexPath]) -> [IndexPath] {
//会获取当前表格视图中所有可见行的 IndexPath 数组(即正在屏幕上显示的单元格的索引路径)
let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows ?? []
let indexPathsIntersection = Set(indexPathsForVisibleRows).intersection(indexPaths)//使用 Set 来将可见行的 IndexPath 集合化,并与传入的 indexPaths 进行 交集运算,找出既在 indexPaths 中,又是当前可见的行。这个操作是为了只重新加载那些当前可见且需要更新的单元格,避免无关单元格的重复加载。
return Array(indexPathsIntersection)
}
//当滑动的单元格大于等于加载的图片 则一直创造单元格
func isLoadingCell(for indexPath: IndexPath) -> Bool {
return indexPath.row >= (catImagesArray.count - 15)
}
经过上述的设置后,我们就实现了以下效果:
总结:
- 我们用prefetchDataSource 提升用户体验,当用户快速滚动表格时,它能够提前加载数据,避免因加载数据而导致的卡顿。其中我们可以在prefetchRowsAtIndexPaths函数中编写逻辑。这里逻辑包括了isLoadingCell和visibleIndexPathsToReload等一系列方法。
- 为了优化tableview的性能,我们每次获取到网络数据的时候,并不使用tableview.reloadData(),而是通过返回数据的数组catImagesArray和获取到的catImages之间的数量逻辑计算出需要刷新加载区间的cell,使用self.tableView.reloadRows(at: indexPathsToReload, with: .automatic)这部分区间即可。
- 有时候弱引用并没有想象中的那么好用,在编写项目的时候,我习惯性的在写cell的configure函数的时候使用了弱引用,从而造成了cell的图片不断刷新的这一bug。
- 缓存的思想:每次网络请求返回的图片URL我们需要转换成UIImage,这时候我们可以将其转换的数据缓存,避免重复转换。
- 为了防止tableview的复用机制踩坑,我们使用了prepareForReuse()这一方法在单元格被重用之前对其进行重置。
按照国际惯例,最后附上项目工程地址:https://github.com/iOSwhj/TheCatTableView