Введение: почему рефакторинг legacy кода на Swift — это необходимость
Каждый iOS/macOS разработчик рано или поздно сталкивается с legacy кодом. Это может быть проект, написанный на Objective-C и частично переведенный на Swift, или приложение, созданное в эпоху Swift 2.0 с использованием Massive View Controller. Такой код часто работает, но его поддержка превращается в ад: любое изменение требует часов тестирования, а добавление новой фичи ломает старую.
Рефакторинг legacy кода на Swift — это не просто "причесывание" кода. Это стратегический процесс, который делает приложение более тестируемым, расширяемым и производительным. В этой статье мы разберем, как подойти к рефакторингу правильно, с примерами на Swift, чтобы не сломать продакшн и получить работающий результат.
1. Диагностика: как понять, что legacy код требует рефакторинга
Прежде чем менять код, нужно понять, что именно требует изменений. Основные признаки legacy кода на Swift:
- Massive View Controller (MVC-антипаттерн) — файлы с 500+ строками, где логика, UI и сетевые запросы смешаны в одном классе.
- Сильная связанность (tight coupling) — классы напрямую зависят друг от друга, нет протоколов и dependency injection.
- Отсутствие тестов — если вы боитесь изменить строчку кода, потому что не знаете, что сломается.
- Глобальные переменные и синглтоны — использование
UserDefaults.standardилиNotificationCenter.defaultпо всему проекту. - Force unwrapping — повсеместное использование
!вместо безопасного извлечения опционалов.
1.1. Инструменты для анализа legacy кода на Swift
Для объективной оценки состояния кода используйте инструменты:
- SwiftLint — статический анализатор, который выявляет нарушения code style.
- Xcode Instruments — для поиска утечек памяти и проблем производительности.
- Code coverage — покажет, сколько кода покрыто тестами (обычно 0-10% в legacy проектах).
Пример типичного legacy кода, который требует рефакторинга:
class LegacyViewController: UIViewController {
@IBOutlet weak var label: UILabel!
var data: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
// Смесь логики и UI
let url = URL(string: "https://api.example.com/data")!
let data = try! Data(contentsOf: url)
let json = try! JSONSerialization.jsonObject(with: data) as! [[String: Any]]
for item in json {
data.append(item["name"] as! String)
}
label.text = data.first
}
}
Здесь сразу несколько проблем: force unwrapping, синхронный сетевой запрос в главном потоке, смесь логики и представления.
2. Стратегия рефакторинга legacy кода на Swift: пошаговый план
Рефакторинг без стратегии — путь к катастрофе. Используйте подход "боевого сапера": сначала обезопасьте территорию, потом меняйте код.
2.1. Шаг 1: Создайте "защитную сетку" из тестов
Прежде чем что-то менять, напишите тесты для существующего кода. Используйте Characterization Tests — тесты, которые фиксируют текущее поведение системы. Даже если код выглядит ужасно, тесты покажут, что вы не сломали логику.
import XCTest
@testable import LegacyApp
class LegacyViewControllerTests: XCTestCase {
func testViewDidLoad_SetsLabelText() {
let sut = LegacyViewController()
sut.loadViewIfNeeded()
// Проверяем, что label не nil и содержит текст
XCTAssertNotNil(sut.label.text)
}
}
Этот тест может быть не идеальным, но он зафиксирует текущее состояние. После рефакторинга тест должен проходить.
2.2. Шаг 2: Разделите ответственность (Single Responsibility)
Выделите из ViewController отдельные сервисы: сетевой слой, парсинг данных, бизнес-логику. Используйте протоколы для слабой связанности.
// Протокол для сетевого слоя
protocol NetworkServiceProtocol {
func fetchData(completion: @escaping (Result<[[String: Any]], Error>) -> Void)
}
// Реализация
class NetworkService: NetworkServiceProtocol {
func fetchData(completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
let url = URL(string: "https://api.example.com/data")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
completion(.failure(NSError(domain: "ParseError", code: 0)))
return
}
completion(.success(json))
}.resume()
}
}
// Теперь ViewController использует протокол
class RefactoredViewController: UIViewController {
@IBOutlet weak var label: UILabel!
private let networkService: NetworkServiceProtocol
init(networkService: NetworkServiceProtocol = NetworkService()) {
self.networkService = networkService
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
networkService.fetchData { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let items):
self?.label.text = items.first?["name"] as? String ?? "No data"
case .failure(let error):
self?.label.text = "Error: \(error.localizedDescription)"
}
}
}
}
}
2.3. Шаг 3: Замените синглтоны на Dependency Injection
Синглтоны — зло legacy кода. Они создают глобальное состояние и делают тестирование невозможным. Внедрите DI через инициализатор или фабрику.
// До рефакторинга
class AnalyticsManager {
static let shared = AnalyticsManager()
func logEvent(_ name: String) { /* ... */ }
}
// После рефакторинга
protocol AnalyticsService {
func logEvent(_ name: String)
}
class RealAnalyticsService: AnalyticsService {
func logEvent(_ name: String) { /* реальная реализация */ }
}
class MockAnalyticsService: AnalyticsService {
var loggedEvents: [String] = []
func logEvent(_ name: String) { loggedEvents.append(name