精选文章

09. 列表实战

2020-09-12 · UITableView

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,能更新局部就更新局部
JJ

作者简介

专注于内容创作、产品策略与设计实践。欢迎交流合作。

上一篇 下一篇