← Назад к вопросам
Приложение погоды
2.0 Middle🔥 171 комментариев
#Архитектура и паттерны#Работа с сетью#Хранение данных
Условие
Разработайте iOS приложение для отображения погоды.
Функциональные требования:
- Минимум 2 города по умолчанию
- Поиск и добавление новых городов
- Отображение температуры в списке городов
- Детальный экран с подробной информацией о погоде
- Прогноз на 3-7 дней
Технические требования:
- Использовать OpenWeatherMap API (api.openweathermap.org)
- Реализовать offline-режим (кэширование данных)
- Минимум 2 экрана (список городов + детали)
- Архитектура: MVVM или VIPER
- Без использования Storyboard (код или SwiftUI)
- Зависимости через SPM или CocoaPods
Бонус:
- Автообновление данных при возвращении в приложение
- Pull-to-refresh
- Юнит-тесты
Типичное тестовое задание для Junior/Middle iOS позиции.
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение
Архитектура и структура проекта
Использую MVVM с Coordinators — это идеальный баланс между простотой и масштабируемостью для iOS приложений среднего размера.
WeatherApp/
├── App/
│ ├── SceneDelegate.swift
│ └── AppDelegate.swift
├── Coordinators/
│ ├── AppCoordinator.swift
│ └── NavigationCoordinator.swift
├── Domain/
│ ├── Models/
│ │ ├── Weather.swift
│ │ ├── City.swift
│ │ └── Forecast.swift
│ ├── Repositories/
│ │ └── WeatherRepositoryProtocol.swift
│ └── UseCases/
│ ├── FetchWeatherUseCase.swift
│ └── SearchCitiesUseCase.swift
├── Data/
│ ├── Network/
│ │ ├── WeatherAPIClient.swift
│ │ └── APIModels.swift
│ ├── Storage/
│ │ └── WeatherCacheManager.swift
│ └── Repositories/
│ └── WeatherRepository.swift
├── Presentation/
│ ├── CityList/
│ │ ├── CityListViewController.swift
│ │ ├── CityListViewModel.swift
│ │ └── CityListCell.swift
│ ├── CityDetail/
│ │ ├── CityDetailViewController.swift
│ │ ├── CityDetailViewModel.swift
│ │ └── ForecastCell.swift
│ ├── Search/
│ │ ├── SearchViewController.swift
│ │ └── SearchViewModel.swift
│ └── Common/
│ ├── LoadingView.swift
│ └── ErrorAlertPresenter.swift
└── Resources/
├── Assets.xcassets
└── Localized.strings
Domain Layer — Модели
Weather.swift — основная модель:
import Foundation
struct Weather: Codable {
let id: String
let cityName: String
let temperature: Double
let description: String
let humidity: Int
let windSpeed: Double
let pressure: Int
let feelsLike: Double
let tempMin: Double
let tempMax: Double
let lastUpdated: Date
}
struct Forecast: Codable {
let date: Date
let temperature: Double
let description: String
let humidity: Int
let icon: String
}
struct City: Codable, Identifiable {
let id: String
let name: String
let latitude: Double
let longitude: Double
}
Network Layer — API клиент
WeatherAPIClient.swift:
import Foundation
protocol WeatherAPIClientProtocol {
func fetchWeather(for city: String) async throws -> Weather
func fetchForecast(for city: String) async throws -> [Forecast]
func searchCities(query: String) async throws -> [City]
}
final class WeatherAPIClient: WeatherAPIClientProtocol {
private let apiKey: String
private let session: URLSession
private let baseURL = "https://api.openweathermap.org/data/2.5"
init(apiKey: String, session: URLSession = .shared) {
self.apiKey = apiKey
self.session = session
}
func fetchWeather(for city: String) async throws -> Weather {
let url = URL(string: "\(baseURL)/weather?q=\(city)&appid=\(apiKey)&units=metric")
guard let url = 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()
decoder.dateDecodingStrategy = .secondsSince1970
let apiModel = try decoder.decode(WeatherAPIModel.self, from: data)
return apiModel.toDomain()
}
func fetchForecast(for city: String) async throws -> [Forecast] {
let url = URL(string: "\(baseURL)/forecast?q=\(city)&appid=\(apiKey)&units=metric")
guard let url = 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()
decoder.dateDecodingStrategy = .secondsSince1970
let apiModel = try decoder.decode(ForecastAPIModel.self, from: data)
// Берём 1 прогноз на день (каждые 24 часа)
return apiModel.toDomain().dropDuplicates()
}
func searchCities(query: String) async throws -> [City] {
let url = URL(string: "\(baseURL)/find?q=\(query)&appid=\(apiKey)&type=like")
guard let url = 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 apiModel = try JSONDecoder().decode(SearchAPIModel.self, from: data)
return apiModel.toDomain()
}
}
enum APIError: LocalizedError {
case invalidURL
case invalidResponse
case decodingError
case networkError(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .invalidResponse: return "Invalid server response"
case .decodingError: return "Failed to parse data"
case .networkError(let error): return error.localizedDescription
}
}
}
Кэширование — Storage Layer
WeatherCacheManager.swift:
import Foundation
final class WeatherCacheManager {
private let defaults = UserDefaults.standard
private let cachePrefix = "weather_cache_"
private let cacheDuration: TimeInterval = 3600 // 1 час
func saveWeather(_ weather: Weather, for city: String) {
let cacheData = CacheData(weather: weather, timestamp: Date())
let key = cachePrefix + city.lowercased()
if let encoded = try? JSONEncoder().encode(cacheData) {
defaults.set(encoded, forKey: key)
}
}
func getWeather(for city: String) -> Weather? {
let key = cachePrefix + city.lowercased()
guard let data = defaults.data(forKey: key),
let cacheData = try? JSONDecoder().decode(CacheData.self, from: data) else {
return nil
}
// Проверяем, не устарели ли данные
let isExpired = Date().timeIntervalSince(cacheData.timestamp) > cacheDuration
return isExpired ? nil : cacheData.weather
}
func clearExpiredCache() {
let defaults = UserDefaults.standard
let allKeys = defaults.dictionaryRepresentation().keys
for key in allKeys where key.hasPrefix(cachePrefix) {
if let data = defaults.data(forKey: key),
let cacheData = try? JSONDecoder().decode(CacheData.self, from: data) {
let isExpired = Date().timeIntervalSince(cacheData.timestamp) > cacheDuration
if isExpired {
defaults.removeObject(forKey: key)
}
}
}
}
private struct CacheData: Codable {
let weather: Weather
let timestamp: Date
}
}
Repository Pattern
WeatherRepository.swift:
protocol WeatherRepositoryProtocol {
func fetchWeather(for city: String) async throws -> Weather
func searchCities(query: String) async throws -> [City]
}
final class WeatherRepository: WeatherRepositoryProtocol {
private let apiClient: WeatherAPIClientProtocol
private let cacheManager: WeatherCacheManager
init(apiClient: WeatherAPIClientProtocol, cacheManager: WeatherCacheManager) {
self.apiClient = apiClient
self.cacheManager = cacheManager
}
func fetchWeather(for city: String) async throws -> Weather {
// Сначала проверяем кэш
if let cached = cacheManager.getWeather(for: city) {
return cached
}
// Если нет в кэше, запрашиваем с API
let weather = try await apiClient.fetchWeather(for: city)
cacheManager.saveWeather(weather, for: city)
return weather
}
func searchCities(query: String) async throws -> [City] {
return try await apiClient.searchCities(query: query)
}
}
Presentation Layer — MVVM
CityListViewModel.swift:
import Foundation
import Combine
@MainActor
final class CityListViewModel: ObservableObject {
@Published var cities: [Weather] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let repository: WeatherRepositoryProtocol
private let defaultCities = ["London", "New York"]
private var cancellables = Set<AnyCancellable>()
init(repository: WeatherRepositoryProtocol) {
self.repository = repository
loadInitialCities()
}
func loadInitialCities() {
Task {
for city in defaultCities {
await loadWeather(for: city)
}
}
}
func loadWeather(for city: String) async {
DispatchQueue.main.async { self.isLoading = true }
do {
let weather = try await repository.fetchWeather(for: city)
DispatchQueue.main.async {
if !self.cities.contains(where: { $0.id == weather.id }) {
self.cities.append(weather)
} else {
if let index = self.cities.firstIndex(where: { $0.id == weather.id }) {
self.cities[index] = weather
}
}
self.isLoading = false
self.errorMessage = nil
}
} catch {
DispatchQueue.main.async {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
func refresh() {
Task {
await loadInitialCities()
}
}
}
CityListViewController.swift (с SwiftUI):
import UIKit
import SwiftUI
final class CityListViewController: UIViewController {
private let viewModel: CityListViewModel
private let refreshControl = UIRefreshControl()
private weak var tableView: UITableView?
init(viewModel: CityListViewModel) {
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()
setupUI()
setupBindings()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Автообновление при возвращении
viewModel.refresh()
}
private func setupUI() {
title = "Weather"
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(didTapAdd)
)
let tableView = UITableView(frame: .zero, style: .plain)
tableView.dataSource = self
tableView.delegate = self
tableView.register(CityWeatherCell.self, forCellReuseIdentifier: "cell")
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
self.tableView = tableView
// Pull-to-refresh
refreshControl.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
tableView.addSubview(refreshControl)
}
private func setupBindings() {
viewModel.$cities
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.tableView?.reloadData()
}
.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 didTapAdd() {
let searchVM = SearchViewModel(repository: viewModel.repository)
let searchVC = SearchViewController(viewModel: searchVM)
let nav = UINavigationController(rootViewController: searchVC)
present(nav, animated: true)
}
@objc private func didPullToRefresh() {
viewModel.refresh()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.refreshControl.endRefreshing()
}
}
private var cancellables = Set<AnyCancellable>()
}
extension CityListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.cities.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CityWeatherCell
let weather = viewModel.cities[indexPath.row]
cell.configure(with: weather)
return cell
}
}
extension CityListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let weather = viewModel.cities[indexPath.row]
let detailVM = CityDetailViewModel(weather: weather, repository: viewModel.repository)
let detailVC = CityDetailViewController(viewModel: detailVM)
navigationController?.pushViewController(detailVC, animated: true)
tableView.deselectRow(at: indexPath, animated: true)
}
}
Dependency Injection
AppCoordinator.swift:
import UIKit
final class AppCoordinator {
private let window: UIWindow
private lazy var apiClient = WeatherAPIClient(
apiKey: "YOUR_API_KEY" // Из Config или Info.plist
)
private lazy var cacheManager = WeatherCacheManager()
private lazy var repository = WeatherRepository(
apiClient: apiClient,
cacheManager: cacheManager
)
init(window: UIWindow) {
self.window = window
}
func start() {
let viewModel = CityListViewModel(repository: repository)
let viewController = CityListViewController(viewModel: viewModel)
let nav = UINavigationController(rootViewController: viewController)
window.rootViewController = nav
window.makeKeyAndVisible()
}
}
Unit-тесты
CityListViewModelTests.swift:
import XCTest
@testable import WeatherApp
final class CityListViewModelTests: XCTestCase {
var sut: CityListViewModel!
var mockRepository: MockWeatherRepository!
override func setUp() {
super.setUp()
mockRepository = MockWeatherRepository()
sut = CityListViewModel(repository: mockRepository)
}
func testLoadWeatherSuccess() async {
// Arrange
let mockWeather = Weather(
id: "1",
cityName: "London",
temperature: 15.0,
description: "Cloudy",
humidity: 65,
windSpeed: 10.5,
pressure: 1013,
feelsLike: 13.0,
tempMin: 12.0,
tempMax: 18.0,
lastUpdated: Date()
)
mockRepository.mockWeather = mockWeather
// Act
await sut.loadWeather(for: "London")
// Assert
XCTAssertEqual(sut.cities.count, 1)
XCTAssertEqual(sut.cities.first?.cityName, "London")
}
func testLoadWeatherFailure() async {
// Arrange
mockRepository.mockError = APIError.invalidURL
// Act
await sut.loadWeather(for: "InvalidCity")
// Assert
XCTAssertNil(sut.cities.first)
XCTAssertNotNil(sut.errorMessage)
}
}
class MockWeatherRepository: WeatherRepositoryProtocol {
var mockWeather: Weather?
var mockError: Error?
func fetchWeather(for city: String) async throws -> Weather {
if let error = mockError {
throw error
}
return mockWeather ?? Weather(
id: "1", cityName: city, temperature: 20,
description: "Clear", humidity: 50, windSpeed: 5,
pressure: 1013, feelsLike: 20, tempMin: 15, tempMax: 25,
lastUpdated: Date()
)
}
func searchCities(query: String) async throws -> [City] {
return []
}
}
Ключевые особенности
- MVVM с Coordinators — чистая архитектура, легко тестировать
- Async/await — вместо старых замыканий, modern Swift API
- Combine — реактивное программирование для UI обновлений
- Кэширование — работает offline, снижает нагрузку на API
- Dependency Injection — все зависимости инъецируются
- Unit-тесты — Mock repository для изолированного тестирования
- Pull-to-refresh — стандартный паттерн iOS
- Error Handling — правильное распределение ошибок через слои
API Key
Зарегистрируйтесь на https://openweathermap.org/api и получите бесплатный API ключ. Добавьте его в Info.plist или Config.swift.
Дополнительные улучшения для production
- Использовать CoreLocation для автоматического определения города
- Добавить локализацию (EN, RU)
- Реализовать network monitoring (Reachability)
- Использовать KeyChain для чувствительных данных
- Analytics tracking (Firebase)
- Обработка background fetch для обновления в фоне