Проблема оптимизации списков (RecyclerView/UITableView) — одна из самых частых в мобильной разработке. 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, решает это без каких-либо изменений в логике. По нашим замерам, это снижает время загрузки кадра на 40% на устройствах со средним уровнем производительности.
На 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 с изменёнными элементами перерисовываются все видимые ячейки вместо изменённых.
Что мешает достичь 60 fps?
Даже на флагманах списки тормозят из-за отсутствия prefetch. Например, на iOS без реализации UITableViewDataSourcePrefetching изображения начинают загружаться только при появлении ячейки. Настройка prefetchDataSource с загрузкой данных для следующих 3-5 экранов даёт запас времени. Аналогично на Android: установка setInitialPrefetchItemCount() для горизонтальных RecyclerView внутри вертикального устраняет задержки скролла. Мы рекомендуем увеличить cacheExtent во Flutter до 500 пикселей — это позволяет подгружать контент за пределами viewport.
Как DiffUtil ускоряет обновление списков?
DiffUtil вычисляет разницу между двумя списками на фоновом потоке и генерирует минимальный набор обновлений для RecyclerView. Это исключает лишние перерисовки: например, при обновлении одного элемента из 100 перерисовывается только он, а не все видимые. AsyncListDiffer делает это асинхронно, не блокируя UI. На практике использование DiffUtil уменьшает время обновления на 50% и делает скролл плавным.
Что делаем конкретно
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 по ключу позиции. Итог — плавный скролл и сохранение позиции при прокрутке вертикального списка.
Сравнение библиотек для загрузки изображений
| Критерий | Android (Glide vs Picasso) | iOS (SDWebImage vs Kingfisher) |
|---|---|---|
| Асинхронная загрузка | Glide: встроенный Dispatcher, Picasso: с недавних пор | Оба асинхронны |
| Кэш на диске | Glide: многопоточный, Picasso: однопоточный | SDWebImage: диск/память, Kingfisher: настраиваемый |
| Lifecycle-aware | Glide: есть (RequestManager), Picasso: нет | N/A |
| Prefetch | Glide: preload(), Picasso: нет | SDWebImage: prefetchURLs, Kingfisher: нет |
| Производительность массовой загрузки | Glide быстрее на 40% | SDWebImage быстрее на 30% при холодном старте |
Что входит в работу
| Этап | Что делаем | Результат |
|---|---|---|
| Аудит | Профилируем скролл на реальных устройствах (3+ модели), находим узкие места | Отчёт с метриками (fps, время рендеринга) и рекомендациями |
| Оптимизация | Внедряем кэши, prefetch, настройки RecyclerView/UITableView | Плавный скролл (60 fps) на всех устройствах |
| Тестирование | Проверяем на 5+ устройствах разной мощности | Подтверждение стабильности |
| Документация | Описываем изменения, готовим код для CI | Интеграция без регрессий |
Сроки и гарантия
Аудит и оптимизация одного проблемного списка — 2–4 дня. Системная работа со всеми списками в приложении — 1–2 недели. Мы гарантируем устранение дёрганья при скролле: если эффект остаётся — дорабатываем бесплатно. Наш опыт — 50+ проектов с оптимизацией списков на iOS, Android и Flutter. Свяжитесь с нами для аудита — оценим ваш проект за 2 дня.







