← Назад к вопросам
iTunes поиск музыки
2.2 Middle🔥 171 комментариев
#Архитектура и паттерны#Многопоточность и асинхронность#Работа с сетью
Условие
Разработайте iOS приложение для поиска музыки через iTunes API.
Функциональные требования:
- Поиск песен (минимум 3 символа для начала поиска)
- Отображение списка результатов: обложка альбома, название трека, имя артиста
- Индикатор загрузки во время поиска
- Экран плеера с информацией о треке
API:
- iTunes Search API: https://itunes.apple.com/search
- Пример запроса: https://itunes.apple.com/search?term=jack+johnson&media=music
Технические требования:
- 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()
}
}
Ключевые особенности
- Debounce с Combine — не более 1 запроса в 0.5 секунды, не на каждый символ
- Image Cache — NSCache для быстрой загрузки обложек
- Search History — UserDefaults для сохранения последних 10 поисков
- Audio Playback — 30-секундный preview с контролем воспроизведения
- Favorites — сохранение избранных треков в UserDefaults
- Error Handling — правильная обработка сетевых ошибок
- MVVM архитектура — разделение concerns, легко тестировать
- Combine binding — реактивный UI без нужды в KVO
Это решение демонстрирует production-ready подход к iOS разработке.