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

Приложение погоды

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 []
    }
}

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

  1. MVVM с Coordinators — чистая архитектура, легко тестировать
  2. Async/await — вместо старых замыканий, modern Swift API
  3. Combine — реактивное программирование для UI обновлений
  4. Кэширование — работает offline, снижает нагрузку на API
  5. Dependency Injection — все зависимости инъецируются
  6. Unit-тесты — Mock repository для изолированного тестирования
  7. Pull-to-refresh — стандартный паттерн iOS
  8. 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 для обновления в фоне