Оптимизация списков (RecyclerView/UITableView/ListView) мобильного приложения
UITableView дёргается при быстром скролле — и почти всегда причина не в «медленном железе», а в синхронной декодировке JPEG на main thread в cellForRowAt. Prefetch срабатывает слишком поздно, ячейка уже запрошена, и пока изображение декодируется — кадр пропущен. На iPhone SE 2nd gen с его скромной памятью это воспроизводится стабильно там, где на Pro не замечается вовсе.
Реальные причины тормозов
Самая частая история на Android: RecyclerView с LinearLayoutManager и сотнями элементов, где в onBindViewHolder выполняется Picasso.get().load(url).into(imageView) без явного placeholder и без отмены предыдущего запроса через tag. При быстром скролле запросы накапливаются, старые не отменяются, UI-поток периодически блокируется колбэками. Переход на Glide с RequestManager привязанным к lifecycle и preload() в onScrollStateChanged решает это без каких-либо изменений в логике.
На iOS аналогичная ситуация: SDWebImage без SDWebImageAvoidAutoSetImage применяет изображение на main thread сразу после загрузки, вне зависимости от того, видна ли ячейка. Добавляешь sd_setImageWithURL:placeholderImage:options:SDWebImageAvoidAutoSetImage и применяешь в completion только если indexPath == self.tableView.indexPathForCell(cell) — и дёрганье исчезает.
Вторая по частоте проблема — тяжёлые вычисления высоты ячейки. UITableView.automaticDimension удобен, но при сложном layout с несколькими UILabel запускает полный systemLayoutSizeFitting на каждую видимую ячейку. Кэш высот через [IndexPath: CGFloat] и пересчёт только при изменении данных решает проблему.
На Jetpack Compose LazyColumn без key {} не может корректно переиспользовать composable при изменении данных — при submitList с изменёнными элементами перерисовываются все видимые ячейки вместо изменённых.
Что делаем конкретно
Android RecyclerView:
-
setHasFixedSize(true)если размер RecyclerView не меняется при обновлении данных -
setItemViewCacheSize(20)для увеличения offscreen кэша ячеек -
RecycledViewPool.setMaxRecycledViews(type, count)при нескольких RecyclerView с одинаковым типом ячеек — шаринг пула -
AsyncListDifferилиListAdapterсDiffUtil.ItemCallback— diff на фоновом потоке обязателен для любого динамического списка - Prefetch через
LinearLayoutManager.setInitialPrefetchItemCount()для nested horizontal списков
iOS UITableView / UICollectionView:
-
prefetchDataSource— декодируем и кэшируем данные доcellForRowAt -
estimatedRowHeightс реальным значением (не44для ячеек высотой120) — неправильныйestimatedRowHeightвызывает прыжки при скролле -
prepareForReuse()— обязательная отмена всех async-операций:imageLoadTask?.cancel() - Offscreen rendering ячеек через
UIGraphicsImageRendererдля статичного контента (аватары, иконки с наложением)
Flutter LazyColumn (ListView.builder):
-
itemExtent— если все элементы одной высоты, указание фиксированногоitemExtentубирает необходимость measure каждой ячейки -
cacheExtent— увеличиваем до500–1000пикселей для preloading вне viewport -
AutomaticKeepAliveClientMixin— сохраняем состояние ячеек при скролле назад
Кейс с вложенными списками
Горизонтальный RecyclerView внутри вертикального — распространённый паттерн для «Netflix-like» интерфейсов. Типичная ошибка: каждый горизонтальный RecyclerView создаёт свой RecycledViewPool. При скролле вертикального списка горизонтальные ресайклятся вместе с дочерними элементами, и при возврате ячейки их состояние (позиция скролла) теряется.
Решение: выносим RecycledViewPool на уровень активности и передаём в каждый горизонтальный RecyclerView через setRecycledViewPool(). Сохраняем LinearLayoutManager.onSaveInstanceState() в ViewModel по ключу позиции. Итог — плавный скролл и сохранение позиции при прокрутке вертикального списка.
Сроки
Аудит и оптимизация одного проблемного списка — 2–4 дня. Системная работа со всеми списками в приложении — 1–2 недели.







