Оптимізація списків мобільної програми (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 горизонтальних списків
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 тижні.







