09. 列表实战
列表是 iOS 项目的高频模块,做不好会卡顿、错位、内存飙升。按“结构 → 复用 → 性能 → 维护”梳理关键点,并给出完整代码示例。
一、列表结构与职责划分
1. 数据模型与 ViewModel
struct Post {
let id: Int
let title: String
let summary: String
let coverURL: URL?
}
struct PostViewModel {
let title: String
let summary: String
let coverURL: URL?
init(post: Post) {
title = post.title
summary = post.summary
coverURL = post.coverURL
}
}
2. Controller 只负责组合
class PostListVC: UIViewController {
private let tableView = UITableView()
private var items: [PostViewModel] = []
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setupTable()
loadData()
}
private func setupTable() {
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.dataSource = self
tableView.delegate = self
tableView.register(PostCell.self, forCellReuseIdentifier: "PostCell")
tableView.separatorStyle = .singleLine
tableView.estimatedRowHeight = 88
tableView.rowHeight = UITableView.automaticDimension
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func loadData() {
// 模拟数据
let posts = [
Post(id: 1, title: "UITableView 实战", summary: "复用与性能关键点", coverURL: nil),
Post(id: 2, title: "UICollectionView 布局", summary: "网格与卡片流", coverURL: nil)
]
items = posts.map(PostViewModel.init)
tableView.reloadData()
}
}
extension PostListVC: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "PostCell", for: indexPath) as! PostCell
cell.configure(with: items[indexPath.row])
return cell
}
}
二、Cell 结构与复用机制
1. 视图层拆分与布局
class PostCell: UITableViewCell {
private let coverImageView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
iv.translatesAutoresizingMaskIntoConstraints = false
return iv
}()
private let titleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16, weight: .bold)
label.numberOfLines = 2
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let summaryLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 13)
label.numberOfLines = 2
label.textColor = .darkGray
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private var currentURL: URL?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(coverImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(summaryLabel)
NSLayoutConstraint.activate([
coverImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
coverImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
coverImageView.widthAnchor.constraint(equalToConstant: 72),
coverImageView.heightAnchor.constraint(equalToConstant: 72),
titleLabel.leadingAnchor.constraint(equalTo: coverImageView.trailingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
titleLabel.topAnchor.constraint(equalTo: coverImageView.topAnchor),
summaryLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
summaryLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
summaryLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6),
summaryLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -12)
])
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func prepareForReuse() {
super.prepareForReuse()
coverImageView.image = nil
titleLabel.text = nil
summaryLabel.text = nil
currentURL = nil
}
func configure(with vm: PostViewModel) {
titleLabel.text = vm.title
summaryLabel.text = vm.summary
currentURL = vm.coverURL
loadImage(url: vm.coverURL)
}
private func loadImage(url: URL?) {
guard let url else { return }
ImageLoader.shared.load(url: url) { [weak self] image, responseURL in
guard let self else { return }
// 避免复用错位
if responseURL == self.currentURL {
self.coverImageView.image = image
}
}
}
}
2. 简单图片加载与缓存
final class ImageLoader {
static let shared = ImageLoader()
private let cache = NSCache<NSURL, UIImage>()
func load(url: URL, completion: @escaping (UIImage?, URL) -> Void) {
if let cached = cache.object(forKey: url as NSURL) {
completion(cached, url)
return
}
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self, let data, let image = UIImage(data: data) else {
DispatchQueue.main.async { completion(nil, url) }
return
}
self.cache.setObject(image, forKey: url as NSURL)
DispatchQueue.main.async { completion(image, url) }
}.resume()
}
}
三、动态高度与布局稳定性
1. Auto Layout 动态高度
tableView.estimatedRowHeight = 88
tableView.rowHeight = UITableView.automaticDimension
2. 文本高度预计算(性能更稳)
func textHeight(_ text: String, width: CGFloat, font: UIFont) -> CGFloat {
let rect = (text as NSString).boundingRect(
with: CGSize(width: width, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: font],
context: nil
)
return ceil(rect.height)
}
四、分页与下拉刷新
1. 下拉刷新
let refresh = UIRefreshControl()
refresh.addTarget(self, action: #selector(onRefresh), for: .valueChanged)
tableView.refreshControl = refresh
@objc private func onRefresh() {
// 重新拉取数据
refresh.endRefreshing()
}
2. 触底加载
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if indexPath.row == items.count - 1 {
// 触底加载更多
}
}
五、预加载与滑动体验
1. 预加载图片
extension PostListVC: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let urls = indexPaths.compactMap { items[$0.row].coverURL }
urls.forEach { ImageLoader.shared.load(url: $0) { _, _ in } }
}
}
六、UICollectionView 适用场景
- 网格、卡片、瀑布流
- 多列布局与自定义排布
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: 160, height: 220)
layout.minimumLineSpacing = 12
layout.minimumInteritemSpacing = 12
七、列表性能清单(落地版)
cellForRowAt内不做耗时操作- 复杂视图提前计算或缓存
- 图片异步 + 缓存 + 复用校验
- 避免频繁
reloadData,能更新局部就更新局部