← Назад к вопросам

Простой Instagram (лента изображений)

1.8 Middle🔥 181 комментариев
#UIKit и верстка#Работа с сетью#Управление памятью

Условие

Разработайте iOS приложение для просмотра ленты изображений.

Функциональные требования:

  • Получение списка картинок из публичного API
  • Ленивая загрузка изображений (lazy loading) — картинки загружаются по мере скролла
  • При нажатии на картинку она открывается на весь экран
  • Пагинация (подгрузка новых изображений при достижении конца списка)

Рекомендуемые API:

Технические требования:

  • 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
}

Ключевые особенности

  1. Lazy Loading — загрузка изображений по мере появления на экране
  2. Пагинация — автоматическая загрузка новых изображений при скролле к концу
  3. Image Caching — NSCache с ограничением памяти до 100MB
  4. UICollectionView Grid — сетка 2x2 с адаптивным размером
  5. Placeholder — UIActivityIndicatorView во время загрузки
  6. Zoom & Pan — UIScrollView с поддержкой зума до 4x
  7. Favorites — сохранение в UserDefaults
  8. Pull-to-Refresh — переподгрузка ленты
  9. Search — поиск по запросу с debounce
  10. Error Handling — правильная обработка сетевых ошибок

Это production-ready приложение для просмотра изображений с оптимальной производительностью.