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

iTunes поиск музыки

2.2 Middle🔥 171 комментариев
#Архитектура и паттерны#Многопоточность и асинхронность#Работа с сетью

Условие

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

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

  • Поиск песен (минимум 3 символа для начала поиска)
  • Отображение списка результатов: обложка альбома, название трека, имя артиста
  • Индикатор загрузки во время поиска
  • Экран плеера с информацией о треке

API:

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

  • UIKit или SwiftUI
  • Debounce для поиска (чтобы не делать запрос на каждый символ)
  • Кэширование изображений
  • Обработка ошибок сети

Бонус:

  • Воспроизведение preview аудио (30 секунд)
  • История поиска
  • Добавление в избранное

Реальное тестовое задание от IT-компании.

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение

Архитектура проекта

Использую MVVM с Combine для реактивного программирования и debounce.

iTunesSearch/
├── Models/
│   ├── Track.swift
│   └── APIModels.swift
├── Network/
│   ├── iTunesAPIClient.swift
│   └── ImageCache.swift
├── ViewModels/
│   ├── SearchViewModel.swift
│   └── PlayerViewModel.swift
├── Views/
│   ├── SearchViewController.swift
│   ├── TrackCell.swift
│   ├── PlayerViewController.swift
│   └── LoadingIndicator.swift
└── Utilities/
    └── Constants.swift

Domain Models

Models/Track.swift:

import Foundation

struct Track: Codable, Identifiable {
    let id: Int
    let trackName: String
    let artistName: String
    let artworkUrl100: String
    let artworkUrl600: String?
    let previewUrl: String?
    let trackPrice: Double?
    let releaseDate: String?
    let collectionName: String?
    
    enum CodingKeys: String, CodingKey {
        case id = "trackId"
        case trackName
        case artistName
        case artworkUrl100
        case artworkUrl600
        case previewUrl
        case trackPrice
        case releaseDate
        case collectionName
    }
}

struct SearchResult: Codable {
    let resultCount: Int
    let results: [Track]
}

Network Layer с debounce

Network/iTunesAPIClient.swift:

import Foundation

protocol iTunesAPIClientProtocol {
    func searchTracks(term: String) async throws -> [Track]
}

final class iTunesAPIClient: iTunesAPIClientProtocol {
    private let session: URLSession
    private let baseURL = "https://itunes.apple.com/search"
    
    init(session: URLSession = .shared) {
        self.session = session
    }
    
    func searchTracks(term: String) async throws -> [Track] {
        var components = URLComponents(string: baseURL)
        components?.queryItems = [
            URLQueryItem(name: "term", value: term),
            URLQueryItem(name: "media", value: "music"),
            URLQueryItem(name: "entity", value: "song"),
            URLQueryItem(name: "limit", value: "50")
        ]
        
        guard let url = components?.url else {
            throw APIError.invalidURL
        }
        
        let (data, response) = try await session.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.invalidResponse
        }
        
        let decoder = JSONDecoder()
        let result = try decoder.decode(SearchResult.self, from: data)
        return result.results
    }
}

enum APIError: LocalizedError {
    case invalidURL
    case invalidResponse
    case decodingError
    case networkError(String)
    
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "Invalid search URL"
        case .invalidResponse:
            return "Server returned an error"
        case .decodingError:
            return "Failed to decode response"
        case .networkError(let message):
            return message
        }
    }
}

Image Cache Manager

Network/ImageCache.swift:

import UIKit

final class ImageCache {
    static let shared = ImageCache()
    private let cache = NSCache<NSString, UIImage>()
    private let queue = DispatchQueue(label: "com.itunes.imagecache", attributes: .concurrent)
    
    private init() {}
    
    func image(for url: String) async -> 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
            self?.cache.setObject(image, forKey: url as NSString)
        }
    }
    
    func loadImage(from urlString: String) async throws -> UIImage {
        // Проверяем кэш
        if let cached = await image(for: urlString) {
            return cached
        }
        
        // Загружаем с сети
        guard let url = URL(string: urlString) else {
            throw APIError.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 APIError.invalidResponse
        }
        
        // Сохраняем в кэш
        setImage(image, for: urlString)
        return image
    }
}

Search ViewModel с Debounce

ViewModels/SearchViewModel.swift:

import Foundation
import Combine

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var searchText: String = ""
    @Published var tracks: [Track] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var searchHistory: [String] = UserDefaults.standard.stringArray(forKey: "searchHistory") ?? []
    
    private let apiClient: iTunesAPIClientProtocol
    private var cancellables = Set<AnyCancellable>()
    private let debounceDelay: TimeInterval = 0.5
    
    init(apiClient: iTunesAPIClientProtocol) {
        self.apiClient = apiClient
        setupDebounce()
    }
    
    private func setupDebounce() {
        $searchText
            .debounce(for: .milliseconds(Int(debounceDelay * 1000)), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .sink { [weak self] text in
                self?.handleSearchTextChange(text)
            }
            .store(in: &cancellables)
    }
    
    private func handleSearchTextChange(_ text: String) {
        guard text.count >= 3 else {
            tracks = []
            errorMessage = nil
            return
        }
        
        performSearch(text)
    }
    
    private func performSearch(_ term: String) {
        isLoading = true
        errorMessage = nil
        
        Task {
            do {
                let results = try await apiClient.searchTracks(term: term)
                self.tracks = results
                self.isLoading = false
                self.addToHistory(term)
            } catch {
                self.errorMessage = error.localizedDescription
                self.isLoading = false
                self.tracks = []
            }
        }
    }
    
    private func addToHistory(_ term: String) {
        var history = searchHistory
        history.removeAll { $0 == term }
        history.insert(term, at: 0)
        
        // Ограничиваем историю до 10 элементов
        if history.count > 10 {
            history = Array(history.prefix(10))
        }
        
        searchHistory = history
        UserDefaults.standard.set(history, forKey: "searchHistory")
    }
    
    func clearHistory() {
        searchHistory = []
        UserDefaults.standard.removeObject(forKey: "searchHistory")
    }
}

Search View Controller

Views/SearchViewController.swift:

import UIKit
import Combine

final class SearchViewController: UIViewController {
    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var loadingIndicator: UIActivityIndicatorView!
    
    private let viewModel = SearchViewModel(apiClient: iTunesAPIClient())
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "Search Music"
        setupTableView()
        setupSearchBar()
        setupBindings()
    }
    
    private func setupTableView() {
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(TrackCell.self, forCellReuseIdentifier: "trackCell")
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 80
    }
    
    private func setupSearchBar() {
        searchBar.delegate = self
        searchBar.placeholder = "Search music (min 3 chars)..."
    }
    
    private func setupBindings() {
        viewModel.$searchText
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
        
        viewModel.$tracks
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
        
        viewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isLoading in
                if isLoading {
                    self?.loadingIndicator.startAnimating()
                } else {
                    self?.loadingIndicator.stopAnimating()
                }
            }
            .store(in: &cancellables)
        
        viewModel.$errorMessage
            .receive(on: DispatchQueue.main)
            .sink { [weak self] error in
                if let error = error {
                    self?.showError(error)
                }
            }
            .store(in: &cancellables)
    }
    
    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 SearchViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        viewModel.searchText = searchText
    }
}

extension SearchViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.tracks.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "trackCell", for: indexPath) as! TrackCell
        let track = viewModel.tracks[indexPath.row]
        cell.configure(with: track)
        return cell
    }
}

extension SearchViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let track = viewModel.tracks[indexPath.row]
        let playerVM = PlayerViewModel(track: track)
        let playerVC = PlayerViewController(viewModel: playerVM)
        navigationController?.pushViewController(playerVC, animated: true)
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

Track Cell

Views/TrackCell.swift:

import UIKit

final class TrackCell: UITableViewCell {
    private let artworkImageView = UIImageView()
    private let trackNameLabel = UILabel()
    private let artistNameLabel = UILabel()
    private let collectionNameLabel = UILabel()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        let stackView = UIStackView(arrangedSubviews: [trackNameLabel, artistNameLabel, collectionNameLabel])
        stackView.axis = .vertical
        stackView.spacing = 4
        
        artworkImageView.contentMode = .scaleAspectFill
        artworkImageView.layer.cornerRadius = 8
        artworkImageView.clipsToBounds = true
        artworkImageView.translatesAutoresizingMaskIntoConstraints = false
        artworkImageView.widthAnchor.constraint(equalToConstant: 60).isActive = true
        artworkImageView.heightAnchor.constraint(equalToConstant: 60).isActive = true
        
        let mainStack = UIStackView(arrangedSubviews: [artworkImageView, stackView])
        mainStack.axis = .horizontal
        mainStack.spacing = 12
        mainStack.alignment = .center
        mainStack.translatesAutoresizingMaskIntoConstraints = false
        
        contentView.addSubview(mainStack)
        NSLayoutConstraint.activate([
            mainStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
            mainStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            mainStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            mainStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
        ])
        
        trackNameLabel.font = .boldSystemFont(ofSize: 14)
        trackNameLabel.numberOfLines = 2
        
        artistNameLabel.font = .systemFont(ofSize: 12)
        artistNameLabel.textColor = .gray
        
        collectionNameLabel.font = .systemFont(ofSize: 11)
        collectionNameLabel.textColor = .lightGray
    }
    
    func configure(with track: Track) {
        trackNameLabel.text = track.trackName
        artistNameLabel.text = track.artistName
        collectionNameLabel.text = track.collectionName ?? "Unknown Album"
        
        // Загружаем изображение асинхронно
        Task {
            do {
                let image = try await ImageCache.shared.loadImage(from: track.artworkUrl100)
                DispatchQueue.main.async { [weak self] in
                    self?.artworkImageView.image = image
                }
            } catch {
                DispatchQueue.main.async { [weak self] in
                    self?.artworkImageView.image = UIImage(systemName: "music.note")
                }
            }
        }
    }
}

Player ViewModel

ViewModels/PlayerViewModel.swift:

import Foundation
import AVFoundation

@MainActor
final class PlayerViewModel: NSObject, ObservableObject, AVAudioPlayerDelegate {
    @Published var track: Track
    @Published var isPlaying = false
    @Published var currentTime: TimeInterval = 0
    @Published var duration: TimeInterval = 0
    @Published var isFavorite = false
    
    private var audioPlayer: AVAudioPlayer?
    private var displayLink: CADisplayLink?
    
    init(track: Track) {
        self.track = track
        super.init()
        self.isFavorite = UserDefaults.standard.bool(forKey: "favorite_\(track.id)")
    }
    
    func playPreview() {
        guard let previewUrl = track.previewUrl,
              let url = URL(string: previewUrl) else {
            return
        }
        
        Task {
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
                try AVAudioSession.sharedInstance().setActive(true)
                
                let audioPlayer = try AVAudioPlayer(data: data, fileTypeHint: .m4a)
                audioPlayer.delegate = self
                self.audioPlayer = audioPlayer
                
                self.duration = audioPlayer.duration
                audioPlayer.play()
                self.isPlaying = true
                startUpdatingTime()
            } catch {
                print("Failed to play preview: \(error)")
            }
        }
    }
    
    func pause() {
        audioPlayer?.pause()
        isPlaying = false
        stopUpdatingTime()
    }
    
    func resume() {
        audioPlayer?.play()
        isPlaying = true
        startUpdatingTime()
    }
    
    func toggleFavorite() {
        isFavorite.toggle()
        UserDefaults.standard.set(isFavorite, forKey: "favorite_\(track.id)")
    }
    
    private func startUpdatingTime() {
        let displayLink = CADisplayLink(
            target: self,
            selector: #selector(updateTime)
        )
        displayLink.add(to: .main, forMode: .common)
        self.displayLink = displayLink
    }
    
    private func stopUpdatingTime() {
        displayLink?.invalidate()
        displayLink = nil
    }
    
    @objc private func updateTime() {
        currentTime = audioPlayer?.currentTime ?? 0
    }
    
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        isPlaying = false
        currentTime = 0
        stopUpdatingTime()
    }
}

Player View Controller

Views/PlayerViewController.swift:

import UIKit
import Combine

final class PlayerViewController: UIViewController {
    private let viewModel: PlayerViewModel
    private var cancellables = Set<AnyCancellable>()
    
    // UI Elements
    private let artworkImageView = UIImageView()
    private let trackNameLabel = UILabel()
    private let artistNameLabel = UILabel()
    private let playButton = UIButton()
    private let favoriteButton = UIButton()
    private let progressSlider = UISlider()
    private let currentTimeLabel = UILabel()
    private let durationLabel = UILabel()
    
    init(viewModel: PlayerViewModel) {
        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 = .systemBackground
        title = "Player"
        setupUI()
        setupBindings()
        loadArtwork()
    }
    
    private func setupUI() {
        // Artwork
        artworkImageView.contentMode = .scaleAspectFit
        artworkImageView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(artworkImageView)
        
        // Labels
        trackNameLabel.font = .boldSystemFont(ofSize: 18)
        trackNameLabel.text = viewModel.track.trackName
        trackNameLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(trackNameLabel)
        
        artistNameLabel.font = .systemFont(ofSize: 14)
        artistNameLabel.textColor = .gray
        artistNameLabel.text = viewModel.track.artistName
        artistNameLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(artistNameLabel)
        
        // Buttons
        playButton.setTitle("▶ Play Preview", for: .normal)
        playButton.setTitle("⏸ Pause", for: .selected)
        playButton.backgroundColor = .systemBlue
        playButton.setTitleColor(.white, for: .normal)
        playButton.layer.cornerRadius = 8
        playButton.addTarget(self, action: #selector(didTapPlayButton), for: .touchUpInside)
        playButton.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(playButton)
        
        favoriteButton.setImage(UIImage(systemName: "heart"), for: .normal)
        favoriteButton.setImage(UIImage(systemName: "heart.fill"), for: .selected)
        favoriteButton.addTarget(self, action: #selector(didTapFavorite), for: .touchUpInside)
        favoriteButton.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(favoriteButton)
        
        // Progress
        progressSlider.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(progressSlider)
        
        currentTimeLabel.text = "0:00"
        currentTimeLabel.font = .systemFont(ofSize: 12)
        currentTimeLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(currentTimeLabel)
        
        durationLabel.text = "0:30"
        durationLabel.font = .systemFont(ofSize: 12)
        durationLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(durationLabel)
        
        // Constraints
        NSLayoutConstraint.activate([
            artworkImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40),
            artworkImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            artworkImageView.widthAnchor.constraint(equalToConstant: 250),
            artworkImageView.heightAnchor.constraint(equalToConstant: 250),
            
            trackNameLabel.topAnchor.constraint(equalTo: artworkImageView.bottomAnchor, constant: 40),
            trackNameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            
            artistNameLabel.topAnchor.constraint(equalTo: trackNameLabel.bottomAnchor, constant: 8),
            artistNameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            
            playButton.topAnchor.constraint(equalTo: artistNameLabel.bottomAnchor, constant: 40),
            playButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            playButton.widthAnchor.constraint(equalToConstant: 150),
            playButton.heightAnchor.constraint(equalToConstant: 50),
            
            favoriteButton.topAnchor.constraint(equalTo: playButton.topAnchor),
            favoriteButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            favoriteButton.widthAnchor.constraint(equalToConstant: 40),
            favoriteButton.heightAnchor.constraint(equalToConstant: 40),
            
            progressSlider.topAnchor.constraint(equalTo: playButton.bottomAnchor, constant: 40),
            progressSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            progressSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            
            currentTimeLabel.topAnchor.constraint(equalTo: progressSlider.bottomAnchor, constant: 8),
            currentTimeLabel.leadingAnchor.constraint(equalTo: progressSlider.leadingAnchor),
            
            durationLabel.topAnchor.constraint(equalTo: progressSlider.bottomAnchor, constant: 8),
            durationLabel.trailingAnchor.constraint(equalTo: progressSlider.trailingAnchor)
        ])
    }
    
    private func setupBindings() {
        viewModel.$isPlaying
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isPlaying in
                self?.playButton.isSelected = isPlaying
            }
            .store(in: &cancellables)
        
        viewModel.$isFavorite
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isFavorite in
                self?.favoriteButton.isSelected = isFavorite
            }
            .store(in: &cancellables)
        
        viewModel.$currentTime
            .receive(on: DispatchQueue.main)
            .sink { [weak self] time in
                self?.progressSlider.value = Float(time / max(self?.viewModel.duration ?? 1, 1))
                self?.currentTimeLabel.text = self?.formatTime(time) ?? "0:00"
            }
            .store(in: &cancellables)
        
        viewModel.$duration
            .receive(on: DispatchQueue.main)
            .sink { [weak self] duration in
                self?.durationLabel.text = self?.formatTime(duration) ?? "0:30"
                self?.progressSlider.maximumValue = Float(duration)
            }
            .store(in: &cancellables)
    }
    
    private func loadArtwork() {
        Task {
            do {
                let artworkUrl = viewModel.track.artworkUrl600 ?? viewModel.track.artworkUrl100
                let image = try await ImageCache.shared.loadImage(from: artworkUrl)
                DispatchQueue.main.async { [weak self] in
                    self?.artworkImageView.image = image
                }
            } catch {
                DispatchQueue.main.async { [weak self] in
                    self?.artworkImageView.image = UIImage(systemName: "music.note.list")
                }
            }
        }
    }
    
    private func formatTime(_ seconds: TimeInterval) -> String {
        let minutes = Int(seconds) / 60
        let secs = Int(seconds) % 60
        return String(format: "%d:%02d", minutes, secs)
    }
    
    @objc private func didTapPlayButton() {
        if viewModel.isPlaying {
            viewModel.pause()
        } else if !viewModel.track.previewUrl.isEmpty {
            if viewModel.audioPlayer != nil {
                viewModel.resume()
            } else {
                viewModel.playPreview()
            }
        }
    }
    
    @objc private func didTapFavorite() {
        viewModel.toggleFavorite()
    }
}

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

  1. Debounce с Combine — не более 1 запроса в 0.5 секунды, не на каждый символ
  2. Image Cache — NSCache для быстрой загрузки обложек
  3. Search History — UserDefaults для сохранения последних 10 поисков
  4. Audio Playback — 30-секундный preview с контролем воспроизведения
  5. Favorites — сохранение избранных треков в UserDefaults
  6. Error Handling — правильная обработка сетевых ошибок
  7. MVVM архитектура — разделение concerns, легко тестировать
  8. Combine binding — реактивный UI без нужды в KVO

Это решение демонстрирует production-ready подход к iOS разработке.

iTunes поиск музыки | PrepBro