Auto Layout и переиспользование ячеек UITableView

Auto Layout и переиспользование ячеек — два места, где middle-разработчик чаще всего ломает и вёрстку, и производительность.

Auto Layout — это решатель системы линейных уравнений вида item1.attr = multiplier × item2.attr + constant, где приоритеты определяют, каким уравнением пожертвовать при конфликте.

Вопрос 1: «Что такое content hugging и compression resistance?»

Что на самом деле проверяют

Понимает ли кандидат, что размер вьюхи задаётся не только констрейнтами, но и её собственным желаемым размером — intrinsicContentSize. Без этого человек лепит фиксированные ширины и получает обрезанный текст на маленьких экранах.

Развёрнутый ответ

У лейбла, кнопки, картинки есть intrinsicContentSize — размер «по содержимому». Когда места больше или меньше, решателю надо понять, кто уступит. Для этого есть два приоритета:

  • Content hugging — «не хочу растягиваться сверх своего содержимого». Чем выше, тем сильнее вьюха липнет к своему контенту.
  • Compression resistance — «не хочу сжиматься меньше своего содержимого». Чем выше, тем скорее сожмут соседа, а не меня.

Бытовой пример: в строке два лейбла — «Название товара» и «1 299 ₽». Места избыток — растягиваться должно название, значит у цены выше hugging. Места не хватает — обрезать надо название, значит у цены выше compression resistance.

priceLabel.setContentHuggingPriority(.required, for: .horizontal)          // 1000
priceLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) // 250
titleLabel.lineBreakMode = .byTruncatingTail

Приоритеты — числа от 1 до 1000: .required = 1000 (нарушить нельзя), .defaultHigh = 750, .defaultLow = 250. Всё, что меньше 1000, — «пожелание»: решатель нарушит его, если иначе решения нет.

Если два обязательных констрейнта противоречат друг другу, в консоли появится Unable to simultaneously satisfy constraints и строка Will attempt to recover by breaking constraint. Читают лог так: находят в списке свои констрейнты по классу вьюхи, определяют взаимоисключающую пару и понижают приоритет одного до 999.

Вопрос 2: «Как работает dequeueReusableCell и почему в таблице “прыгают” данные?»

Что на самом деле проверяют

Понимание, что ячейка — переиспользуемый объект из пула, а не свежесозданная вьюха. Отсюда растут почти все баги списков.

Развёрнутый ответ

UITableView держит пул ячеек по reuse identifier. Когда ячейка уходит за границу экрана, она возвращается в пул; dequeueReusableCell достаёт оттуда объект вместо создания нового. Ячейка приходит с состоянием от предыдущей строки: старая картинка, включённый чекбокс, изменённое количество строк лейбла.

Правило: cellForRowAt обязан задавать все изменяемые свойства, а не только «отличающиеся». Дополнительно состояние чистят в prepareForReuse:

final class ProductCell: UITableViewCell {
    private var currentItemID: UUID?
    private var imageTask: Task<Void, Never>?

    override func prepareForReuse() {
        super.prepareForReuse()
        imageTask?.cancel()
        imageTask = nil
        currentItemID = nil
        thumbnailView.image = nil
        accessoryType = .none
    }

    func configure(with item: Product) {
        currentItemID = item.id
        titleLabel.text = item.title
        accessoryType = item.isSelected ? .checkmark : .none

        imageTask = Task { [weak self] in
            let image = try? await ImageLoader.shared.load(item.imageURL)
            guard let self, self.currentItemID == item.id else { return }  // гонка
            self.thumbnailView.image = image
        }
    }
}

Проверка currentItemID == item.id закрывает классическую гонку: при быстром скролле картинка для строки 3 приходит, когда ячейка уже переиспользована под строку 40. Без неё в списке мелькают чужие обложки.

Высота ячеек

Автовысота работает, только если констрейнты внутри contentView задают высоту сверху донизу без разрывов:

tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 88

estimatedRowHeight нужен, чтобы таблица посчитала contentSize без измерения всех ячеек. Без оценки или при сильном расхождении с реальностью позиция контента пересчитывается на лету — скролл «прыгает».

Вопрос 3: «Почему таблица тормозит и как ускорить?»

Развёрнутый ответ

cellForRowAt выполняется на главном потоке за доли миллисекунды до отрисовки кадра. Всё тяжёлое оттуда убирают:

  • Форматтеры (DateFormatter, NumberFormatter) создают один раз — создание стоит дорого.
  • Декодирование и ресайз картинок — в фон, на главный поток отдают готовый UIImage.
  • Атрибутированный текст и высоты кешируют по id модели.
  • cornerRadius + masksToBounds на ячейке с тенями и наложениями вызывает offscreen rendering; для простых случаев берут заранее скруглённую картинку или рисуют фон одним слоем.
  • Отсутствие reuse identifier убивает пул: ячейки создаются заново на каждый кадр.

UITableViewDiffableDataSource удобнее reloadData: вы отдаёте снапшот состояния, а фреймворк сам считает разницу, анимирует вставки и удаления и не даёт словить краш от рассинхрона модели и числа строк.

var snapshot = NSDiffableDataSourceSnapshot<Section, Product.ID>()
snapshot.appendSections([.main])
snapshot.appendItems(products.map(\.id))
await dataSource.apply(snapshot, animatingDifferences: true)

Типичные ошибки кандидатов

  • Путают hugging и compression resistance местами.
  • Думают, что prepareForReuse сам сбрасывает данные — он пустой, его надо писать.
  • Присваивают картинку без проверки, что ячейка всё ещё показывает ту же модель.
  • Ставят automaticDimension без estimatedRowHeight и без замкнутой цепочки констрейнтов.
  • Считают конфликт констрейнтов безобидным предупреждением и не читают лог.
  • Создают DateFormatter внутри cellForRowAt.

Как ответить кратко

Auto Layout решает систему уравнений, приоритеты от 1 до 1000 говорят, чем можно пожертвовать. Content hugging — «не растягивай меня сверх контента», compression resistance — «не сжимай меня меньше контента»; в паре «название и цена» цене поднимают оба, названию режут compression resistance. Ячейки приходят из пула с чужим состоянием, поэтому configure задаёт все поля, а prepareForReuse чистит картинку и отменяет загрузку. Прыгающие обложки — гонка асинхронной загрузки: перед присвоением сверяем id модели. Тормоза лечим выносом тяжёлого из cellForRowAt, кешем форматтеров и высот, отказом от лишнего offscreen rendering, а reloadData меняем на diffable data source.

Проверьте себя
1. В строке два лейбла: название товара и цена. При нехватке места нужно обрезать название, а цену показать целиком. Что настроить?
AПоднять content hugging у названия
BПоднять compression resistance у цены и понизить её у названия
CЗадать цене фиксированную ширину в 80 точек
DПонизить compression resistance у цены до 250
2. При быстром скролле в ячейках ненадолго появляются чужие картинки. В чём причина и как чинить?
AНужно отключить переиспользование ячеек и создавать их через init каждый раз
BГонка асинхронной загрузки: ячейка переиспользована до прихода картинки — сверяйте id модели перед присвоением и отменяйте задачу в prepareForReuse
CПроблема в estimatedRowHeight, его надо убрать
DКартинки надо загружать синхронно прямо в cellForRowAt
3. Чем UITableViewDiffableDataSource удобнее reloadData?
AОн рендерит ячейки в фоновом потоке
BОн отменяет необходимость в prepareForReuse
CВы описываете снапшот состояния, а фреймворк сам считает разницу, анимирует изменения и не даёт рассинхрона модели с числом строк
DОн автоматически считает высоты ячеек без констрейнтов