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.