Проблема оптимізації списків (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 дні.







