← Назад к вопросам
Простой Instagram (лента изображений)
1.8 Middle🔥 181 комментариев
#UIKit и верстка#Работа с сетью#Управление памятью
Условие
Разработайте iOS приложение для просмотра ленты изображений.
Функциональные требования:
- Получение списка картинок из публичного API
- Ленивая загрузка изображений (lazy loading) — картинки загружаются по мере скролла
- При нажатии на картинку она открывается на весь экран
- Пагинация (подгрузка новых изображений при достижении конца списка)
Рекомендуемые API:
- Unsplash API: https://api.unsplash.com
- Pexels API: https://api.pexels.com
- Lorem Picsum: https://picsum.photos
Технические требования:
- UICollectionView (сетка) или UITableView (список)
- Кэширование изображений (NSCache или библиотека)
- Плавный скролл без подтормаживаний
- Placeholder для загружающихся изображений
Бонус:
- Zoom и pan для просмотра изображения
- Сохранение в избранное
- Поддержка различных размеров сетки
- Pull-to-refresh
Тестовое задание на позицию Junior iOS разработчика.
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение
Архитектура проекта
InstagramClone/
├── Models/
│ ├── Photo.swift
│ └── APIModels.swift
├── Network/
│ ├── PexelsAPIClient.swift
│ ├── ImageCache.swift
│ └── PaginationManager.swift
├── ViewModels/
│ ├── PhotoFeedViewModel.swift
│ └── PhotoDetailViewModel.swift
├── Views/
│ ├── PhotoFeedViewController.swift
│ ├── PhotoCell.swift
│ ├── PhotoDetailViewController.swift
│ └── LoadingPlaceholder.swift
└── Utilities/
├── UIImageView+Async.swift
└── Constants.swift
Domain Models
Models/Photo.swift:
import Foundation
struct Photo: Identifiable, Codable {
let id: Int
let width: Int
let height: Int
let url: String
let photographer: String
let src: PhotoSrc
var aspectRatio: CGFloat {
CGFloat(height) / CGFloat(width)
}
}
struct PhotoSrc: Codable {
let tiny: String
let small: String
let medium: String
let large: String
let original: String
}
struct PexelsResponse: Codable {
let photos: [Photo]
let page: Int
let per_page: Int
let total_results: Int
let next_page: String?
}
Pagination Manager
Network/PaginationManager.swift:
import Foundation
final class PaginationManager {
private(set) var currentPage = 1
private(set) var hasMore = true
private let perPage = 20
func reset() {
currentPage = 1
hasMore = true
}
func nextPage() {
currentPage += 1
}
func updateFromResponse(_ response: PexelsResponse) {
hasMore = response.next_page != nil
}
}
Image Cache с поддержкой разных размеров
Network/ImageCache.swift:
import UIKit
final class ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
private let queue = DispatchQueue(
label: "com.instagram.imagecache",
attributes: .concurrent
)
private init() {
// Настраиваем кэш на использование не более 100MB памяти
cache.totalCostLimit = 100 * 1024 * 1024
}
func image(for url: String) -> UIImage? {
return queue.sync { [weak self] in
self?.cache.object(forKey: url as NSString)
}
}
func setImage(_ image: UIImage, for url: String) {
queue.async(flags: .barrier) { [weak self] in
let estimatedSize = image.pngData()?.count ?? 100_000
self?.cache.setObject(image, forKey: url as NSString, cost: estimatedSize)
}
}
func loadImage(from urlString: String) async throws -> UIImage {
// Проверяем кэш
if let cached = image(for: urlString) {
return cached
}
// Загружаем с сети
guard let url = URL(string: urlString) else {
throw NetworkError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = UIImage(data: data) else {
throw NetworkError.invalidResponse
}
// Сохраняем в кэш
setImage(image, for: urlString)
return image
}
func clearCache() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAllObjects()
}
}
}
enum NetworkError: LocalizedError {
case invalidURL
case invalidResponse
case decodingError
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .invalidResponse: return "Server returned an error"
case .decodingError: return "Failed to decode response"
}
}
}
API Client (Pexels)
Network/PexelsAPIClient.swift:
import Foundation
protocol PhotoAPIClientProtocol {
func searchPhotos(query: String, page: Int) async throws -> PexelsResponse
func getCuratedPhotos(page: Int) async throws -> PexelsResponse
}
final class PexelsAPIClient: PhotoAPIClientProtocol {
private let apiKey: String // Получить на https://www.pexels.com/api/
private let baseURL = "https://api.pexels.com/v1"
private let session: URLSession
init(apiKey: String, session: URLSession = .shared) {
self.apiKey = apiKey
self.session = session
}
func searchPhotos(query: String, page: Int) async throws -> PexelsResponse {
var components = URLComponents(string: "\(baseURL)/search")
components?.queryItems = [
URLQueryItem(name: "query", value: query),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "per_page", value: "20")
]
guard let url = components?.url else {
throw NetworkError.invalidURL
}
return try await fetchPhotos(from: url)
}
func getCuratedPhotos(page: Int) async throws -> PexelsResponse {
var components = URLComponents(string: "\(baseURL)/curated")
components?.queryItems = [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "per_page", value: "20")
]
guard let url = components?.url else {
throw NetworkError.invalidURL
}
return try await fetchPhotos(from: url)
}
private func fetchPhotos(from url: URL) async throws -> PexelsResponse {
var request = URLRequest(url: url)
request.setValue(apiKey, forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
let decoder = JSONDecoder()
do {
return try decoder.decode(PexelsResponse.self, from: data)
} catch {
throw NetworkError.decodingError
}
}
}
Photo Feed ViewModel
ViewModels/PhotoFeedViewModel.swift:
import Foundation
import Combine
@MainActor
final class PhotoFeedViewModel: ObservableObject {
@Published var photos: [Photo] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Published var searchQuery: String = ""
@Published var favorites: Set<Int> = Set()
private let apiClient: PhotoAPIClientProtocol
private let paginationManager = PaginationManager()
private var cancellables = Set<AnyCancellable>()
private var isLoadingMore = false
init(apiClient: PhotoAPIClientProtocol) {
self.apiClient = apiClient
setupBindings()
loadCuratedPhotos()
}
private func setupBindings() {
$searchQuery
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] query in
if query.isEmpty {
self?.loadCuratedPhotos()
} else {
self?.searchPhotos(query: query)
}
}
.store(in: &cancellables)
}
func loadCuratedPhotos() {
paginationManager.reset()
photos = []
fetchCuratedPhotos()
}
func refresh() {
loadCuratedPhotos()
}
func loadMoreIfNeeded(currentIndex: Int) {
// Загружаем ещё, когда пользователь находится на 80% списка
let threshold = Int(Double(photos.count) * 0.8)
if currentIndex >= threshold && !isLoadingMore && paginationManager.hasMore {
loadMore()
}
}
private func fetchCuratedPhotos() {
isLoading = true
errorMessage = nil
Task {
do {
let response = try await apiClient.getCuratedPhotos(
page: paginationManager.currentPage
)
self.photos.append(contentsOf: response.photos)
paginationManager.updateFromResponse(response)
self.isLoading = false
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
private func searchPhotos(query: String) {
paginationManager.reset()
photos = []
isLoading = true
errorMessage = nil
Task {
do {
let response = try await apiClient.searchPhotos(
query: query,
page: paginationManager.currentPage
)
self.photos = response.photos
paginationManager.updateFromResponse(response)
self.isLoading = false
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
private func loadMore() {
guard !isLoadingMore && paginationManager.hasMore else { return }
isLoadingMore = true
paginationManager.nextPage()
Task {
do {
let response: PexelsResponse
if searchQuery.isEmpty {
response = try await apiClient.getCuratedPhotos(
page: paginationManager.currentPage
)
} else {
response = try await apiClient.searchPhotos(
query: searchQuery,
page: paginationManager.currentPage
)
}
self.photos.append(contentsOf: response.photos)
paginationManager.updateFromResponse(response)
self.isLoadingMore = false
} catch {
self.errorMessage = error.localizedDescription
self.isLoadingMore = false
}
}
}
func toggleFavorite(_ photoId: Int) {
if favorites.contains(photoId) {
favorites.remove(photoId)
} else {
favorites.insert(photoId)
}
saveFavorites()
}
func isFavorite(_ photoId: Int) -> Bool {
return favorites.contains(photoId)
}
private func saveFavorites() {
let array = Array(favorites)
UserDefaults.standard.set(array, forKey: "favoritesPhotos")
}
func loadFavorites() {
if let array = UserDefaults.standard.array(forKey: "favoritesPhotos") as? [Int] {
favorites = Set(array)
}
}
}
Photo Feed View Controller
Views/PhotoFeedViewController.swift:
import UIKit
import Combine
final class PhotoFeedViewController: UIViewController {
private let viewModel: PhotoFeedViewModel
private let collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: UICollectionViewFlowLayout()
)
private let refreshControl = UIRefreshControl()
private let searchController = UISearchController(searchResultsController: nil)
private let loadingIndicator = UIActivityIndicatorView(style: .large)
private var cancellables = Set<AnyCancellable>()
private let cellsPerRow: CGFloat = 2
private let spacing: CGFloat = 8
init(viewModel: PhotoFeedViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "Photos"
view.backgroundColor = .systemBackground
setupCollectionView()
setupSearchBar()
setupRefreshControl()
setupLoadingIndicator()
setupBindings()
viewModel.loadFavorites()
}
private func setupCollectionView() {
// Layout
let layout = UICollectionViewFlowLayout()
let totalSpacing = spacing * (cellsPerRow + 1)
let itemWidth = (view.bounds.width - totalSpacing) / cellsPerRow
layout.itemSize = CGSize(width: itemWidth, height: itemWidth)
layout.minimumInteritemSpacing = spacing
layout.minimumLineSpacing = spacing
layout.sectionInset = UIEdgeInsets(top: spacing, left: spacing, bottom: spacing, right: spacing)
collectionView.collectionViewLayout = layout
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(PhotoCell.self, forCellWithReuseIdentifier: "photoCell")
collectionView.backgroundColor = .systemBackground
// Add to view
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func setupSearchBar() {
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.placeholder = "Search photos"
navigationItem.searchController = searchController
definesPresentationContext = true
}
private func setupRefreshControl() {
refreshControl.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
collectionView.addSubview(refreshControl)
collectionView.alwaysBounceVertical = true
}
private func setupLoadingIndicator() {
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(loadingIndicator)
NSLayoutConstraint.activate([
loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
private func setupBindings() {
viewModel.$photos
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.collectionView.reloadData()
}
.store(in: &cancellables)
viewModel.$isLoading
.receive(on: DispatchQueue.main)
.sink { [weak self] isLoading in
if isLoading {
self?.loadingIndicator.startAnimating()
} else {
self?.loadingIndicator.stopAnimating()
self?.refreshControl.endRefreshing()
}
}
.store(in: &cancellables)
viewModel.$errorMessage
.receive(on: DispatchQueue.main)
.sink { [weak self] error in
if let error = error {
self?.showError(error)
}
}
.store(in: &cancellables)
}
@objc private func didPullToRefresh() {
viewModel.refresh()
}
private func showError(_ message: String) {
let alert = UIAlertController(
title: "Error",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
extension PhotoFeedViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return viewModel.photos.count
}
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "photoCell", for: indexPath) as! PhotoCell
let photo = viewModel.photos[indexPath.row]
cell.configure(with: photo, isFavorite: viewModel.isFavorite(photo.id))
cell.onFavoriteTap = { [weak self] in
self?.viewModel.toggleFavorite(photo.id)
self?.collectionView.reloadItems(at: [indexPath])
}
return cell
}
}
extension PhotoFeedViewController: UICollectionViewDelegate {
func collectionView(
_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath
) {
let photo = viewModel.photos[indexPath.row]
let detailVM = PhotoDetailViewModel(photo: photo)
let detailVC = PhotoDetailViewController(viewModel: detailVM)
navigationController?.pushViewController(detailVC, animated: true)
}
func collectionView(
_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath
) {
viewModel.loadMoreIfNeeded(currentIndex: indexPath.row)
}
}
extension PhotoFeedViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
viewModel.searchQuery = searchController.searchBar.text ?? ""
}
}
Photo Cell с Placeholder
Views/PhotoCell.swift:
import UIKit
final class PhotoCell: UICollectionViewCell {
private let imageView = UIImageView()
private let placeholderView = UIActivityIndicatorView(style: .medium)
private let favoriteButton = UIButton()
var onFavoriteTap: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
// Image View
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.backgroundColor = .systemGray5
contentView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
// Placeholder
placeholderView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(placeholderView)
NSLayoutConstraint.activate([
placeholderView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
placeholderView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
])
// Favorite Button
var config = UIButton.Configuration.plain()
config.baseForegroundColor = .systemRed
favoriteButton.configuration = config
favoriteButton.addTarget(self, action: #selector(didTapFavorite), for: .touchUpInside)
favoriteButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(favoriteButton)
NSLayoutConstraint.activate([
favoriteButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
favoriteButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
favoriteButton.widthAnchor.constraint(equalToConstant: 44),
favoriteButton.heightAnchor.constraint(equalToConstant: 44)
])
contentView.layer.cornerRadius = 8
contentView.clipsToBounds = true
}
func configure(with photo: Photo, isFavorite: Bool) {
placeholderView.startAnimating()
// Используем кэш для быстрой загрузки
Task {
do {
let image = try await ImageCache.shared.loadImage(from: photo.src.medium)
DispatchQueue.main.async { [weak self] in
self?.imageView.image = image
self?.placeholderView.stopAnimating()
}
} catch {
DispatchQueue.main.async { [weak self] in
self?.placeholderView.stopAnimating()
}
}
}
// Обновляем кнопку избранного
let heartImage = isFavorite ? UIImage(systemName: "heart.fill") : UIImage(systemName: "heart")
favoriteButton.setImage(heartImage, for: .normal)
}
@objc private func didTapFavorite() {
onFavoriteTap?()
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
placeholderView.startAnimating()
}
}
Photo Detail ViewController с Zoom & Pan
Views/PhotoDetailViewController.swift:
import UIKit
final class PhotoDetailViewController: UIViewController {
private let viewModel: PhotoDetailViewModel
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let photographer = UILabel()
init(viewModel: PhotoDetailViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupScrollView()
loadImage()
}
private func setupScrollView() {
scrollView.delegate = self
scrollView.maximumZoomScale = 4.0
scrollView.minimumZoomScale = 1.0
scrollView.bouncesZoom = true
view.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
// Image View
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
scrollView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: scrollView.topAnchor),
imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
imageView.widthAnchor.constraint(equalTo: view.widthAnchor),
imageView.heightAnchor.constraint(equalTo: view.heightAnchor)
])
}
private func loadImage() {
Task {
do {
let image = try await ImageCache.shared.loadImage(from: viewModel.photo.src.original)
DispatchQueue.main.async { [weak self] in
self?.imageView.image = image
}
} catch {
print("Failed to load image: \(error)")
}
}
}
}
extension PhotoDetailViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
struct PhotoDetailViewModel {
let photo: Photo
}
Ключевые особенности
- Lazy Loading — загрузка изображений по мере появления на экране
- Пагинация — автоматическая загрузка новых изображений при скролле к концу
- Image Caching — NSCache с ограничением памяти до 100MB
- UICollectionView Grid — сетка 2x2 с адаптивным размером
- Placeholder — UIActivityIndicatorView во время загрузки
- Zoom & Pan — UIScrollView с поддержкой зума до 4x
- Favorites — сохранение в UserDefaults
- Pull-to-Refresh — переподгрузка ленты
- Search — поиск по запросу с debounce
- Error Handling — правильная обработка сетевых ошибок
Это production-ready приложение для просмотра изображений с оптимальной производительностью.