$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогSwift под iOS: архитектура для маленьких приложений — от MVC к MVVM без перегруза

Swift под iOS: архитектура для маленьких приложений — от MVC к MVVM без перегруза

$ sudo teach IT
·26 июля 2026 г.·10 мин·75
Swift под iOS: архитектура для маленьких приложений — от MVC к MVVM без перегруза

Соберём практичную структуру проекта: модели, сервисы, слой представления и правила обмена данными. Вы научитесь минимально усложнять код, чтобы он не ломался при росте.

Содержание
Почему MVC “ломается” именно в небольших проектахМинимальные принципы архитектуры, которые держат проектГраница ответственности: UI не знает про детали сетиСервисы — для внешнего мираМодели — для данных домена, а не для UIОбмен данными — через явные типы состоянийПереход к MVVM без перегруза: что именно меняемЧто остаётся в ViewЧто уходит из ViewController в ViewModelЧто остаётся в ModelГде появляются сервисыПрактическая структура проектаПример папокДоменная модель: минимально и по делуСервисы: только интеграция с внешним миромСлой представления: состояния и данные без двусмысленностейViewModel: загрузка, маппинг и управление состояниемПодход к реактивности: Combine или async/await + простой наблюдательЭкран на UIKit: минимальный контроллер и прозрачные подпискиКомпоновка зависимостей: где создавать сервисыТипичные ошибки при переходе от MVC к MVVM1) “MVVM” как перенос кода без изменения границ2) Модель начинает зависеть от UI-форматирования3) Состояние описано неявно4) Отсутствие контроля потока обновления UI5) “Сервис” начинает содержать бизнес-правила представленияКак расширять проект без ухудшения структурыНовый экран = новый ViewModel + новый сервис (или общий)Повторное использование: общие сервисы и маппингКэширование и повторные запросы

Маленькие iOS-приложения часто пишутся по шаблону: один ViewController, немного логики для загрузки данных, форматирование строк, пару if для состояний — и всё «работает». Проблема начинается не сразу, а при первом заметном росте: добавили новый экран, чуть сложнее правила отображения, появились зависимости (сеть, хранение, аналитика), потребовалось переиспользование. К этому моменту код обычно уже распределён не по смысловым границам, а по историческим — «что где успели разместить».

Цель этой статьи — собрать практичную архитектуру для небольших приложений на Swift, где:

  • логика отделена от UI,
  • обмен данными прозрачен,
  • зависимостей не становится больше, чем нужно,
  • при росте проект не превращается в клубок из ViewController и коллбэков.

Мы аккуратно пройдём путь от «классического» MVC к MVVM, но без фанатизма: не будем строить фреймворк внутри проекта и не будем усложнять ради сложности. Конечный результат — структура, которую легко поддерживать и на которую можно опираться дальше.


Почему MVC “ломается” именно в небольших проектах

MVC в iOS обычно понимают как:

  • Model — данные,
  • View — интерфейс (в iOS это может быть Storyboard / SwiftUI),
  • Controller — связка, которая обновляет UI и реагирует на события.

Проблема не в MVC как идее, а в практике. Типичный путь деградации выглядит так:

  1. ViewController начинает отвечать за:
    • запрос данных (сеть),
    • преобразование моделей (mapping),
    • состояние загрузки и ошибки,
    • бизнес-правила (валидации, решения),
    • форматирование отображаемых значений.
  2. Для каждого нового кейса появляются новые ветвления:
    • if isLoading { ... } else { ... }
    • switch result { ... }
  3. Изменения в представлении требуются всё чаще, а логика при этом остаётся «внутри контроллера».

В результате контроллер становится «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.swift
    • ArticlesViewModel.swift (или в ViewModels/ отдельно)
    • ArticlesViewState.swift

Доменная модель: минимально и по делу

Допустим, сервер отдаёт статьи. Модель должна описывать смысл данных.

code
// Models/Article.swift
import Foundation

struct Article: Identifiable, Decodable {
    let id: String
    let title: String
    let summary: String
}

Если серверная структура отличается, мы можем завести отдельный DTO и маппить в доменную модель. Но для маленького проекта часто достаточно простого соответствия Decodable, если API не слишком причудливо.


Сервисы: только интеграция с внешним миром

Вынесем сетевую загрузку в сервис. Важный момент: сервис возвращает данные как модель/список моделей, а не «как UI это должно отобразить».

code
// Services/ArticlesService.swift
import Foundation

protocol ArticlesService {
    func fetchArticles() async throws -> [Article]
}

Реализация может использовать URLSession.

code
// 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 — ошибка

Плюс: сюда же можно поместить отображаемую текстовую информацию, если она относится к виду, а не к домену.

code
// 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. Она:

  1. принимает сервис через конструктор,
  2. содержит текущее состояние,
  3. умеет загружать данные (load()),
  4. маппит Article в ArticleCellViewData,
  5. обновляет ArticlesViewState.

Подход к реактивности: Combine или async/await + простой наблюдатель

Для компактности используем @MainActor и простой механизм обновления через замыкание. В UIKit это часто достаточно; в SwiftUI обычно применяют @Published и ObservableObject. Чтобы показать универсально, сделаем замыкание.

code
// 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.

Пример без привязки к кастомным библиотекам:

code
// 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) можно сделать фабрику:

code
// 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 состояния. Например, вариант чуть строже:

code
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 решает, когда обновлять. Если кэш сложнее — добавляйте слой кэша в `

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Swift - для начинающих!

Курс по теме

Swift - для начинающих!

Язык программирования Swift и бесплатный курс разработки приложений под iOS

Бесплатно
Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Solidity DLC - поймай, если сможешь

Solidity DLC - поймай, если сможешь

Аудит смарт-контрактов на реальных взломах. The DAO, Parity, Wormhole, Ronin, Euler и ещё 7 разборов: что пошло не так, сколько потеряли и что бы спасло. Атаки и защиты на Foundry, план входа в профессию аудитора.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Solidity pro - всё или ничего

Solidity pro - всё или ничего

Как пишут контракты, которые работают в основной сети. Наследование и OpenZeppelin, паттерны продакшена, оптимизация газа, ABI, delegatecall и подписи. 7 модулей, 33 урока, а в финале собственное dApp: мини-DAO с токеном, голосованием и казной.

Перейти к курсу

Другие статьи

Стабильная архитектура FastAPI: роутеризация, сервисы и слой схем
стабильная

Стабильная архитектура FastAPI: роутеризация, сервисы и слой схем

Разберём, как организовать проект на FastAPI так, чтобы маршруты не превращались в “контроллеры всего на свете”, а доменная логика оставалась тестируемой и переносимой.

21 июля 2026 г.
770
Dart для кроссплатформенных приложений: управление состоянием без боли
dart

Dart для кроссплатформенных приложений: управление состоянием без боли

Разберём подходы к состоянию и жизненному циклу: когда использовать Future/Stream, как избегать утечек подписок и как организовать обновления UI. Статья подойдёт тем, кто уже пишет на Dart и хочет сделать архитектуру чище.

29 июля 2026 г.
700
Как выучить программирование самому и бесплатно: с чего начать и в каком порядке
выучить

Как выучить программирование самому и бесплатно: с чего начать и в каком порядке

Разберём понятный маршрут для новичка: как выбрать первый язык, что учить в первую очередь, какие мини-проекты делать и как не забросить занятия. Дам бесплатные ориентиры и план, чтобы знания складывались в портфолио.

21 сентября 2026 г.
1950
Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах
такое

Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах

Разберём, из чего состоит веб-страница: что делает HTML (смысл и структура) и что делает CSS (оформление). В конце соберёте мини-страницу и поймёте, что нужно учить в каком порядке.

30 октября 2026 г.
100
Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей
cargo

Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей

Разберём, как организовать Rust workspace, управлять optional features, фиксировать версии библиотек и уменьшать “дрейф” зависимостей при командной разработке и CI.

18 сентября 2026 г.
1950
Что такое SQL и зачем он нужен новичку: как думать о данных простыми словами
такое

Что такое SQL и зачем он нужен новичку: как думать о данных простыми словами

Разберём, что такое базы данных и SQL без сложной теории: как выбирать данные, фильтровать их и собирать таблицы в отчёт. В конце — короткий план, как потренироваться и собрать первые запросы для портфолио.

28 октября 2026 г.
220

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Почему MVC “ломается” именно в небольших проектахМинимальные принципы архитектуры, которые держат проектГраница ответственности: UI не знает про детали сетиСервисы — для внешнего мираМодели — для данных домена, а не для UIОбмен данными — через явные типы состоянийПереход к MVVM без перегруза: что именно меняемЧто остаётся в ViewЧто уходит из ViewController в ViewModelЧто остаётся в ModelГде появляются сервисыПрактическая структура проектаПример папокДоменная модель: минимально и по делуСервисы: только интеграция с внешним миромСлой представления: состояния и данные без двусмысленностейViewModel: загрузка, маппинг и управление состояниемПодход к реактивности: Combine или async/await + простой наблюдательЭкран на UIKit: минимальный контроллер и прозрачные подпискиКомпоновка зависимостей: где создавать сервисыТипичные ошибки при переходе от MVC к MVVM1) “MVVM” как перенос кода без изменения границ2) Модель начинает зависеть от UI-форматирования3) Состояние описано неявно4) Отсутствие контроля потока обновления UI5) “Сервис” начинает содержать бизнес-правила представленияКак расширять проект без ухудшения структурыНовый экран = новый ViewModel + новый сервис (или общий)Повторное использование: общие сервисы и маппингКэширование и повторные запросы