Миграция большого iOS-проекта с Objective-C на Swift кажется понятной задачей ровно до тех пор, пока не начинаешь считать объём. В нашем случае это были 10 тысяч файлов, сотни тысяч строк кода и постоянная необходимость не останавливать развитие продукта. Ручной подход работал слишком медленно, поэтому мы начали автоматизировать миграцию с помощью LLM — и в итоге превратили её из бесконечного техдолга в воспроизводимый процесс.

Из этой статьи вы узнаете, как мы пять лет закрывали этот техдолг и за это время переписали лишь половину. И как в итоге пришли к решению на базе LLM, которое помогло завершить работу кратно быстрее и высвободило время разработчиков для более интересных задач, чем рефакторинг кода.

Меня зовут Андрей Сикерин, я руковожу одной из групп iOS-разработки Яндекс Браузера. Вместе со мной статью писала Елизавета Мазулова@evmazulova, разработчица из нашей же команды. Она создала систему промптов для миграции, ревью, рефакторинга и автоматизации тестирования. Вместе мы расскажем, как устроен весь процесс изнутри: от выбора порядка миграции модулей до контроля качества через тесты.

Все промпты, конфигурационные шаблоны и вспомогательные скрипты выложили в открытый доступ — забирайте и адаптируйте под свой проект. Мы уже проверили, что подход работает не только у нас: взяли открытый репозиторий приложения Wikipedia для iOS и без существенных изменений промптов мигрировали один из его пакетов и поделились результатом с сообществом в виде открытого PR.

Пять лет миграции — и только половина пути

Браузер вышел в 2013 году и был полностью написан на Objective-C, Swift тогда ещё не существовало. Первые Swift-файлы появились в проекте в 2015-м, а в 2020-м произошло объединение кодовых баз Браузера и приложения Яндекс в едином репозитории. Поскольку приложение Яндекс было целиком на Swift, это задало новый вектор: мы объявили мораторий на написание нового кода на Objective-C.

Objective-C тормозит развитие продукта: Swift Concurrency, SwiftUI и другие современные инструменты Apple с ним не дружат, скорость разработки падает. Плюс рынок: найти разработчика, который хорошо знает Objective-C, с каждым годом становится сложнее.

На тот момент в проекте было 10 038 файлов и 541 856 строк Objective-C. Мораторий остановил рост этого кода, но не помог от него избавиться. Миграция шла, но медленно: спустя пять лет, ко второй половине 2025 года, было переписано лишь чуть более 50% кода.

Причины низкого темпа понятны: огромный объём legacy, запутанные зависимости между модулями, постоянная необходимость развивать продукт. К тому же ручное переписывание — монотонная работа, чреватая ошибками, поэтому разработчики берутся за неё неохотно.

Если бы мы продолжали в том же темпе, полная миграция растянулась бы ещё на годы вперёд. Но с появлением LLM стало очевидно, что эта задача — отличный кандидат для автоматизации: уже есть референсное поведение, задача сводится к переводу с одного языка на другой без изменения функциональности. А значит, результат можно проверить автоматически: скомпилировать и прогнать тесты.

Начинать нужно с «листьев» графа зависимостей

LLM отлично знает синтаксис Swift и Objective-C, но ничего не знает о специфике конкретного проекта. Ядро браузера — форк Chromium со своей системой сборки (gn, ninja), собственной библиотекой и строгим code style. Без этого контекста нейросеть генерирует «сферический код в вакууме».

Поэтому первым делом мы структурировали всю необходимую информацию о проекте в конфигурационный файл rules.md: архитектуру и систему сборки, code style, структуру тестов, архитектурный словарь типов и паттернов для замены.

Следующий вопрос: с чего начать? К тому моменту оставалось переписать ~3700 файлов. Мы быстро поняли, что эффективнее всего переписывать целыми модулями — в среднем по 2000–3000 строк. Но случайный выбор модуля легко приводил к проблемам. Если компонент активно используется из Objective-C-кода, при переписывании на Swift приходится создавать bridging-слой (прослойку совместимости между Swift и Objective-C), накапливать @objc-аннотации и наследоваться от NSObject.

Оптимальная стратегия: начинать с «листьев» графа зависимостей, то есть компонентов, которые вызываются только из Swift-кода. При таком подходе можно писать чистый Swift без промежуточных слоёв.

Искать такие модули можно вручную, доверить поиск LLM или автоматизировать процесс. Учитывая огромный размер нашего репозитория, мы выбрали последний вариант и написали скрипт-анализатор. Он строит граф зависимостей, парсит BUILD.gn-файлы и ранжирует компоненты от листьев к вершине.

Система из четырёх промптов

Для миграции одного модуля мы создали систему из четырёх специализированных промптов, каждый из которых отвечает за свой этап.

  • Swift-conversion — основной промпт переписывания. Определяет оптимальный порядок миграции файлов внутри модуля, заменяет типы и имена, валидирует результат через компиляцию и тесты.

  • Refactoring — улучшение кода под Swift best practices после успешной сборки. Выбор правильных типов (struct/class/enum), упрощение протоколов до замыканий, внедрение dependency injection.

  • Review — автоматическая проверка по чек-листу перед отправкой на ревью. Проверяет заголовки файлов, корректность замены типов, соответствие code style.

  • ObjC-removal — периодическая очистка от избыточных @objc-аннотаций после того, как зависимости мигрированы.

Промпты оформлены как Jinja2-шаблоны и заполняются из rules.md. Процесс настройки простой: заполняете шаблон правил для своего проекта и запускаете скрипт, указав путь к директории с правилами вашей IDE. Скрипт сгенерирует и поместит четыре готовых промпта в указанную папку.

Большинство агентских IDE поддерживают механизм пользовательских правил: промпты автоматически подгружаются в контекст каждого диалога. Мы используем SourceCraft Code Assistant (.codeassistant/rules/), но тот же подход работает и в других популярных IDE.

Контроль качества: генерация тестов

Но переписать код — это только полдела, важно убедиться, что мы ничего не сломали.

Многие старые модули исторически не имели достаточного тестового покрытия. Это возлагало колоссальную ответственность на ревьюеров: проверять глазами корректность логики в PR на 2000+ строк кода рискованно и ненадёжно.

Поэтому мы изменили порядок. Для модулей с недостаточным покрытием сначала пишем Swift-тесты на Objective-C-интерфейс и вливаем их отдельным PR — они проходят CI и фиксируют правильное поведение существующего кода. И только после этого открываем второй PR с миграцией: свифтизированный код сразу проверяется уже влитыми тестами. Существующие Objective-C-тесты мигрируем в последнюю очередь.

Генерацию тестов разбили на три этапа:

  • Планирование. LLM анализирует исходный Objective-C-модуль и составляет тест-план на естественном языке: сценарии использования, граничные случаи, ожидаемое поведение.

  • Валидация. Разработчик вычитывает план — это быстрее, чем читать код. Исключаем избыточные проверки, добавляем упущенные сценарии.

  • Генерация. Только после утверждения плана LLM пишет код тестов.

Эти этапы оформлены в отдельный промпт для интеграционных тестов.

Написание тестов — это ещё один пласт задач, с которыми LLM справляется успешно. С таким подходом мы закрываем две потребности сразу: не только безопасно мигрируем код, но и попутно покрываем тестами старые «тёмные пятна» в проекте.

Детали реализации промптов

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

Такой подход автоматизировал процесс, но не изменил его качественно: при миграции крупных модулей ошибок могло быть сотни. LLM теряла фокус и не могла исправить все ошибки за приемлемое количество итераций.

Мы написали несколько простых скриптов, которые отрабатывают до компиляции и предотвращают большинство однотипных ошибок ещё «на подлёте».

  • Скрипт для импорта зависимостей анализирует импорты header-файлов в переписываемом модуле и на их основе определяет список необходимых Swift-модулей. Он нужен, чтобы заранее устранять частую ошибку cannot find type 'ClassName' in scope. Конкретная реализация зависит от системы сборки, в нашем случае мы парсим BUILD.gn-файлы.

  • Скрипт для переименования классов обновляет имена классов с Objective-C-префиксами на Swift-версии (YBTabManager → TabManager) во всех Swift-файлах.

  • Скрипт для обновления импортов исправляет Objective-C-код, который после миграции начинает использовать Swift-классы (#import "YBTabManager.h" → #import "Browser-Swift.h").

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

Архитектурный словарь: перевод исторических паттернов

Многие архитектурные решения браузера пришли из мира C++. При миграции на Swift заменяем старые Objective-C-обёртки на современные аналоги из нашей базовой библиотеки. Все правила собраны в макросе objc_to_swift_equivalents().

  • Асинхронность: YBFuture/YBPromise → Future/Promise. Нужно правильно конвертировать типы между старым и новым API: использовать Future.convert(from:) для преобразования и корректно работать с ошибками через Result.

  • Реактивность: YBCondition → ObservableVariable<Bool> — типизированный observable вместо булевого флага.

  • Коллекции наблюдателей: YBObserversSet → WeakCollection, методы add() → append(), enumerateObjects() → forEach().

  • Блоки: YBVoidBlock → Action — тип-алиас для замыканий без параметров и возвращаемого значения.

  • Ленивая инициализация: YBLazyLoader/YCLazy → Lazy.

Каждое правило подкреплено примерами из реального кода, чтобы модель понимала не только что заменять, но и как это выглядит в контексте.

Code style

Помимо замены типов, важно следовать идиоматичным Swift-подходам конкретного проекта. В нашем случае правила описаны в макросе project_code_style().

Безопасность: запрет на force unwrap (!) и fatalError. Даже если оригинальный Objective-C-код гарантировал не-nil значение, используем безопасные альтернативы: optional binding, nil-coalescing operator (??) или assert() для отладки.

Декларативность: использование .build-блоков вместо конкатенации массивов через +. Result Builder позволяет писать более читаемый код с условной логикой и автоматическим flattening вложенных массивов.

Простота: замена convenience init на единый init с дефолтными значениями параметров. Использование lazy var вместо приватных методов-геттеров для кеширования.

Objective-C-совместимость: атрибуты @objc и наследование от NSObject убираются там, где класс больше не используется из Objective-C-кода. Исключения: методы-селекторы для target-action и классы, реализующие Objective-C-протоколы из старого кода.

Маркировка

Каждый переписанный файл получает специальный хедер:

// Generated as part of AI Swiftization.
// Please review, refactor, and ensure it follows the code style before removing this comment.

Этот комментарий помогает держать контекст о том, какие файлы были автоматически переписаны и ещё нуждаются в дополнительной валидации и рефакторинге. После завершения миграции всего проекта такие комментарии будут удалены. На данный момент в проекте уже больше 700 таких файлов.

Важность примеров и контекста

Качество конвертации сильно зависит от количества примеров в промпте. Особенно это критично для сложных случаев — например, конвертация YBFuture с одновременной обработкой успеха и ошибки через Result. Когда мы столкнулись с этой проблемой, добавили в макрос objc_to_swift_equivalents() детальный раздел с примерами всех вариантов конвертации — это значительно улучшило ситуацию. Но есть и обратная сторона: когда в одном промпте собрано слишком много правил, фокус LLM размывается. Модель успешно конвертирует код, но упускает детали при финальной проверке. Поэтому мы разделили ответственность: Swift-conversion отвечает только за корректную компиляцию и базовую замену типов, Refactoring концентрируется на улучшении кода и стиле, Review проверяет результат по чек-листу перед отправкой на ревью.

Кроме того, для ускорения процесса ревью мы использовали внутренние MCP-серверы. Для GitHub, GitLab и Bitbucket уже есть готовые решения. Это позволяет LLM получать комментарии ревьюеров и автоматически исправлять большую часть минорных замечаний: форматирование, переименование переменных, недостающие комментарии. Финальная валидация остаётся за разработчиком.

Проверка универсальности подхода

Чтобы проверить, работает ли подход за пределами Яндекс Браузера, мы решили опробовать его на стороннем проекте. Для эксперимента выбрали приложение Wikipedia для iOS — популярный опенсорс-проект с другой архитектурой и системой сборки. Мы мигрировали SPM-пакет WMFComponentsObjC, который отвечает за форматирование wiki-разметки, адаптировали шаблон под их code style и xcodebuild, заполнили конфигурационный файл, опираясь в том числе на инструкции для Copilot, и открыли PR в репозиторий проекта.

Этот эксперимент показал, что подход не завязан на специфику нашего монорепозитория. Чтобы перенести его в другой проект, достаточно заполнить конфигурационный шаблон с учётом локальных правил, системы сборки и code style.

Результаты и метрики

За время работы над проектом:

  • 106 pull requests влито в master;

  • −97 500 строк legacy кода;

  • −2167 файлов.

На графике ниже показана динамика уменьшения Objective-C-кода. Мы аппроксимировали эти данные и сравнили скорости переписывания. При наивном подсчёте получается ускорение в 5,5 раза, но с учётом затраченных человеко-часов более реалистичная оценка — ускорение примерно в 2,5 раза.

Важно отметить, что эти цифры отражают лишь количественное улучшение. Качественно процесс тоже изменился: хотя он всё ещё требует активного участия разработчика, фокус внимания сместился с монотонного переписывания на валидацию корректности миграции и сложный рефакторинг. Рутинное переписывание кода полностью делегировано LLM.

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

Материалы в открытом доступе

Всё, чем мы поделились в статье, опубликовано на GitHub и SourceCraft. Для удобства дублируем списком: 

  • Шаблон конфигурационного файла rules.md (GH, SC)— заполните под свой проект.

  • Скрипт настройки промптов (GH, SC) — генерирует промпты для вашей IDE.

  • Вспомогательные скрипты (GH, SC) — анализ импортов, переименование классов, исправление импортов.

  • Swift-conversion (GH, SC) — основной промпт переписывания.

  • Refactoring (GH, SC) — улучшение кода под Swift best practices.

  • Review (GH, SC) — проверка по чек-листу перед отправкой на ревью.

  • Интеграционные тесты (GH, SC) — промпт для генерации тестов.

Благодарности

Хотим поблагодарить всю команду Яндекс Браузера для iOS — за готовность пробовать новые подходы, за внимательное ревью и за то, что этот эксперимент стал общим делом.

Комментарии (0)