Когда лента в iOS-приложении начинает подтормаживать при прокрутке, а память растёт до сотен мегабайт, первым делом проверяют, не грузятся ли все изображения и видео сразу — отсутствие ленивой загрузки (lazy loading) медиа является самой частой причиной таких симптомов. Паттерн lazy media означает, что контент загружается только в тот момент, когда он реально появляется на экране или вот-вот появится, а не при открытии экрана целиком.
В этой статье разберём, как устроена отложенная загрузка медиа в iOS, какие инструменты даёт система, как реализовать её в UIKit и SwiftUI, и какие ошибки чаще всего приводят к утечкам памяти и мерцанию ячеек.
Что такое lazy media и зачем это нужно
Lazy media — это подход, при котором изображения, видео и другие тяжёлые ресурсы подгружаются асинхронно по требованию. Вместо загрузки всех файлов при инициализации экрана приложение запрашивает только те, что попадают в видимую область, плюс небольшой буфер для плавного скролла.
Выгода тройная. Во-первых, снижается потребление оперативной памяти: распакованное изображение в памяти занимает значительно больше, чем его сжатый файл, поэтому десятки «невидимых» картинок способны привести к принудительному завершению приложения системой. Во-вторых, экономится трафик пользователя. В-третьих, интерфейс остаётся отзывчивым, потому что главный поток не блокируется декодированием данных.
Что даёт система из коробки
Платформа iOS уже содержит механизмы, которые работают по принципу ленивой загрузки. Переиспользование ячеек в UITableView и UICollectionView через dequeueReusableCell — фундамент: система создаёт ограниченный пул ячеек и переиспользует их при скролле, поэтому загрузку контента логично привязывать к моменту конфигурации ячейки.
Дополнительно существует API предзагрузки — UITableViewDataSourcePrefetching и UICollectionViewDataSourcePrefetching. Он позволяет начать сетевой запрос заранее, до того как ячейка станет видимой, и тем самым скрыть задержку сети.
- 🔄 Reusable cells — переиспользование ячеек, базовый механизм экономии памяти;
- ⚡ Prefetching API — упреждающая загрузка данных для ячеек, которые скоро появятся;
- 🖼️ ImageIO — инструменты для потокового декодирования и даунсемплинга изображений;
- 🎬 AVPlayer — потоковое воспроизведение видео без полной загрузки файла.
Реализация в UIKit: UITableView и UICollectionView
Классическая схема выглядит так. В методе конфигурации ячейки вы запускаете асинхронную загрузку по URL, а в prepareForReuse отменяете предыдущий запрос и сбрасываете картинку на плейсхолдер. Без отмены запроса возникает известная проблема: из-за переиспользования ячеек в быстрой прокрутке в ячейку подставляется «чужое» изображение.
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
imageLoader.load(url: items[indexPath.row].imageURL)
}
}
Обратите внимание: prefetching следует сочетать с отменой в cancelPrefetchingForItemsAt, иначе при резкой смене направления скролла очередь запросов разрастётся. Также полезно ограничивать число одновременных загрузок, чтобы не перегружать сеть и память.
☑️ Чек-лист lazy loading в UICollectionView
Lazy media в SwiftUI
В SwiftUI базовый инструмент — AsyncImage, который сам загружает картинку по URL и отображает фазы: плейсхолдер, результат или ошибку. Для длинных списков его размещают внутри LazyVStack или List, чтобы представления создавались только при появлении на экране.
Однако у встроенного решения есть ограничения: тонкий контроль над кэшированием и приоритетами загрузки в AsyncImage ограничен. Если нужен собственный кэш на диске, даунсемплинг или отмена запросов, обычно пишут собственный view-model-загрузчик или подключают стороннюю библиотеку. Конкретный выбор зависит от требований проекта — универсального «лучшего» варианта здесь нет.
Кэширование и работа с памятью
Ленивая загрузка без кэша заставляет приложение качать одни и те же файлы снова и снова при прокрутке вверх-вниз. Стандартный подход — двухуровневый кэш: быстрый в памяти (часто на базе NSCache, который сам освобождает данные при нехватке памяти) и постоянный на диске для повторных запусков.
Отдельная тема — даунсемплинг. Если сервер отдаёт фото в высоком разрешении, а на экране нужна миниатюра, декодирование полного размера впустую расходует память. Инструменты ImageIO позволяют декодировать изображение сразу в нужном размере. Точные параметры зависят от формата исходников, поэтому стоит сверяться с документацией Apple по CGImageSource.
⚠️ Внимание: хранение декодированных изображений в обычном словаре без ограничений — типичная причина memory warning и последующего закрытия приложения системой. Используйте кэш с автоматической очисткой и обрабатывайте предупреждения о нехватке памяти.
Ленивая загрузка видео
С видео ситуация принципиально иная: полный файл почти никогда не загружают целиком. AVPlayer умеет проигрывать поток по мере поступления данных, поэтому «ленивость» здесь заключается в другом — создавать плеер и начинать буферизацию только для видимой ячейки и освобождать ресурсы, когда ячейка уходит с экрана.
Для лент с автовоспроизведением важно ограничивать число одновременно активных плееров: несколько параллельных буферизаций заметно нагружают устройство. Практический приём — держать активным только один плеер для текущей видимой ячейки и ставить остальные на паузу с обнулением ссылки на плеер в didEndDisplaying.
| Подход | Когда применять | Основной плюс | Ограничение |
|---|---|---|---|
| URLSession + свой кэш | Полный контроль над логикой | Гибкость, нет зависимостей | Много ручной работы |
| Сторонние библиотеки | Типовые ленты изображений | Готовые кэш и отмена запросов | Внешняя зависимость в проекте |
| AsyncImage (SwiftUI) | Простые списки, прототипы | Минимум кода | Ограниченный контроль кэша |
| AVPlayer потоково | Видеоконтент | Нет полной загрузки файла | Нужно управлять жизненным циклом плеера |
Типичные ошибки и их диагностика
Первая ошибка — загрузка на главном потоке через синхронный Data(contentsOf:). Даже для локальных файлов это блокирует UI; для сетевых URL такой вызов недопустим вовсе. Вторая — отсутствие отмены запроса в переиспользуемых ячейках, о чём говорилось выше. Третья — игнорирование даунсемплинга: декодирование полноразмерного фото для миниатюры расходует память пропорционально исходному разрешению, а не размеру на экране.
Для диагностики используйте Instruments: шаблоны Allocations и Leaks покажут рост памяти при скролле, а Time Profiler — блокировки главного потока. Если память растёт линейно с прокруткой и не возвращается назад, почти наверняка кэш не ограничен или где-то удерживаются ссылки на изображения (например, через замыкания с сильным захватом).
⚠️ Внимание: замыкания завершения загрузки, захватывающие ячейку или контроллер сильной ссылкой, создают retain cycle и мешают освобождению памяти. Используйте [weak self] и проверяйте актуальность ячейки перед обновлением UI.
Как проверить, что lazy loading работает
Откройте Network Link Conditioner или замедлите сеть, прокрутите ленту и убедитесь, что запросы идут только для видимых и соседних ячеек. В Instruments (Allocations) память при длинном скролле должна колебаться в ограниченном диапазоне, а не расти монотонно. При возврате к уже просмотренным ячейкам изображения должны появляться мгновенно из кэша, без повторного сетевого запроса.
Когда lazy loading не нужен
Не каждый экран требует отложенной загрузки. Если на экране статично отображается два-три небольших изображения из asset-каталога, усложнение архитектуры не даст ничего, кроме лишнего кода. Ленивая загрузка оправдана там, где контента потенциально много: ленты, галереи, каталоги, чаты с медиавложениями.
Также учитывайте обратную сторону prefetching: агрессивная предзагрузка на большом буфере может расходовать трафик пользователя на контент, который он так и не увидит. Разумный компромисс — небольшое окно предзагрузки и отмена запросов при смене направления скролла.
Часто задаваемые вопросы
Чем prefetching отличается от обычной ленивой загрузки?
Lazy loading запускает загрузку в момент появления ячейки, а prefetching — заранее, для ячеек, которые скоро станут видимыми. Сочетание обоих подходов даёт плавный скролл без видимых плейсхолдеров.
Достаточно ли AsyncImage для продакшен-приложения?
Для простых списков — часто да. Если нужны дисковый кэш, даунсемплинг, приоритеты и отмена запросов, потребуется собственный загрузчик или сторонняя библиотека. Решение зависит от требований конкретного проекта.
Почему в ячейках появляются «чужие» изображения при быстрой прокрутке?
Из-за переиспользования ячеек: запрос, запущенный для одной ячейки, завершается, когда она уже отображает другой элемент. Решение — отмена запроса в prepareForReuse и проверка соответствия URL перед установкой картинки.
Как избежать роста памяти при долгом скролле ленты?
Используйте кэш с автоматической очисткой (например, на базе NSCache), применяйте даунсемплинг изображений до размера отображения и проверяйте замыкания на сильные захваты. Контролируйте результат через Instruments.
Нужно ли что-то особенное для видео в ленте?
Да: создавайте AVPlayer только для видимой ячейки, освобождайте его при уходе ячейки с экрана и ограничивайте число одновременно буферизующих плееров. Полную загрузку видеофайла в память выполнять не следует.