Swift под iOS: архитектура для маленьких приложений — от MVC к MVVM без перегруза
Соберём практичную структуру проекта: модели, сервисы, слой представления и правила обмена данными. Вы научитесь минимально усложнять код, чтобы он не ломался при росте.
Содержание
Swift под iOS: архитектура для маленьких приложений — от MVC к MVVM без перегруза
Маленькие iOS-приложения часто пишутся по шаблону: один ViewController, немного логики для загрузки данных, форматирование строк, пару if для состояний — и всё «работает». Проблема начинается не сразу, а при первом заметном росте: добавили новый экран, чуть сложнее правила отображения, появились зависимости (сеть, хранение, аналитика), потребовалось переиспользование. К этому моменту код обычно уже распределён не по смысловым границам, а по историческим — «что где успели разместить».
Цель этой статьи — собрать практичную архитектуру для небольших приложений на Swift, где:
- логика отделена от UI,
- обмен данными прозрачен,
- зависимостей не становится больше, чем нужно,
- при росте проект не превращается в клубок из
ViewControllerи коллбэков.
Мы аккуратно пройдём путь от «классического» MVC к MVVM, но без фанатизма: не будем строить фреймворк внутри проекта и не будем усложнять ради сложности. Конечный результат — структура, которую легко поддерживать и на которую можно опираться дальше.
Почему MVC “ломается” именно в небольших проектах
MVC в iOS обычно понимают как:
- Model — данные,
- View — интерфейс (в iOS это может быть Storyboard / SwiftUI),
- Controller — связка, которая обновляет UI и реагирует на события.
Проблема не в MVC как идее, а в практике. Типичный путь деградации выглядит так:
ViewControllerначинает отвечать за:- запрос данных (сеть),
- преобразование моделей (mapping),
- состояние загрузки и ошибки,
- бизнес-правила (валидации, решения),
- форматирование отображаемых значений.
- Для каждого нового кейса появляются новые ветвления:
if isLoading { ... } else { ... }switch result { ... }
- Изменения в представлении требуются всё чаще, а логика при этом остаётся «внутри контроллера».
В результате контроллер становится «god object», его трудно тестировать, а повторное использование логики почти отсутствует. Даже если проект небольшой, цена хаоса высока: переписывать и разносить код начинает казаться проще, чем держать в порядке то, что уже написано.
Минимальные принципы архитектуры, которые держат проект
Вместо попытки выбрать «идеальный паттерн» стоит зафиксировать правила, которые работают почти всегда:
Граница ответственности: UI не знает про детали сети
UI (слой представления) не должен разбираться, как именно загрузить данные, как авторизоваться, как устроен URL. Он должен работать с готовыми сущностями: «список элементов», «загружено/ошибка», «текст для отображения».
Сервисы — для внешнего мира
Сеть, файловая система, Keychain, аналитика — это внешние зависимости. В маленьком приложении сервисы лучше держать как отдельный слой, чтобы логика не разрасталась внутри экранов.
Модели — для данных домена, а не для UI
Модель должна описывать смысл (например, Article, UserProfile, TodoItem), а не форматирование для конкретного экрана ("2026-07-26" как отображаемая строка). Отображаемые строки и формат уже можно делать в слое представления (через view model), чтобы UI был предсказуем.
Обмен данными — через явные типы состояний
Если есть загрузка, успех и ошибка — это стоит выразить типами. Не «магическими bool» и не «пустыми массивами», которые имеют два смысла сразу.
Переход к MVVM без перегруза: что именно меняем
MVVM обычно объясняют как «ViewController + ViewModel». Но важно понять, что именно мы должны вынести из ViewController, чтобы улучшения были реальными.
Что остаётся в View
- отображение интерфейса,
- подписка на изменения данных,
- реакции на пользовательские действия (нажатия).
Что уходит из ViewController в ViewModel
- загрузка данных (через сервисы),
- обработка ошибок,
- подготовка представления (mapping в
ViewState/ViewData), - хранение состояния экрана.
Что остаётся в Model
- структуры доменных данных (
struct/class), - (иногда) бизнес-правила, если они действительно относятся к домену и должны переиспользоваться.
Где появляются сервисы
MVVM не отменяет сервисы — наоборот, они становятся удобными «входами» для ViewModel. ViewModel получает зависимость (например, ArticlesService) и не знает, как именно сервис устроен.
Практическая структура проекта
Рассмотрим структуру, которая масштабируется до нескольких экранов и не требует сложной инфраструктуры.
Предположим, что приложение показывает список сущностей (например, статьи или задачи). Наша архитектура будет включать:
Models— доменные моделиServices— работа с внешними источникамиViewModels— представление и состояния экранаUI— экраны (например,ViewController)- общий контейнер типов (опционально)
Пример папок
Models/Services/ViewModels/Scenes/Articles/(или по экранам)ArticlesViewController.swiftArticlesViewModel.swift(или вViewModels/отдельно)ArticlesViewState.swift
Доменная модель: минимально и по делу
Допустим, сервер отдаёт статьи. Модель должна описывать смысл данных.
// Models/Article.swift
import Foundation
struct Article: Identifiable, Decodable {
let id: String
let title: String
let summary: String
}
Если серверная структура отличается, мы можем завести отдельный DTO и маппить в доменную модель. Но для маленького проекта часто достаточно простого соответствия Decodable, если API не слишком причудливо.
Сервисы: только интеграция с внешним миром
Вынесем сетевую загрузку в сервис. Важный момент: сервис возвращает данные как модель/список моделей, а не «как UI это должно отобразить».
// Services/ArticlesService.swift
import Foundation
protocol ArticlesService {
func fetchArticles() async throws -> [Article]
}
Реализация может использовать URLSession.
// Services/DefaultArticlesService.swift
import Foundation
final class DefaultArticlesService: ArticlesService {
private let session: URLSession
private let baseURL: URL
init(session: URLSession = .shared, baseURL: URL) {
self.session = session
self.baseURL = baseURL
}
func fetchArticles() async throws -> [Article] {
let url = baseURL.appendingPathComponent("/articles")
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode([Article].self, from: data)
}
}
Ключевой плюс: ViewModel не зависит от URLSession, ей достаточно протокола.
Слой представления: состояния и данные без двусмысленностей
Чтобы избежать хаоса с флагами, опишем состояние экрана одним типом. Например:
idle— ничего не делалиloading— идёт загрузкаloaded— есть данныеfailed— ошибка
Плюс: сюда же можно поместить отображаемую текстовую информацию, если она относится к виду, а не к домену.
// ViewModels/ArticlesViewState.swift
import Foundation
struct ArticlesViewState {
var isLoading: Bool = false
var title: String = "Articles"
var items: [ArticleCellViewData] = []
var errorMessage: String? = nil
}
struct ArticleCellViewData: Identifiable {
let id: String
let title: String
let subtitle: String
}
В небольших проектах это проще, чем плодить несколько типов ViewState и переключать UI через switch — но суть та же: у состояния есть один источник правды.
ViewModel: загрузка, маппинг и управление состоянием
Теперь соберём ViewModel. Она:
- принимает сервис через конструктор,
- содержит текущее состояние,
- умеет загружать данные (
load()), - маппит
ArticleвArticleCellViewData, - обновляет
ArticlesViewState.
Подход к реактивности: Combine или async/await + простой наблюдатель
Для компактности используем @MainActor и простой механизм обновления через замыкание. В UIKit это часто достаточно; в SwiftUI обычно применяют @Published и ObservableObject. Чтобы показать универсально, сделаем замыкание.
// ViewModels/ArticlesViewModel.swift
import Foundation
@MainActor
final class ArticlesViewModel {
private let service: ArticlesService
private(set) var state = ArticlesViewState() {
didSet { onStateChange?(state) }
}
var onStateChange: ((ArticlesViewState) -> Void)?
init(service: ArticlesService) {
self.service = service
}
func load() {
state.isLoading = true
state.errorMessage = nil
Task {
do {
let articles = try await service.fetchArticles()
state.isLoading = false
state.items = articles.map { article in
ArticleCellViewData(
id: article.id,
title: article.title,
subtitle: article.summary
)
}
} catch {
state.isLoading = false
state.items = []
state.errorMessage = Self.mapError(error)
}
}
}
private static func mapError(_ error: Error) -> String {
// Для небольшого проекта — простая маппинг-логика.
// В более зрелом — отдельный ErrorPresenter/Mapper.
if let urlError = error as? URLError {
return "Network error: \(urlError.code)"
}
return "Unexpected error. Please try again."
}
}
Обратите внимание на нюанс: ViewModel маппит доменные Article в данные для ячеек (ArticleCellViewData). Это не бизнес-логика — это подготовка представления.
Экран на UIKit: минимальный контроллер и прозрачные подписки
ViewController теперь отвечает только за отображение состояния и обработку действий пользователя. Контроллер:
- создаёт
ViewModel, - подписывается на изменения состояния,
- вызывает
load()вviewDidLoad(или по событию), - обновляет UI по
state.
Пример без привязки к кастомным библиотекам:
// Scenes/Articles/ArticlesViewController.swift
import UIKit
final class ArticlesViewController: UIViewController {
private let viewModel: ArticlesViewModel
// Пример UI
private let tableView = UITableView()
private let titleLabel = UILabel()
private let errorLabel = UILabel()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
init(viewModel: ArticlesViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("Use init(viewModel:)")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bind()
viewModel.load()
}
private func setupUI() {
view.backgroundColor = .systemBackground
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
errorLabel.textColor = .systemRed
errorLabel.numberOfLines = 0
errorLabel.isHidden = true
activityIndicator.hidesWhenStopped = true
tableView.dataSource = self
tableView.delegate = self
let stack = UIStackView(arrangedSubviews: [titleLabel, activityIndicator, errorLabel, tableView])
stack.axis = .vertical
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func bind() {
viewModel.onStateChange = { [weak self] state in
guard let self else { return }
self.render(state)
}
}
private func render(_ state: ArticlesViewState) {
titleLabel.text = state.title
activityIndicator.startAnimating() if state.isLoading else activityIndicator.stopAnimating()
errorLabel.isHidden = (state.errorMessage == nil)
errorLabel.text = state.errorMessage
tableView.reloadData()
}
}
extension ArticlesViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.state.items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ArticleCell")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "ArticleCell")
let item = viewModel.state.items[indexPath.row]
cell.textLabel?.text = item.title
cell.detailTextLabel?.text = item.subtitle
return cell
}
}
Важное замечание: контроллер всё ещё читает viewModel.state. Это допустимо для небольшого проекта, но принципиально — не нужно держать логику в render. Контроллер не должен делать сетевые вызовы и не должен знать формат доменных моделей.
Если хочется сделать строже — можно передавать в onStateChange только ViewData (без возможности «в обход» читать state), но для простоты мы оставили читаемость.
Компоновка зависимостей: где создавать сервисы
В маленьких проектах обычно не хочется вводить сложный DI контейнер. Достаточно единообразного способа собрать зависимости в одном месте. Например, в SceneDelegate/AppCoordinator.
В UIKit (без полноценного coordinator) можно сделать фабрику:
// Scenes/Articles/ArticlesFactory.swift
import Foundation
enum ArticlesFactory {
static func make() -> ArticlesViewController {
let baseURL = URL(string: "https://example.com/api")!
let service = DefaultArticlesService(baseURL: baseURL)
let viewModel = ArticlesViewModel(service: service)
return ArticlesViewController(viewModel: viewModel)
}
}
Теперь экраны создаются с правильными зависимостями, а внутри них код не раздувается.
Типичные ошибки при переходе от MVC к MVVM
1) “MVVM” как перенос кода без изменения границ
Частая ситуация: весь код из ViewController просто копируют в ViewModel, но границы не проясняют. В итоге ViewModel превращается в вторую копию контроллера — только в другом файле.
Правило: в ViewModel остаётся подготовка представления и управление состоянием. Сетевые детали — в сервисах.
2) Модель начинает зависеть от UI-форматирования
Если Article начинает содержать displayTitle/displayDateString, то доменная модель перестаёт быть доменной. Форматирование — это ответственность представления (или отдельного Presenter), а не ответа API.
3) Состояние описано неявно
items: [] и errorMessage: nil может звучать достаточно, но через пару итераций вы столкнётесь с двусмысленностью:
[]— «загрузка ещё не закончилась» или «данных нет»?errorMessage == nil— ошибка отсутствует или ошибка скрыта?
Один из способов — явный enum состояния. Например, вариант чуть строже:
enum LoadState {
case idle
case loading
case loaded(items: [ArticleCellViewData])
case failed(message: String)
}
Для совсем небольших экранов текущий подход (с isLoading + errorMessage) тоже рабочий, но следите, чтобы смысл не разъезжался.
4) Отсутствие контроля потока обновления UI
UIKit требует обновлений UI в main thread. Если ViewModel не гарантирует @MainActor, можно получить редкие падения. В нашем примере @MainActor — простое и надёжное решение.
5) “Сервис” начинает содержать бизнес-правила представления
Сервис не должен знать, какие строки нужны на экране. Его задача — вернуть данные. Всё остальное — задача ViewModel.
Как расширять проект без ухудшения структуры
Давайте посмотрим, что происходит, когда приложение растёт.
Новый экран = новый ViewModel + новый сервис (или общий)
Если появляется экран профиля, вы добавляете:
UserProfile(Model),UserProfileService(Service),UserProfileViewModel(ViewModel),UserProfileViewController(UI).
Сервисы можно переиспользовать, если логика общая. ViewModel зависит от протоколов.
Повторное использование: общие сервисы и маппинг
Если есть несколько экранов, использующих один и тот же набор запросов — сервис должен покрывать запросы. А маппинг в разные представления делайте в отдельных ViewModel (или в отдельных mapper-функциях).
Кэширование и повторные запросы
Кэш — это отдельная история. Минимально: сервис возвращает данные, а ViewModel решает, когда обновлять. Если кэш сложнее — добавляйте слой кэша в `
Комментарии
Пока нет комментариев