Connect with us

Разработка

Как бы я спроектировал iOS-приложение с нуля, если бы оно должно было прожить 10 лет

Если свести всё к одному вопросу, то он звучал бы так: станет ли человеку, которому придётся менять это через пять лет, легче или сложнее из-за решения, которое я принимаю сейчас?

Опубликовано

/

     
     

Проектируйте с расчётом на изменения, а не на текущий тренд

Легко выбрать архитектуру только потому, что о ней все говорят — будь то MVVM, чистая архитектура или какой-нибудь модный в этом году composable-фреймворк. Для приложения, которое должно жить долго, я бы выбирал по другой причине: я хочу, чтобы изменения обходились дёшево. Представьте, что продуктовая команда перерабатывает чекаут в 2028 году, компания переходит на новую систему аутентификации в 2030-м, backend заменяют в 2031-м, а в 2035-м проект передают новой команде. Архитектура должна позволять делать всё это без переписывания приложения, и для меня это гораздо важнее, чем наличие у неё узнаваемого названия.

На практике всё сводится к чётким границам. UI отображает состояние и реагирует на действия пользователя. Бизнес-правила не должны зависеть от конкретного UI-фреймворка, а сеть, хранение данных, аутентификация и аналитика должны находиться за своими отдельными границами, а не быть размазаны по экранам. В первый день вам не нужны двенадцать слоёв. Вам просто не нужен экран, который одновременно загружает данные, форматирует ответы, что-то сохраняет, применяет бизнес-правила и управляет навигацией. Пока приложение маленькое, такой экран кажется нормальным, а через три года он превращается в 4 000 строк, к которым никто не хочет прикасаться, и для добавления небольшой функции приходится понимать половину приложения.

Форма, к которой я бы стремился, выглядит примерно так, причём зависимости всегда направлены только вниз:

SwiftUI views
      |
      v
View models          presentation state, user actions
      |
      v
Business logic       rules, validation, pricing (no UI imports)
      |
      v
Repositories
      |
      +-----------------+
      v                 v
Network            Local storage

Для нового приложения в 2026 году я бы начал со SwiftUI, потому что это современный UI-фреймворк Apple, в который компания по-прежнему активно инвестирует. Но «основной UI-фреймворк» не должен превращаться в «всё приложение знает о SwiftUI». Если бизнес-логика привязана к типам представлений, менять UI становится сложнее, а если харнение данных переплетено с состоянием представлений, миграции начинают причинять боль. Если держать основную логику независимой, UI сможет меняться — будь то UIKit-компонент для сложного случая или что-то, что Apple ещё даже не анонсировала. Я бы также убедился, что команда знает UIKit, потому что долго живущие приложения неизбежно накапливают старый код: приобретённый модуль, сторонний SDK, сложный компонент, который так и не перенесли. Со временем такие приложения начинают выглядеть как исторические документы, где разные стили и старые API соседствуют с современными, и я бы предпочёл заранее к этому подготовиться, а не удивляться позже.

Я бы и не пытался предсказывать, что именно Apple построит в будущем. Долгосрочные усилия по проектированию я бы вкладывал в концепции, которые остаются неизменными: экран показывает информацию, пользователь совершает действие, приложение проверяет его, данные откуда-то приходят и где-то должны храниться, а ошибки нужно обрабатывать. API под этим всем будут меняться, а эти концепции — нет.

Зависимости и абстракции

Добавить библиотеку можно за несколько минут. Удалить её через пять лет иногда превращается в отдельный проект. Допустим, мы добавили стороннюю сетевую библиотеку, чтобы сэкономить несколько часов, а через три года её больше не поддерживают, новая версия Swift её ломает или компания-разработчик исчезает. Перед добавлением зависимости я бы спрашивал себя, что произойдёт, если через три года нам придётся её убрать, и если честный ответ — «придётся переписать половину приложения», я бы хорошо подумал. Некоторые зависимости абсолютно оправданы. Но для критичных частей у собственных фреймворков Apple есть преимущество: владелец платформы контролирует их развитие. Для всего остального я бы смотрел на качество поддержки, здоровье сообщества, безопасность, лицензию и сложность замены. Зависимость должна решать проблему, а не становиться частью идентичности приложения.

С dependency injection действует то же правило. Он полезен, потому что делает компоненты заменяемыми и тестируемыми, но с ним легко переборщить. Если есть сетевой компонент, платёжный сервис или data store, я хочу иметь возможность подменить его fake-реализацией в тестах, и обычно этого достаточно. Любая абстракция тоже имеет стоимость сопровождения, поэтому я бы добавлял её тогда, когда она даёт конкретную пользу, а не потому, что архитектурная схема выглядит красивее с ещё одним прямоугольником.

Вот примерно как выглядит «достаточно» для платёжного сервиса. View model зависит от небольшого протокола, а тест передаёт ей fake, возвращающий нужный тесту результат:

struct Receipt { let id: String }

protocol PaymentService {
    func charge(amount: Decimal) async throws -> Receipt
}
final class CheckoutViewModel {
    private let payments: PaymentService
    init(payments: PaymentService) {
        self.payments = payments
    }
    func pay(_ amount: Decimal) async throws -> Receipt {
        try await payments.charge(amount: amount)
    }
}
// In tests
struct FakePaymentService: PaymentService {
    var result: Result<Receipt, Error>
    func charge(amount: Decimal) async throws -> Receipt {
        try result.get()
    }
}

Один протокол и один fake. Обычно именно столько абстракции и нужно компоненту такого рода.

Аутентификация — хорошее место для применения этого подхода. Она имеет привычку протекать повсюду: if user.isLoggedIn появляется экран за экраном, пока смена системы входа не превращается в кошмар. Я бы предпочёл, чтобы остальная часть приложения зависела от небольшого интерфейса, а сама подсистема аутентификации решала, как именно она работает:

protocol SessionProvider {
    var isAuthenticated: Bool { get }
    func validToken() async throws -> String
}

Если позже компания сменит identity провайдера или добавит passkeys, экраны и сетевой код продолжат вызывать тот же интерфейс.

Screens      Networking      Analytics
    \            |            /
     \           |           /
      +---- SessionProvider ----+
                 |
      +----------+-----------+
      |                      |
LegacyLoginSession   IdentityProviderSession
      (2026)                 (2030)

Всё, что находится выше протокола, остаётся неизменным, когда меняется блок внизу.

Сначала данные, потом экраны

Разработчики часто начинают с экранов — login, home, profile, settings — и только позже обнаруживают, что модель данных не выдерживает реальности. Для приложения на десять лет я бы потратил серьёзное время сначала именно на данные. Чем владеем мы, а что приходит с бэкенда? Что можно кэшировать, а что обязано работать офлайн? Что происходит, если пользователь не открывал приложение шесть месяцев, или если бэкенд изменил поле?

Миграции — самый очевидный десятилетний вопрос. Представьте приложение, которое начиналось с имени и email пользователя, потом разделило имя на first name и last name, затем добавило телефон, настройки, информацию о подписке и новую модель аккаунта. Нельзя просто удалять локальную базу при каждом изменении схемы, потому что у пользователей есть данные и они важны. Будь то легковесная миграция в Core Data или версионирование схем и миграционные планы в SwiftData, сама технология хранения может меняться, но любым данным, живущим годами, нужна стратегия миграции с самого начала, а не как срочный фикс в последний момент.

За несколько лет одна модель может пройти такой путь, и локальные данные каждого пользователя должны корректно пройти все эти этапы:

v1   User(name, email)
      |   split name into two fields
      v
v2   User(firstName, lastName, email, phone)
      |   add preferences
      v
v3   User(...) + Preferences
      |   new account model
      v
v4   Account -> Profiles

В SwiftData каждая версия представляется как VersionedSchema, а SchemaMigrationPlan описывает переход между ними. Ниже — схема перехода с v1 на v2, а не production-код:

enum SchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [User.self] }

@Model final class User {
        var name: String
        var email: String
        init(name: String, email: String) {
            self.name = name
            self.email = email
        }
    }
}
enum SchemaV2: VersionedSchema {
    static let versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [User.self] }
    @Model final class User {
        var firstName: String
        var lastName: String
        var email: String
        var phone: String?
        init(firstName: String, lastName: String, email: String, phone: String? = nil) {
            self.firstName = firstName
            self.lastName = lastName
            self.email = email
            self.phone = phone
        }
    }
}
enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
    static var stages: [MigrationStage] { [v1ToV2] }
    static let v1ToV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Only the old (V1) models are visible here.
            // Read each full name and stash it somewhere safe.
        },
        didMigrate: { context in
            // Only the new (V2) models are visible here.
            // Fill in firstName and lastName from the stashed values.
            try context.save()
        }
    )
}

Важная деталь: каждый closure видит только одну версию данных, поэтому разделение одного поля требует подхода «сначала временно сохранить, потом записать». Перед релизом такую миграцию нужно тестировать на реальной базе из старой версии приложения.

То же относится и к взаимоотношениям с бэкендом. iOS-приложение может жить годами, пока API меняется намного быстрее, и нельзя предполагать, что все пользователи обновятся сразу. Кто-то останется на старой версии на месяцы, а некоторые устройства вообще не смогут запускать самый новый iOS. Поэтому я бы заранее предусмотрел версионирование API, обратную совместимость, терпимость к неизвестным приложению полям и контролируемый способ выводить старое поведение из эксплуатации. Фиче-флаги и удаленные конфигурации помогают для вещей, которые действительно часто меняются, например минимальной суммы покупки или временного скрытия функции. Но я бы не делал настраиваемым вообще всё, потому что каждое удалённо управляемое поведение — это ещё одна вещь, которую команде нужно понимать и тестировать.

Production — это не demo

Demo выглядит так: нажали, запрос прошёл, данные появились. Production — это сервер недоступен, соединение оборвалось, токен истёк, ответ содержит что-то неожиданное, сторонний сервис упал, пользователь закрыл приложение посреди операции или на телефоне закончилось место. Приложение, живущее десять лет, встретит всё это и, скорее всего, много раз, поэтому я бы относился к обработке ошибок как к части продукта. Не каждая ошибка требует страшного алерта. Иногда правильный ответ — повторить запрос, иногда показать кэшированные данные, иногда дать понятное объяснение, а иногда просто продолжить работу с ограниченной функциональностью.

Concurrency относится к той же теме. В долгоживущем приложении со временем становится всё больше асинхронной работы: сеть, операции с базой, обработка изображений, background tasks, AI-запросы. Если обращаться с этим небрежно, появляются гонки данных и задачи, которые продолжают работать, хотя уже должны были остановиться. Я бы заранее договорился, где выполняется async-работа, какие операции должны идти на main actor, как работает отмена, кто владеет тасками и что происходит, когда экран исчезает или пользователь запускает одну и ту же операцию дважды. Эти правила важнее знания любого конкретного concurrency API.

Также нужно видеть, что происходит в прордакшене. Худшее сообщение для команды — «у некоторых пользователей приложение падает», когда нет способа понять почему. Поэтому я бы с самого начала настроил сбор отчетов о сбоях, логирование, мониторинг быстродействия и видимость сетевых ошибок и критических пользовательских flow. Инструменты за десять лет поменяются, потребность — нет. Но observability — это не значит логировать всё подряд, а чувствительная информация не должна случайно попадать в логи. К аналитике нужен такой же подход. Всё начинается с «давайте отслеживать эту кнопку», а через пять лет получается 3 000 событий, два названия для одного и того же и дашборд, зависящие от событий, происхождение которых уже никто не помнит. Соглашения по именованию, документация важных событий и периодические ревью помогают не превратить это в технический долг.

Безопасность, accessibility и дизайн-система

Безопасность — это не задача, которую можно однажды закончить. За десять лет может измениться стандарт, какой-то способ шифрования перестанет считаться приемлемым, правила конфиденциальности поменяют требования к хранению данных, а компания внедрит passkey. Поэтому я бы не размазывал предположения о безопасности по случайным экранам. Я бы централизовал работу с токенами, правильно хранил чувствительные данные, не держал секреты в кодовой базе, соблюдал актуальные сетевые требования безопасности и имел процесс обновления security-зависимостей. Цель не в том, чтобы предсказать технологии безопасности 2036 года, а в том, чтобы их смена не требовала переписывать приложение.

С accessibility всё так же. Dynamic Type, VoiceOver, контрастность, labels, touch targets и reduced motion намного проще учитывать как часть обычной разработки, чем превращать в отдельный проект на седьмом году жизни приложения. Общая дизайн-система помогает и здесь. Приложение с сотнями экранов начинает расползаться, если каждый разработчик стилизует кнопки по-своему: у одной corner radius 12, у другой 10, немного разные шрифты, разные отступы, и в итоге создаётся ощущение, будто это пять приложений от пяти команд. Общая типографика, цвета, spacing, компоненты, loading/error states и accessibility-правила значительно упрощают сопровождение, а визуальный дизайн потом можно менять, не ломая фундамент.

Тестирование, сборки и модули

Приложение, которое живёт десять лет, получит тысячи изменений, а значит, ему нужна страховочная сетка. Люди чинят одно и ломают другое, фреймворки меняются, миграции ведут себя иначе, а разработчик, пришедший месяц назад, не знает, зачем существует странный кусок кода. Я бы тестировал то, поломка чего действительно причинит боль: бизнес-логику, важные преобразования данных, сетевое поведение и критические пользовательские сценарии, а UI-тесты использовал бы только там, где они реально дают ценность. Это напрямую зависит от архитектуры. Если расчёт скидки находится внутри SwiftUI view, для его тестирования придётся строить view, настраивать state и имитировать взаимодействие. Если это обычная application logic, её можно протестировать быстро и изолированно — в первый год это кажется мелочью, а к десятому становится огромным преимуществом.

struct DiscountCalculator {
    func finalPrice(subtotal: Decimal, couponPercent: Decimal?) -> Decimal {
        guard let percent = couponPercent, percent > 0 else { return subtotal }
        let discount = subtotal * min(percent, 100) / 100
        return subtotal - discount
    }
}
import Testing

@Test func couponReducesSubtotal() {
    #expect(DiscountCalculator().finalPrice(subtotal: 200, couponPercent: 10) == 180)
}
@Test func discountNeverGoesBelowZero() {
    #expect(DiscountCalculator().finalPrice(subtotal: 50, couponPercent: 150) == 0)
}

Никакого view, никакой настройки state и никакого взаимодействия, которое нужно симулировать. Тест выполняется за миллисекунды, и когда правила ценообразования изменятся в 2031 году, у человека, который их меняет, будет страховка.

Я бы хотел, чтобы процесс сборки был скучным. Релиз не должен зависеть от того, помнит ли один разработчик семнадцать ручных шагов. Новый разработчик должен иметь возможность клонировать проект, пройти инструкции по настройке и запустить его, CI должен автоматически собирать и тестировать, а signing и provisioning должны быть задокументированы. Представьте критический релиз в 2032 году, когда единственный человек, знающий, как собрать production build, ушёл из компании шесть месяцев назад. Это организационный технический долг, и он абсолютно реален.

По мере роста приложения я бы рассматривал модуляризацию, но не 150 модулей только потому, что кто-то сказал, будто модуляризация — это хорошо. Я бы проводил границы вокруг осмысленных областей вроде аутентификации, платежей, поиска, работы с сетью и общим UI, чтобы контролировать зависимости и позволить командам работать, не редактируя постоянно один гигантский target. У модулей тоже есть стоимость сопровождения, поэтому вводить их стоит тогда, когда этого действительно требуют размер приложения и структура команды.

Документация, onboarding и странный код

Мне не нужен комментарий // Increment counter над строкой counter += 1. Мне нужна документация, объясняющая решения: почему эту зависимость нельзя заменить, зачем у этого API странный workaround, почему compatibility layer всё ещё существует, почему поле базы данных нельзя удалить. В любом зрелом приложении есть странный код, и важно не то, что он странный, а то, знает ли кто-нибудь, зачем он существует. Возьмём ветку вроде if legacyMode. Первый инстинкт разработчика может быть — удалить её. Но, возможно, она нужна потому, что старая версия бэкенда всё ещё обслуживает тысячи клиентов, или её требует регулирование, или она обходит баг старой версии iOS. Если никто это не записал, однажды кто-то удалит код, production сломается, а короткое объяснение могло бы избавить от нескольких лет путаницы.

Я бы также исходил из того, что первоначальные разработчики уйдут. Техлид, архитектор, человек, который написал неудобный workaround аутентификации, даже первоначальный product manager — к 2036 году их всех может уже не быть, а кодовая база должна продолжить жить. Хороший тест — сможет ли разработчик, который никогда раньше не видел проект, разобраться в нём через шесть месяцев работы. Представьте, что кто-то клонирует проект в 2031 году: он собирается? тесты запускаются? есть документация? можно найти API, authentication flow и feature modules? можно внести небольшое изменение, не спрашивая пять человек? То, будет ли десятилетнее приложение зависеть от памяти одного человека, во многом решается в первый день.

Код, сгенерированный ИИ

Часть кода, написанного в 2026 году, будет сгенерирована ИИ, а инструменты 2035 года, вероятно, окажутся гораздо мощнее. Может показаться, что это повод меньше переживать о качестве кода, но я думаю наоборот. Инструмент может сгенерировать функцию за секунды — и за те же секунды создать неправильную абстракцию. Он может скопировать устаревший шаблон, добавить ненужную сложность или внести технически корректное изменение, нарушающее архитектуру, а чем дольше живёт приложение, тем дороже становятся такие решения. Поэтому я бы активно использовал ИИ для повторяющейся работы, но оставался консервативным в архитектуре. Команда должна договориться о нескольких базовых правилах: сгенерированный код проходит review, соблюдает существующие границы, security-sensitive код проверяется особенно тщательно, зависимости не добавляются автоматически, важные изменения сопровождаются тестами, и ничего не merge’ится только потому, что компилируется. Команда также должна спокойно относиться к тому, чтобы отклонять сгенерированный код. Цель не в том, чтобы избегать ИИ, а в том, чтобы не накапливать сгенерированный ИИ технический долг.

Ежегодные изменения Apple и борьба с переусложнением

Каждый год приносит новую версию iOS, новые API, новые устройства и новые требования к конфиденциальности, и приложение за десять лет пройдёт через множество таких циклов. Я бы не переписывал кодовую базу после каждого анонса. Я бы поддерживал приложение в состоянии, где обновления можно внедрять постепенно: использовать availability checks, изолировать платформенный код, осознанно удалять deprecated API, держать зависимости актуальными и рано тестировать beta-версии. Приложение не обязано гоняться за каждой новой функцией, но оно должно оставаться достаточно здоровым, чтобы принимать важные изменения.

Есть и противоположная ловушка: услышать «десять лет» и начать проектировать под миллионы пользователей ещё до появления первого. Distributed caching, пять слоёв абстракции и огромная модульная архитектура — это не то, что означает долгую жизнь. Я бы строил простую систему с понятными местами для развития, избегал решений, блокирующих рост, и масштабировал только те части, которым это реально нужно. Когда кто-то говорит, что экран медленный или SwiftUI создаёт проблему, я бы хотел видеть вместо мнения конкретные показатели: время запуска, потребление памяти, частота сбоев и сетевых ошибок — в том числе на самых старых поддерживаемых устройствах. Десятилетнему приложению не нужна бесконечная оптимизация — ему нужна осмысленная оптимизация.

Что бы я построил и как понял бы, что всё работает

Если бы я начинал сегодня, стек не был бы экзотическим: Swift и SwiftUI с пониманием UIKit для interoperability, разумные границы, structured concurrency, несколько хорошо поддерживаемых зависимостей, продуманная стратегия persistence, versioned API contract, тесты вокруг бизнес-логики и критических пользовательских сценариев, ранний CI/CD, crash reporting, дизайн-система, accessibility с самого начала, аккуратная аутентификация и работа с данными, задокументированные архитектурные решения и AI-assisted development с review. Это намеренно. Цель — не самое сложное приложение в первый год, а фундамент, способный выдержать годы изменений. Я бы также пересматривал архитектуру каждый год и спрашивал: что стало болезненным, какой модуль меняется слишком часто, какие зависимости или API начинают создавать проблемы, где разработчики теряют время и что можно упростить или удалить. Долголетие появляется не потому, что каждое решение 2026 года было идеальным — это невозможно. Оно появляется потому, что система умеет исправлять собственные плохие решения.

Старый код сам по себе тоже не враг. Код может быть старым и при этом абсолютно понятным, а двухлетняя архитектура уже может быть кошмаром. Я бы оценивал здоровье приложения по тому, насколько сложно внести следующее изменение. Если функция сегодня занимает два дня, а в следующем году уже две недели, или если каждому новому разработчику нужны месяцы на адаптацию, или никто не хочет прикасаться к какому-то модулю — что-то идёт не так.

Тест, который я бы использовал, — представить разработчика, открывающего проект в 2036 году. Он видит немного старого Swift, несколько UIKit-компонентов, новый SwiftUI рядом со старым SwiftUI, миграции, compatibility code, комментарии, объясняющие странные решения, тесты, понятный процесс сборки и документацию. Я бы хотел, чтобы он подумал: «Приложение старое, но с ним можно работать», а не: «Кто вообще это написал?» Речь о том, чтобы дать следующему разработчику честный шанс, потому что идеального варианта всё равно не будет. Если свести всё к одному вопросу, то он звучал бы так: станет ли человеку, которому придётся менять это через пять лет, легче или сложнее из-за решения, которое я принимаю сейчас?

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: