Collection view выбирают всегда, когда экран не сводится к одной вертикальной колонке строк на всю ширину: сетки, карусели, смешанные секции. А начиная с iOS 14 его часто выбирают и для обычных списков, потому что list configuration даёт внутри него строки в стиле таблицы.
**UITableView: одна колонка, встроенные разделители, индекс секций, свайп-действия и редактирование; по-прежнему самый простой выбор для классических списков.
UICollectionView**: раскладка вынесена в отдельный UICollectionViewLayout, поэтому одни и те же данные могут быть сеткой, полкой или списком; UICollectionLayoutListConfiguration даёт строки в стиле таблицы.
iOSMiddle
UICollectionView и diffable data source
Compositional layout, diffable data source, регистрация ячеек, content configuration, префетч
Закрыто вопросов
0 / 23
- 0Повторить
- 1Часто спрашивают
Вопросы
Ячейка переиспользуется, а код выставляет бейдж и красную цену только в ветке true: ячейка, которая последней показывала товар со скидкой, сохраняет и то и другое, когда её достают из очереди для обычного товара. С заголовком всё в порядке, потому что он выставляется на любом пути.
dequeueReusableCell возвращает переиспользованную ячейку со всем, что на ней оставил предыдущий элемент, поэтому каждое свойство, зависящее от данных, нужно выставлять на каждом пути:
Каждый случай исправляется одной строкой: cell.saleBadge.isHidden = !product.isOnSale и cell.priceLabel.textColor = product.isOnSale ? .systemRed : .label.
if без else в конфигурации ячейки и есть тот самый паттерн, который стоит искать на ревью.
Сброс бейджа в prepareForReuse тоже скрывает симптом, но разносит одно правило по двум местам; шаг конфигурации должен сам по себе полностью описывать ячейку.
deleteRows вызывается до того, как модель уменьшилась: во время обновления таблица спрашивает у data source новое число строк, всё ещё получает старое и бросает NSInternalInconsistencyException с «Invalid update: invalid number of rows in section 0».
Почему: когда deleteRows применяется, таблица проверяет, что строк после = строк до − удалённые + вставленные, и вызывает tableView(_:numberOfRowsInSection:) прямо в этот момент. В notes всё ещё старое число элементов, таблица ждёт на один меньше, и расхождение бросается исключением, а не исправляется.
Исправление: сначала меняйте модель, потом сообщайте view: notes.remove(at:) перед deleteRows. Если изменений несколько, делайте все правки модели внутри блока performBatchUpdates вместе с соответствующими вставками и удалениями.
То же правило в других местах: insertRows, moveRow, insertSections и аналоги в UICollectionView проверяют ту же арифметику.
Убрать класс ошибок целиком: diffable data source сам вычисляет изменения по снапшоту, поэтому рассинхронизироваться нечему.
Как поймать: падение детерминированное: unit-тест, который вызывает tableView(_:commit:forRowAt:) у загруженного контроллера, воспроизводит его без всякого UI.
Ячейка из очереди представляет собой переиспользованный объект, на котором ещё висит старая картинка, а запрос, запущенный для её предыдущего элемента, позже дописывает в неё результат. Очищайте картинку и отменяйте загрузку при переиспользовании, а перед установкой результата проверяйте, какой элемент ячейка показывает сейчас.
Переиспользование не очищает данные, а только возвращает view. Асинхронная загрузка добавляет вторую проблему: completion захватил ячейку, а не элемент, поэтому медленный ответ рисует картинку в ту строку, которую ячейка показывает к этому моменту.
Сразу очищать: imageView.image = nil в prepareForReuse или плейсхолдер в configure, чтобы старая картинка не мелькнула ни на кадр. Первым делом вызывайте super.prepareForReuse().
Отменять: храните Task в ячейке и отменяйте его в prepareForReuse.
Проверять идентичность: к моменту отмены результат может быть уже в пути. Запоминайте id элемента, для которого стартовала загрузка, и выбрасывайте картинку, если ячейка уже показывает другое. Никогда не захватывайте IndexPath: он меняется при вставке элементов.
Кэш и даунсэмплинг: держите декодированные превью в NSCache и декодируйте под размер показа (byPreparingThumbnail(ofSize:) или ImageIO), а не фото в полном разрешении; именно это делает скролл плавным.
Индекс строки копируется в starButton.tag при конфигурации ячейки, а insertRows не переконфигурирует ячейки, которые уже на экране, поэтому после появления песни сверху каждая видимая звёздочка хранит старый индекс и переключает песню на строку выше.
Почему: insertRows, deleteRows и moveRow сдвигают ячейки, которые уже на экране, не вызывая для них cellForRowAt; свежий тег получают только заново извлечённые из очереди ячейки. Любой индекс, сохранённый в ячейке (tag или indexPath, захваченный в замыкании), представляет собой снимок, который устаревает при следующем обновлении.
Хуже после удаления: последняя видимая ячейка всё ещё хранит индекс за концом songs, и songs[sender.tag] падает с «Index out of range».
Исправление: определяйте строку в момент нажатия: переведите начало координат кнопки в систему таблицы и спросите indexPathForRow(at:) или вызовите tableView.indexPath(for: cell). С diffable data source захватывайте вместо индекса стабильный id элемента и ищите его, когда придёт нажатие.
addTarget в cellForRowAt не баг: UIControl игнорирует пару target–action, которая у него уже есть, поэтому переиспользованная ячейка не срабатывает дважды.
Как поймать: unit-тест, который вставляет строку, вызывает действие звёздочки у ячейки с известным названием и проверяет, какая песня изменилась; на ревью считайте tag = indexPath.row тревожным сигналом.
Flow layout раскладывает элементы в строку и переносит на следующую, поэтому сетка задаётся просто шириной элемента, посчитанной из доступной ширины: вычесть отступы секции и промежутки, разделить на число колонок и округлить вниз.
Откуда берутся размеры: itemSize у layout для одного фиксированного размера или collectionView(_:layout:sizeForItemAt:) из UICollectionViewDelegateFlowLayout для каждого элемента.
Spacing означает минимум: minimumInteritemSpacing задаёт только наименьший промежуток; если ширины не складываются ровно, flow layout расширит промежутки или перенесёт последнюю колонку.
Округлять вниз: округление вверх может вытолкнуть третий элемент на следующую строку; делайте floor.
Поворот и iPad: flow layout заново запрашивает sizeForItemAt, когда меняется размер bounds, поэтому размер, выведенный из ширины, просто работает; invalidateLayout() вызывайте сами, только когда изменился входной параметр расчёта (например, число колонок, выбранное по size class).
Ловушка сторибордов: начиная с Xcode 11 у flow layout в Interface Builder Estimate Size стоит в Automatic, это включает self-sizing, и ячейки игнорируют sizeForItemAt. Поставьте None.
Когда уходить: flow layout реализует один алгоритм переноса по строкам. Разные раскладки по секциям, горизонтальные полки внутри вертикальной ленты или самоподстраивающиеся сетки относятся к задачам compositional layout (iOS 13+). Flow layout не объявлен устаревшим и вполне годится для одной простой сетки.
Потому что вы описываете всё состояние снапшотом, а вставки, удаления и перемещения UIKit вычисляет сам. reloadData не анимирует и сбрасывает все видимые ячейки; performBatchUpdates падает, как только ваша арифметика indexPath разойдётся с моделью.
**reloadData: перезагружает всё: без анимации, видимые ячейки настраиваются с нуля, оценки self-sizing пересчитываются. Подходит для полного сброса, но не для одной изменившейся строки.
performBatchUpdates**: с анимацией, но каждый insertItems(at:) и deleteItems(at:) вы считаете сами, и после обновления количества должны совпасть с data source, иначе получите «Invalid update: invalid number of items in section 0».
Diffable: UICollectionViewDiffableDataSource плюс NSDiffableDataSourceSnapshot<Section, Item>: вы добавляете идентификаторы и вызываете apply, а дифф порождает анимированное пакетное обновление. Снапшот служит единственным источником правды, поэтому этот класс падений исчезает.
Цена: идентификаторы должны быть уникальными и Hashable, стоимость диффа растёт с числом элементов и выполняется внутри apply, а об изменении содержимого под тем же id нужно сообщать через reconfigureItems.
Доступен с iOS 13 и является стандартом для новых списков на UIKit.
Только стабильные id. Дифф сопоставляет элементы по Hashable-идентичности, поэтому если идентификатором служит сама структура, правка любого поля создаёт новую идентичность: строка анимированно уезжает и возвращается, а две строки с одинаковыми значениями становятся дубликатами, на которых снапшот падает.
Дифф сравнивает идентификаторы по секциям: id, который есть в обоих снапшотах, но на новом месте, даёт перемещение; только в новом даёт вставку; только в старом даёт удаление; в обоих на том же месте ничего не меняется.
Правка превращается в удаление плюс вставку: синтезированный Hashable покрывает все хранимые свойства, поэтому смена title меняет идентификатор: ячейка заменяется, выделение теряется, анимация выглядит неправильно.
Дубликаты роняют приложение: две строки-плейсхолдера с одинаковыми значениями, один id в двух секциях или постраничный API, вернувший элемент дважды, заканчиваются ошибкой «supplied item identifiers are not unique».
Изменяемые классы ещё хуже: класс, чей хеш зависит от изменяемого состояния, меняет хеш, пока его держит снапшот, и следующий дифф выдаёт бессмыслицу.
Решение: кладите в снапшот UUID, серверный id или Model.ID, а значения держите в хранилище по id, из которого читает регистрация ячейки. Изменение значения под тем же id дифф тогда не видит, поэтому сообщайте о нём через reconfigureItems. Структура с собственными ==/hash(into:) только по id тоже подходит, но тогда каждое изменение содержимого нужно сообщать через reconfigureItems.
snapshot.reconfigureItems([id]): он заново запускает регистрацию ячейки на той же ячейке, что на экране, а reloadItems выбрасывает эту ячейку и достаёт из очереди новую.
**reloadItems**: обрабатывается как удаление и вставка того же элемента: старая ячейка уходит в пул переиспользования, вызывается prepareForReuse, новая ячейка настраивается с нуля. Теряются декодированная картинка, идущая анимация, статус first responder и смещение вложенного скролла, а self-sizing снова начинает с оценки.
**reconfigureItems** (iOS 15+) работает без dequeue и без prepareForReuse: UIKit вызывает хендлер регистрации с существующей ячейкой, и меняются только значения. Выделение, фокус и работа в процессе сохраняются.
Только для существующих элементов: реконфигурация id, которого нет в снапшоте, приводит к ошибке; новая строка добавляется через appendItems.
Без diffable: та же пара есть у самого view: collectionView.reconfigureItems(at:) и reloadItems(at:).
Нужен стабильный id: если идентификатором элемента служит структура модели, смена поля даёт новый идентификатор, и любой из вызовов превращается в удаление плюс вставку.
Правило: reload означает «теперь это другая вещь», reconfigure означает «та же вещь, новые значения»; почти любое обновление из живой модели относится ко второму.
Потому что оба убирают ошибки, которые допускал старый подход: регистрация связывает класс ячейки с типом элемента на этапе компиляции, а content configuration делает внешний вид ячейки значением, которое вы присваиваете, а не набором свойств, которые вы меняете.
Типизированная регистрация: UICollectionView.CellRegistration<Cell, Item> передаёт хендлеру типизированные ячейку и элемент: нет строки-идентификатора, в которой можно опечататься, нет каста as!, нет вызова register, который можно забыть. dequeueConfiguredReusableCell(using:for:item:) регистрирует класс при первом использовании.
Один раз и чистый хендлер: храните регистрацию в свойстве или создавайте до data source; хендлер вызывается на каждом dequeue и на каждом reconfigureItems, поэтому должен зависеть только от элемента.
Конфигурации устроены как значения: defaultContentConfiguration() возвращает UIListContentConfiguration (текст, вторичный текст, картинка), уже связанную с Dynamic Type и тёмной темой. Скопируйте её в var, измените, присвойте обратно; изменение одной лишь копии на экране ничего не меняет.
Вид по состоянию: задайте configurationUpdateHandler или переопределите updateConfiguration(using:); UIKit вызывает его при смене выделения, подсветки, disabled или фокуса, поэтому вы описываете ячейку для UICellConfigurationState, а не перекрашиваете её в didSelectItemAt.
Строки на SwiftUI: с iOS 16 UIHostingConfiguration помещает SwiftUI-вью в ячейку через то же свойство contentConfiguration.
На iOS 15 и новее на первом же dequeue вылетит исключение: UIKit обнаруживает регистрацию, созданную внутри cell provider. Каждый вызов создавал бы новую регистрацию со своим reuse identifier, и ни одна ячейка не могла бы переиспользоваться.
Сообщение исключения говорит об этом прямо: создание новой регистрации при каждом запросе ячейки мешает переиспользованию и оставляет созданные ячейки в памяти на всё время жизни collection view. На iOS 14 такой проверки не было, поэтому тот же код там попадал в релиз и тихо наращивал память по мере скролла.
Исправление: создайте регистрацию один раз, до data source: локальный let, который захватывает замыкание провайдера, или хранимый let / lazy var в контроллере. Провайдер тогда только вызывает dequeueConfiguredReusableCell(using:for:item:).
То же правило для хедеров: SupplementaryRegistration, созданная внутри supplementaryViewProvider, ломается так же.
Хендлер оставьте как есть: читать значения по id из хранилища правильно; меняется только место, где создаётся регистрация.
Item задаёт место под одну ячейку, group выстраивает items вдоль оси, section повторяет свою группу и добавляет отступы и прокрутку, а layout содержит секции. Дробный размер всегда означает долю от контейнера элемента: для item это его группа, для группы это секция.
На последнем правиле и ошибаются: item с .fractionalWidth(1.0) внутри группы с .fractionalWidth(0.5) занимает половину экрана. Группа сама является item, поэтому группы вкладываются друг в друга: большая плитка рядом с двумя маленькими друг над другом собирается как горизонтальная группа, в которой item и вертикальная группа.
**.fractionalWidth / .fractionalHeight**: доля стороны контейнера. Можно и по другой оси: группа с высотой .fractionalWidth(0.5) и двумя items даёт почти квадратные плитки на любом экране.
**.absolute / .estimated: фиксированные пункты (не подходят для текста) / стартовая оценка, которую UIKit поправляет после измерения.
Промежутки**: horizontal(layoutSize:repeatingSubitem:count:) делит ширину поровну и учитывает interItemSpacing. С subitems: [item] побеждает собственная ширина item, поэтому два item по .fractionalWidth(0.5) плюс промежуток уже не помещаются, и вторая колонка исчезает.
Адаптивность: инициализатор с section provider получает NSCollectionLayoutEnvironment; число колонок выбирайте по container.effectiveContentSize.width или по его traitCollection.
Пока ячейка не измерена, layout учитывает в размере контента её оценочную высоту. Каждое реальное измерение заменяет оценку, общая высота меняется, и контент под пальцем сдвигается; чем дальше оценка от реальности, тем сильнее скачок.
Как включить: в compositional layout задайте .estimated высоту и item, и группе. .absolute не измеряется никогда.
Измеримая ячейка: измерение прогоняет Auto Layout по contentView, поэтому констрейнты должны идти цепочкой от верхнего края до нижнего; без нижнего ячейка остаётся с оценкой. Многострочному UILabel нужны ещё leading и trailing, иначе он измеряется как одна бесконечная строка.
Уменьшить ошибку: оценка, близкая к реальному среднему, делает поправку незаметной.
Не терять измерения: reconfigureItems сохраняет ячейку и её размер; reloadItems и reloadData начинают снова с оценки.
Ресайз на месте: с iOS 16 selfSizingInvalidation по умолчанию .enabled: ячейка, у которой поменялась конфигурация, сама меняет размер и может запросить это вызовом invalidateIntrinsicContentSize(). .enabledIncludingConstraints реагирует ещё и на изменения констрейнтов внутри ячейки.
UICollectionLayoutListConfiguration: она превращает compositional layout или одну его секцию в строки в стиле таблицы с разделителями, хедерами, свайп-действиями и аксессуарами.
Весь экран: UICollectionViewCompositionalLayout.list(using:).
Одна секция среди сеток: NSCollectionLayoutSection.list(using:layoutEnvironment:) внутри section provider. Именно поэтому списки переехали в collection view: если третья секция станет каруселью, переписывать экран не придётся.
Внешний вид: appearance: .plain, .grouped, .insetGrouped, .sidebar или .sidebarPlain; headerMode: .supplementary или .firstItemInSection; линиями управляет separatorConfiguration.
Строки: UICollectionViewListCell: defaultContentConfiguration() даёт стандартную раскладку текста и картинки, а accessories добавляет индикатор перехода, галочки, ручки перестановки и кнопки удаления.
Свайп-действия: trailingSwipeActionsConfigurationProvider (и его leading-близнец) возвращает UISwipeActionsConfiguration.
В этом провайдере две ловушки. Он получает IndexPath, который к моменту выполнения действия может устареть, поэтому сразу превращайте его в идентификатор через dataSource.itemIdentifier(for:). И действие само должно применить новый снапшот: строка не исчезнет сама.
collectionView(_:cancelPrefetchingForItemsAt:): без него каждый быстрый скролл забивает очереди загрузки и декодирования работой для элементов, на которые никто не посмотрит, и видимые строки ждут за ней.
Настройка: задайте collectionView.prefetchDataSource и реализуйте UICollectionViewDataSourcePrefetching; prefetchItemsAt приходит до показа, примерно в порядке прокрутки.
Подсказка, а не контракт: для первого экрана он не вызывается и под нагрузкой может пропускаться, поэтому ячейка всё равно должна уметь загрузить данные сама; префетч только прогревает кэш.
Ключом служит id элемента: indexPath сдвигаются с каждым применённым снапшотом, и ключ по indexPath отменит чужую загрузку.
Отменять настоящую работу: Task.cancel() останавливает async-вызовы URLSession, но ваш собственный цикл декодирования должен проверять Task.isCancelled. Отменяйте и в prepareForReuse ячейки.
Это не cell prefetching: isPrefetchingEnabled (включён по умолчанию) заставляет UIKit настраивать ячейки чуть раньше их появления; prefetch data source служит отдельной подсказкой, чтобы грузить данные дальше вперёд. Код, который считает «настроена, значит видна», например аналитика или автоплей, должен жить в willDisplay.
Ничто не мешает второму запросу, пока первый ещё идёт. willDisplay срабатывает для последней ячейки повторно (отскок, повторная раскладка), обе задачи читают одинаковый currentPage, и второе добавление вставляет идентификаторы, которые уже есть в снапшоте: «supplied item identifiers are not unique».
Один запрос за раз: перед стартом проверяйте флаг isLoading (или сохранённый Task) и держите флаг hasMore, чтобы конец ленты перестал запрашиваться.
Запускать заранее: порог за несколько элементов до конца или prefetch data source, чтобы следующая страница пришла раньше, чем пользователь доберётся до низа.
Курсор вместо номера страницы: при постраничном смещении новый пост на сервере сдвигает страницу 2, и её первый элемент повторяет последний элемент страницы 1. Просите «элементы после X» и всё равно убирайте уже известные id перед добавлением.
Показывать состояние: футер загрузки и строка ошибки с кнопкой Retry. Упавший запрос не должен оставлять isLoading навсегда включённым, и это гарантирует defer.
Дайте каждой ячейке свой collection view и свой data source, передавайте элементы строки при конфигурации и сохраняйте горизонтальное смещение каждой строки по её id, иначе из-за переиспользования одна строка окажется прокрученной туда, где оставили другую, а прокрученная строка при возврате сбросится.
Владение: ячейка владеет внутренним collection view и небольшим diffable data source, контроллер только передаёт данные. Если сделать внешний контроллер data source внутренних коллекций и различать строки по tag, всё сломается, как только строки сдвинутся.
Переиспользование: внутренний view при переиспользовании ячейки сохраняет свои элементы и contentOffset. Применяйте новый снапшот в конфигурации через applySnapshotUsingReloadData (между несвязанными строками анимировать нечего), а затем восстанавливайте сохранённое смещение.
Запоминать смещения: сохраняйте contentOffset.x по id строки в tableView(_:didEndDisplaying:forRowAt:) и передавайте обратно при конфигурации.
Высота: задайте строке фиксированную высоту или констрейнт высоты внутреннему collection view: у scroll view нет intrinsic content size, от которого можно было бы подстроиться.
На новом экране: обойдитесь без вложенности: один compositional layout с секциями orthogonalScrollingBehavior даёт те же полки с одним data source и без вложенного переиспользования.
UIKit переместил ячейку, а data source обновил свой снапшот, но новый порядок никто не записал в вашу модель, и следующий снапшот, построенный из модели, возвращает строку назад.
Как включить: dataSource.reorderingHandlers.canReorderItem должен вернуть true для элемента, а list-ячейке нужен аксессуар .reorder(), чтобы появилась ручка.
Как сохранить: reorderingHandlers.didReorder получает NSDiffableDataSourceTransaction; примените его difference к массиву порядка через Array.applying(_:) и сохраните результат прямо там, пока ничто не перестроило экран.
Классический data source: правило то же: collectionView(_:moveItemAt:to:) и есть место, где вы переставляете элемент в своём массиве.
Между секциями или приложениями: это уже полноценный drag and drop: UICollectionViewDragDelegate возвращает UIDragItem, а UICollectionViewDropDelegate возвращает UICollectionViewDropProposal и выполняет работу в collectionView(_:performDropWith:).
Decoration item для фона секции и привязанный supplementary item для бейджа. Декорации создаёт и размещает только layout; supplementary-вью достаются через ваш data source, как ячейки.
Decoration: NSCollectionLayoutDecorationItem.background(elementKind:) в section.decorationItems рисуется под ячейками этой секции; его contentInsets отодвигают подложку от краёв. Data source её никогда не видит, поэтому класс view регистрируется на layout через register(_:forDecorationViewOfKind:); если забудете, приложение упадёт при первом показе секции.
Хедеры и футеры: NSCollectionLayoutBoundarySupplementaryItem в section.boundarySupplementaryItems; pinToVisibleBounds = true делает его липким. Задайте .estimated высоту, чтобы он рос вместе с Dynamic Type.
Привязанный бейдж: NSCollectionLayoutSupplementaryItem(layoutSize:elementKind:containerAnchor:), переданный в NSCollectionLayoutItem(layoutSize:supplementaryItems:). NSCollectionLayoutAnchor с краями [.top, .trailing] и fractionalOffset вроде (0.3, -0.3) вывешивает его частично за угол.
Откуда берутся вью: dataSource.supplementaryViewProvider должен вернуть view для каждого kind, который запросил layout; nil означает падение на assertion, а не пустое место.
Поставьте секции orthogonalScrollingBehavior = .groupPagingCentered и масштабируйте карточки в visibleItemsInvalidationHandler: он вызывается на каждом кадре прокрутки этой секции с видимыми элементами и текущим смещением.
Ортогональная секция прокручивается внутри приватного scroll view, который создаёт layout. contentOffset внешнего collection view при движении карусели не меняется, и scrollViewDidScroll молчит, так что хендлер остаётся единственной точкой входа.
Режимы: .continuous (свободно), .continuousGroupLeadingBoundary (свободно, с остановкой на краю группы), .paging (страницы шириной collection view), .groupPaging и .groupPagingCentered (одна группа на свайп; центрированный вариант оставляет видимыми края соседей).
Тонкая настройка: с iOS 17 section.orthogonalScrollingProperties задаёт decelerationRate и bounce.
Внутри хендлера: у каждого NSCollectionLayoutVisibleItem есть frame и изменяемые transform, alpha и zIndex, которые применяются без прохода layout. Переведите расстояние от midX элемента до видимого центра в масштаб.
Ловушки: не вызывайте из хендлера invalidateLayout(): он снова войдёт в хендлер; не выделяйте в нём память, потому что он вызывается до 120 раз в секунду.
layoutAttributesForElements(in:): UIKit вызывает его снова и снова, пока пользователь скроллит, поэтому версия, которая считает фреймы или обходит все элементы, делает каждый шаг скролла O(число элементов).
Где должна быть работа
prepare(): посчитать все атрибуты один раз и закэшировать. Он вызывается после инвалидации, а не на каждом шаге скролла, и это единственное место для реальных вычислений.
collectionViewContentSize: вернуть закэшированный итог; неверное значение первым делом ломает индикатор прокрутки и отскок.
layoutAttributesForElements(in:) превращается в поиск: держите кэш отсортированным по minY и бинарным поиском находите первого кандидата или раскладывайте атрибуты по строкам.
layoutAttributesForItem(at:) и версии для supplementary и decoration: по одному обращению к словарю или массиву.
Инвалидация: вторая половина
shouldInvalidateLayout(forBoundsChange:), возвращающий true на любое изменение, перезапускает prepare() на каждом шаге скролла. Возвращайте true, только когда меняется ширина.
Для точечных обновлений возвращайте UICollectionViewLayoutInvalidationContext и отмечайте только затронутые элементы через invalidateItems(at:), чтобы prepare() перестраивал часть кэша.
С самоподстраивающимися ячейками shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:) решает, вызывает ли одна измеренная ячейка перераскладку; возвращайте false, если размер не изменился.
Тихая ловушка
layoutAttributesForElements(in:) должен возвращать и атрибуты хедеров, футеров и декораций. Всё, что не попало в этот массив, просто не показывается, и никто об этом не предупредит.
Внутри apply: он хеширует и сравнивает каждый идентификатор, чтобы найти изменения, а потом анимирует каждое изменение, причём на главном потоке. С тяжёлыми структурами-идентификаторами и одним apply на каждое сообщение сокета полный дифф повторяется много раз в секунду.
Куда уходит время
Хеширование: стоимость диффа растёт с числом элементов, а цену одного элемента определяет ваш Hashable. Структура из двадцати полей в роли идентификатора хеширует двадцать полей десять тысяч раз; UUID или String хешируются дёшево.
Анимация: пропорциональна числу изменений. Тысяча анимированных перемещений стоит дороже, чем дифф, который их нашёл; animatingDifferences: false пропускает анимацию, но дифф всё равно считает.
Частота: десять сообщений в секунду означают десять полных диффов в секунду.
Что менять
Объединять обновления: копите входящие сообщения и применяйте не чаще раза в ~100 мс.
Маленькие id, значения в хранилище: а изменения содержимого сообщайте через reconfigureItems, не создавая новых идентификаторов.
Пропускать дифф при полной замене: applySnapshotUsingReloadData(_:) для первой загрузки или нового поиска: без анимации и без диффа.
Уносить свою работу с главного потока: парсинг, слияние и сортировку модели можно делать в акторе; верните отсортированный массив id и соберите снапшот на main actor; для простых id это дёшево.
На каком потоке
Документация Apple разрешает вызывать apply с фоновой очереди, только если каждый вызов идёт с одной и той же очереди; смешение очередей даёт падения на дублирующихся идентификаторах, которые проявляются только под нагрузкой. В строгом режиме конкурентности Swift 6 самое простое правило, которое может проверить компилятор: применять на main actor, а дорогую подготовку держать подальше от него.
Сначала выделите модель строк (то, по чему делает switch метод cellForRowAt, становится идентификатором элемента в снапшоте) и покройте тестами эту чистую функцию «состояние → строки» до того, как поменяется хоть один тип UIKit.
Порядок, при котором каждый шаг можно отревьюить
Модель строк: превратите неявные строки в enum или тип id и напишите func items(for state: State) -> [Row]. Покройте unit-тестами. Это единственный шаг, затрагивающий логику, и весь риск сосредоточен на нём.
Замена контейнера: UICollectionViewCompositionalLayout.list(using:) с UICollectionLayoutListConfiguration плюс UICollectionViewDiffableDataSource над типом строки. UIListContentConfiguration (defaultContentConfiguration()) заменяет textLabel и detailTextLabel.
Методы делегата по видам: высоты строк уходят в layout; didSelectRowAt становится didSelectItemAt; свайп-действия переезжают в свайп-провайдеры list configuration; заголовки секций становятся headerMode = .supplementary; willDisplay и didEndDisplaying сохраняют имена.
**Убрать reloadData**: в последнюю очередь, когда его уже никто не вызывает.
Как доказать
Тесты модели строк из шага 1: проверьте точный порядок элементов для нескольких состояний.
Снапшот-тесты экрана на стандартном размере текста, на самом крупном размере доступности и на языке с письмом справа налево; list-ячейки не совпадают с самописными попиксельно, поэтому один раз просмотрите диффы и запишите новые эталоны.
UI-тесты на взаимодействия, которые потеряли свой метод делегата: выбор, свайп, перестановку.
Выпустите новый экран за фича-флагом на один релиз и сравните одни и те же события аналитики в обоих вариантах.
Ловушки
indexPath: длинный делегат хранит их в нескольких местах, а после диффа они устаревают. Каждый превращается в dataSource.itemIdentifier(for:) на входе и indexPath(for:) на выходе. И не делайте редизайн в том же пул-реквесте, иначе ни один дифф скриншотов ничего не докажет.