Реализация галереи изображений (Image Gallery) в мобильном приложении
Галерея кажется простым компонентом — пока не приходит реальный контент. Пользователь загружает 600 фотографий с отпуска, листает сетку, и через секунду приложение получает memory warning на iPhone 12. Проблема не в количестве фото — она в том, как UICollectionView или RecyclerView управляют декодированием и кэшированием изображений.
Где чаще всего ломается
Синхронная декодировка на main thread. UIImage(data:) внутри cellForItemAt — это блокировка главного потока. На сетке 3×3 с фото 4K это заметно сразу: подёргивание при скролле, просадка FPS до 30 на iPhone SE 2-го поколения. Решение — ImageRenderer с preparingThumbnail(of:) или Kingfisher с processor: DownsamplingImageProcessor. На Android аналогично: Glide с override(targetWidth, targetHeight) делает downsampling до отображаемого размера, а не грузит 12 МП оригинал в bitmap.
Неправильный размер кэша. NSCache без лимита — это не кэш, это утечка под нагрузкой. Типичная история: NSCache.countLimit = 100, но каждый объект — UIImage на 8 МБ, итого 800 МБ в RAM на устройстве с 4 ГБ. Правильно — лимитировать по totalCostLimit в байтах, а не по количеству.
Жизненный цикл ячеек и cancelled tasks. При быстром скролле в UICollectionView ячейка переиспользуется раньше, чем завершилась загрузка предыдущего изображения. Если не отменить URLSessionDataTask при prepareForReuse, в ячейку вставится чужое фото. SDWebImage решает это через sd_cancelCurrentImageLoad() в prepareForReuse, Kingfisher — через привязку задачи к ImageView.kf.
Как мы строим галерею
Для iOS — Kingfisher 7.x как основной image pipeline: встроенный memory + disk cache, DownsamplingImageProcessor для thumbnail, FadeTransition при загрузке. Для сложных анимаций переходов между сеткой и full-screen просмотром — UIViewControllerTransitioningDelegate с UIPresentationController, hero-анимация через matched geometry effect в SwiftUI.
На Android — Coil 2.x (Kotlin-native, Coroutines-based). AsyncImage в Compose с rememberAsyncImagePainter, diskCachePolicy = CachePolicy.ENABLED, size = Size.ORIGINAL только для full-screen просмотра. В LazyVerticalGrid — contentScale = ContentScale.Crop с явным size на уровне модификатора.
Для full-screen просмотра: PhotoView на Android (pinch-to-zoom через Matrix трансформации), MagnificationGesture + ScrollView в SwiftUI или кастомный UIPinchGestureRecognizer в UIKit. Свайп вниз для закрытия — интерактивный dismiss с UIPercentDrivenInteractiveTransition.
// iOS — downsampling при загрузке thumbnail
let processor = DownsamplingImageProcessor(size: thumbnailSize)
imageView.kf.setImage(
with: url,
options: [
.processor(processor),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage,
.transition(.fade(0.2))
]
)
Выбор и загрузка с устройства. На iOS — PHPickerViewController (iOS 14+, без запроса разрешений на полную библиотеку). На Android — ActivityResultContracts.PickMultipleVisualMedia (Photo Picker API, Android 13+) или ACTION_OPEN_DOCUMENT для старых версий. Оба варианта возвращают URI, из которых нужно создавать копии во внутреннем хранилище перед загрузкой на сервер — иначе URI протухнет после перезапуска приложения.
Offline и синхронизация. Локальный кэш на диске — SQLite через Room/CoreData для метаданных (id, url, localPath, syncStatus), файлы в FileManager.default.urls(for: .cachesDirectory) / context.getExternalFilesDir(). При восстановлении сети — очередь загрузок через BGTaskScheduler (iOS) или WorkManager (Android).
Что входит в работу
- Сеточный layout с поддержкой разных пропорций (квадраты, masonry, адаптив)
- Full-screen просмотр с pinch-to-zoom и swipe-to-dismiss
- Загрузка из устройства через системный picker
- Загрузка/скачивание с сервера с прогресс-индикатором
- Memory + disk кэш с корректными лимитами
- Offline-режим для ранее загруженных изображений
Сроки
2–3 рабочих дня для стандартной галереи. Сложные анимированные переходы, masonry layout с разными пропорциями или интеграция с облачным хранилищем — до 5 дней. Стоимость рассчитывается индивидуально после анализа требований.







