В каждом проекте наступает момент, когда кто-то говорит: «Надо просто переписать его».
Наше приложение для iOS работало годами. Оно было создано на Objective-C, когда сториборды были в новинку, а UITableView был королём пользовательского интерфейса. Со временем новые функции накапливались. Быстрые иксы стали постоянными. Проект превратился в лоскутное одеяло из хитрых хаков и забытых TODO.
К 2024 году в нём было 40 000 строк кода на Objective-C.
Оно работало, но его поддержка уже не приносила удовольствия.
Новые разработчики выглядели испуганными, когда видели это. Swift стал новым стандартом. И, честно говоря, мы устали отвечать на вопрос: «Почему это всё ещё на Objective-C?».
И вот однажды утром в понедельник наш тимлид сказал:
«Давайте сделаем это. Давайте перенесём всё на Swift».
Звучало заманчиво — новый старт, более чистый синтаксис, меньше сбоев.
Мы дали себе три месяца.
Мы ошиблись. На это ушёл год.
План (который отлично выглядел на бумаге)
Наш план был прост:
- Сначала конвертировать небольшие служебные файлы
- Затем перенести модели и менеджеры
- Переписать компоненты пользовательского интерфейса и контроллеры представлений
- Полностью удалить Objective-C. Всё выглядело чётко, логично и достижимо.
Но если разработка программного обеспечения чему-то нас и научила, так это тому, что ни один план не выдерживает столкновения с реальностью.
Через две недели наша первая сборка дала сбой — и не компилировалась несколько дней.
1. Кошмар bridging header
Первым серьёзным препятствием стал заголовочный файл связок (bridging header) — файл, соединяющий Swift и Objective-C.
Теоретически всё просто. Вы импортируете туда заголовки Objective-C, и Swift получает к ним доступ.
На практике это хрупкий карточный домик.
Однажды небольшое изменение в одном файле Objective-C привело к остановке компиляции 200 файлов Swift. Ошибки были такими:
'SomeClass' is unavailable: cannot find Swift declaration
Или такими:
Duplicate symbol _OBJC_CLASS_$_AppManager
Мы обнаружили, что в нашем проекте незаметно скрываются циклические импорты. Когда Swift пытался ссылаться на классы, которые уже ссылались на файлы Swift, всё рушилось.
В итоге нам пришлось реструктурировать модули, удалять ненужные импорты, а иногда и переписывать целые классы, чтобы разорвать эти циклы. Заголовочный файл не был мостом между Swift и Objective-C — он превратился в минное поле.
2. Катастрофа опциональности
Objective-C не заботится о nil.
Swift заботится — и очень.
В Objective-C можно написать:
NSString *title = nil;
Без проблем. Но в Swift:
let title: String = nil
Это мгновенный сбой.
В нашем старом коде nil использовался повсеместно как ленивое сокращение. Иногда это означало «значение не задано». Иногда это означало «произошла ошибка». Иногда мы даже не знали, почему это nil.
Когда мы конвертировали код, Swift потребовал, чтобы мы явно пометили всё как optional или non-optional. Автоматический конвертер попытался угадать, но часто ошибался.
Внезапно у нас появились сотни таких строк:
if let name = user.name {
print(name)
} else {
print("Name missing")
}
Мы потратили недели только на исправление nullability. Это было однообразно, скучно, но необходимо. Однако после того, как мы закончили, количество сбоев заметно снизилось. Типобезопасность в Swift мучительна, но оно того стоило.
3. Проблемы с динамической типизацией
Object-C гибкий. Слишком гибкий.
В Object-C можно написать:
id result = [self getData];
И возвращать буквально что угодно — строку, массив или призрак из 2013 года.
Swift же, напротив, требует ясности:
let result = getData() // what type is this?
При попытке компиляции возникли сотни ошибок «несоответствие типов» и «невозможно преобразовать значение». Swift не допускает «может быть, то, может быть, это». Он требует определённости.
Нам пришлось переопределить возвращаемые типы, переписать вспомогательные методы и иногда создавать классы-обёртки, просто чтобы удовлетворить запросы компилятора. Создавалось ощущение, будто мы учим Swift, что наше приложение по идее должно делать — тому, о чём даже мы сами порой забывали.
4. Утечки памяти, которые не исчезают
Одним из самых больших сюрпризов для нас стало управление памятью. Мы предполагали, что ARC (автоматический подсчёт ссылок) Swift волшебным образом справится со всем лучше, чем Objective-C. Нет.
Из-за того, что мы смешивали Swift и Objective-C во время миграции, у нас возникли странные утечки. Замыкания Swift захватывали делегаты Objective-C, которые затем снова сохраняли объекты Swift. Результат: циклы удержания, которые никак не удавалось устранить.
Мы использовали Instruments для их отслеживания и обнаружили утечки повсюду, особенно в асинхронных колбеках и контроллерах представлений.
В какой-то момент наше приложение использовало более гигабайта памяти после пяти минут простоя.
В итоге мы написали небольшие тестовые утилиты, такие как:
weak var weakSelf = self
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
assert(weakSelf == nil, "Memory leak detected")
}
На устранение этих утечек ушли недели. Мы на собственном горьком опыте убедились, что ARC — это не волшебство. Дисциплина всё равно нужна.
5. Скрипты сборки, которые работают неправильно
Наша система CI/CD была разработана на основе Objective-C. Swift, будучи более модульным, половину её сломал. Мы начали получать непонятные ошибки сборки, например:
Command CompileSwift failed with a nonzero exit code
Никаких подробностей. Никаких подсказок.
Инкрементальные сборки также стали мучительно медленными. Каждое небольшое изменение кода приводило к полной пересборке, увеличивая время сборки на 10–15 минут. Умножьте это на количество разработчиков, и производительность резко упала.
В конце концов, мы разделили проект на несколько небольших фреймворков Swift, скорректировали порядок сборки и перешли на Xcode Cloud. Стабилизация заняла месяц.
Извлеченный урок: при переходе на Swift система сборки также нуждается в переписывании.
6. Обходной путь SwiftUI
В середине кто-то сказал:
«Если мы всё равно всё переписываем, почему бы не использовать SwiftUI?»
Звучало логично. Но таковым не было.
SwiftUI плохо сочетался с нашей структурой, нагруженной UIKit. Combine и реактивные биндинги боролись с нашей логикой Objective-C, основанной на делегатах. Приложение превратилось в странную смесь декларативного и императивного кода.
После двух спринтов, полных неразберихи, мы свернули эксперимент со SwiftUI.
Мы решили пока оставить UIKit и, возможно, перейти на SwiftUI в будущем.
Это был болезненный, но необходимый шаг.
Никогда не смешивайте модернизации. Одна революция за раз.
7. Шторм сбоев
Когда мы наконец получили работающую сборку, нас уже ничто не могло остановить.
Мы выпустили бета-версию.
А затем начались сбои.
Наши отчёты о сбоях были полны ошибок, таких как:
Fatal error: Unexpectedly found nil while unwrapping an Optional value
Причина? Снова обработка optional — и тот факт, что система исключений Swift работает не так, как в Objective-C.
В Objective-C:
@try {
[manager performDangerousTask];
} @catch (NSException *exception) {
NSLog(@"Caught %@", exception);
}
Но в Swift исключения таким образом не перехватываются. Если функция Swift падает, @catch в Objective-C его не видит. Нам пришлось рефакторить части кода, чтобы использовать Result или throws.
Это научило нас, что миграция не заканчивается компиляцией. Она заканчивается, когда сбои прекращаются.
8. Усталость команды
Оставив в стороне технические вопросы, самым сложным был моральный дух команды.
Работа с миграцией — странная штука. Вы не создаёте новые функции. Вы переписываете старые — часто с теми же ошибками, но на новом языке.
Разработчики начали чувствовать себя в тупике. Каждый пул-реквест казался повторением предыдущего. Разгорались споры о соглашениях об именовании, принудительной развёртке и о том, следует ли использовать guard let или if let.
Наша команда постепенно разделилась на две группы:
- Прагматики: «Давайте сначала сделаем так, чтобы это работало».
- Перфекционисты: «Давайте сделаем чистую архитектуру на чистом Swift».
Обе стороны были правы. Обе стороны устали.
Мы пытались поддерживать мотивацию небольшими победами — завершением модуля, удалением целого .m-файла, получением зелёных тестов. Но выгорание было реальным. Проект миграции — это марафон без аплодисментов в конце.
9. Победы (когда всё наконец сработало)
В конце концов, мы достигли финишной черты.
Первая полная сборка Swift прошла успешно.
Наши тесты прошли без проблем.
Приложение запустилось — быстрее, чем раньше.
И мы заметили реальные улучшения:
- Меньше сбоев в рантайме
- Более чистая архитектура
- Более лёгкий ревью кода
- Более быстрая адаптация для новых разработчиков
Строгая типизация и опциональные параметры Swift сделали наше приложение безопаснее. Async/await избавился от старого ада колбеков. Простые функции стали элегантнее:
func fetchUser() async throws -> User {
try await api.get("/user")
}
Казалось, мы наконец-то снова пишем современное iOS-приложение. Этот момент стоил всех бессонных ночей.
10. Скрытые затраты
Но правда в том, что за это пришлось заплатить немалую цену.
Мы перенесли не просто кодовую базу.
Мы также перенесли:
- Нашу систему сборки
- Нашу стратегию тестирования
- Нашу документацию
- Наш образ мышления разработчика
Каждое совещание занимало больше времени, потому что Swift изменил наше представление о коде. Каждый отчёт об ошибке превращался в спор между «проблемой Swift» и «проблемой миграции».
Мы поняли, что модернизация — это не только язык.
Это изменение образа мышления команды.
Что бы мы сделали по-другому в следующий раз
Если бы нам пришлось делать это снова, вот что бы мы изменили:
- Начнем с основ
Сначала перенесём служебные и сетевые уровни, а потом — пользовательский интерфейс. - Поддержим Objective-C в актуальности дольше
Используем модули на разных языках вместо полного переписывания. - Автоматизируем больше
Используем SwiftLint, средства форматирования кода и скрипты миграции. - Заранее определим правила кодирования
Согласуем наименования, опциональные переменные и асинхронные шаблоны ещё до того, как напишем первую строку. - Будем готовы к выгоранию
Это неинтересная работа — планируем небольшие этапы. - Тестируем как сумасшедшие
Не думаем, что переписанный код по умолчанию работает. Он работает редко.
11. Что нас чуть не убило (и что нас спасло)
Нас чуть не убил не Swift. Недооценка сложности нашей кодовой базы.
Swift просто обнажил уже существующие уязвимости — скрытые за слабой типизацией, старыми API и «временными» хаками прошлых лет.
Но миграция также спасла нас. Она заставила нас очистить, переосмыслить и упростить код. Она напомнила нам, что «технический долг» — это не метафора, а реальность, которая со временем накапливается.
В конце концов, мы не просто перешли на Swift. Мы перешли от мышления «заставь это работать» к мышлению «сделай это правильно».
Заключение
Когда мы начинали, мы думали, что модернизируемся. На самом деле мы просто исправляли свои прошлые ошибки.
Сделали бы мы это снова? Вероятно, но медленнее и умнее.
Swift дал нам безопасность, ясность и производительность. Но он также научил нас смирению. Никакой инструмент или язык не спасёт от плохого планирования, срезок или излишней самоуверенности.
Переписывание кода романтично, пока вы сами не столкнётесь с ним.
И если вы когда-нибудь поймаете себя на мысли «Давайте просто всё перепишем на Swift» — сделайте глубокий вдох. Распланируйте лучше. И всё равно действуйте.
Потому что, хотя это чуть не убило нас, в итоге оно сделало нас лучшими разработчиками.

