Привет, Хабр! Меня зовут Дмитрий Сурков, я iOS-разработчик приложения для среднего и малого бизнеса ПСБ.
Наше приложение состоит из различных модулей и внутренних библиотек, которые связаны между собой, поэтому важно сохранять гибкость и обратную совместимость во время разработки. В этой статье разберемся, как вносимые изменения нарушают эти правила и как это исправить.

Реализация фич осуществляется в отдельных модулях — фреймворках и библиотеках. При разработке мы руководствуемся принципами SOLID. Разработку выделенного модуля важно вести в соответствии с принципом Открытости/Закрытости (система должна быть открыта для расширения, но закрыта для изменения).
Каждое изменение фиксируется определенной версией по СемВер. Изменения в коде, которые изменяют публичный интерфейс, обязывают вносить правки во всех потребителях данного модуля. Чем чаще такие изменения делаем — тем хуже стабильность системы, а время разработки заметно возрастает. Необходимо стараться вносить изменения в код, которые не будут изменять существующий публичный интерфейс.
В данной статье, мы рассмотрим:
Пример на библиотеке дизайн-системы с компонентами, так как она используется в основном приложении и дополнительном фреймворке построения UI.
Что такое семантическое версионирование и как мы его используем.
Анализ и поиск решения наиболее частых нарушений обратной совместимости.
Итоги проделанной работы.
Исходная точка
Когда идет активная фаза разработки, мы вносим много изменений. И это важно учитывать на всех этапах работы команды. Чтобы понять текущее положение и оценить в итоге результат, мы подсчитаем время, затраченное на весь процесс внесения изменений и поднятия версии.
Так как наша библиотека дизайн-системы используется сразу на двух клиентах, стоит понимать, что поднятие мажорной версии влечет за собой обновление сразу в двух местах, а далее — по увеличению связи этих клиентов с зависимостью.
Наш ci настроен на многочисленные автоматизации: проверка сборки, покрытие тестами, связи с задачами, автоподнятие версии и т.д. При создании мердж реквеста запускается пайплайн, в котором выполняются все эти проверки. Также стоит брать во внимание человеческий фактор и загруженность.
На основе этого можно выделить следующую цепочку действий:
Необходимо завести задачу на поднятие версии (≈ 5 мин).
Создать ветку, внести изменения и проверить работоспособность, а еще внести правки, если необходимо (≈ 15-20 мин).
Отправить на код-ревью (≈ 2-3 мин).
Подвинуть задачу по статусам, в нашем случае необходим ресурс тестировщика (1-∞ мин).
Пройти ревью, дождаться успешного выполнения пайплайна (≈ 60 мин).
Влить в мастер.
И снова по кругу...
Последний пункт говорит о повторении процесса для всех остальных клиентов, для которых также необходимо поднять версию зависимости. В итоге весь процесс может занимать от 2 до 3 часов, потом умножаем на количество клиентов и получаем 4-6 часов.
Это довольно много для простого поднятия версии, поэтому мы решили разобраться, как мы можем сократить это время и свести к минимуму мажорные изменения. Давайте приступать!
Семантическое версионирование
По сути семантическое версионирование — это соглашение об именовании, которое мы применяем к версиям продукта, будь то приложение или собственная библиотека.

После третьей цифры существуют дополнительные компоненты, призванные внести более детальную информацию для версии, но оставим это на самостоятельное изучение, так как в данной статье нас интересуют только основные компоненты.
Основные компоненты версионирования
Мажор версия — почти все изменения, которые затрагивают публичный интерфейс и нарушают обратную совместимость. Т.е. требуют от клиента вносить правки после обновления, провоцируют появление ошибок и могут влиять на сборку проекта.
Минор версия — все изменения публичного интерфейса, которые не нарушают обратную совместимость.
Патч версия — все изменения, которые не затрагивают публичный интерфейс, изменения внутри метода, переименования приватных свойств и т.д.
Мы придерживаемся данных правил. Кроме того, организовали автоподнятие версий.
Перед влитием задачи в мастер, происходит выполнение пайплайна, где в логах отображается результат проверки ABI и выставляется соответствующая версия.

Желтым выделен warning = поднятие минорной версии.
Красным выделен error = поднятие мажорной версии.
А далее идет описание того, что вызвало данные изменения. Если ничего нет, то это обычно патч.
Благодаря такой наглядности мы можем проанализировать всё, что вызывает повышение той или иной версии, и вот что у нас получилось:
Major версия
Изменения в public протоколе:
Удаление протокола
Изменение названия
Расширение новым типом, требующим реализации обязательных методов / свойств / соответствия, без дефолтной реализации в расширении протокола
Добавление или удаление метода / свойства
Изменение названия метода / свойства
Изменение типа возвращаемого значения метода / свойства
Изменение сигнатуры метода — добавление обязательных аргументов, изменение типов или названий аргументов
Изменения в public классах и структурах:
Удаление класса / структуры
Изменение названия
Понижение уровня доступа методов, свойств
Изменение типа данных свойств
Удаление метода или свойства
Изменения в public перечислениях:
Удаление случая (case)
Добавление случая (case)
Переименование случая (case)
Изменение ассоциированных значений (associated values)
Изменение методов / свойств и их названий / типов / аргументов
Изменения во внешних зависимостях (зависимости SPM пакета):
Добавление и удаление зависимости — в случае, когда зависимость используется в коде и вызывает мажорные изменения
Данные изменения публичного интерфейса являются нарушением обратной совместимости и требуют повышения мажорной версии, поскольку влияют на клиента и могут вызывать ошибки. Например, невозможно найти уже используемый метод или класс, типы возвращаемых значений не совпадают и т.д.
Minor версия
Изменения в public протоколе:
Добавление нового public протокола
Добавление свойства или метода с дефолтной реализацией через расширение протокола
Изменения в public классах и структурах:
Добавление нового public класса или структуры
Добавление инициализатора
Добавление свойств в инициализаторы с дефолтными значениями
Добавление свойств с дефолтным значением или вычисляемым свойством
Увеличение уровня доступа методов и свойств
Изменения в public перечислениях:
Добавление свойств с дефолтным значением или вычисляемым свойством
Изменения во внешних зависимостях (зависимости SPM пакета):
Добавление и удаление зависимости — в случае, когда зависимость используется в коде и вызывает минорные изменения
Здесь изменения, которые затрагивают публичный интерфейс, но не нарушают обратную совместимость, добавляют новый функционал, который не требует от клиента обязательной реализации.
Patch версия
Изменения в public протоколе:
Расширение типом, который не нарушает обратную совместимость, не требует обязательной реализации и соответствия, а также не добавляет новый функционал, например добавление 'Sendable'
Изменение порядка объявления методов / свойств / типов
Изменение дефолтного значения свойства
Изменения в public классах и структурах:
Аналогичные 'Патч' изменениям в протоколах
Добавление не публичных методов / свойств / сущностей
Изменения в public перечислениях:
Аналогичные 'Патч' изменениям в протоколах
Добавление непубличных методов / свойств / сущностей
Изменения во внешних зависимостях (зависимости SPM пакета):
Удаление и добавление зависимостей — в случае, когда зависимость используется в коде и не вызывает мажорных и минорных изменений
Повышение версии зависимости — при условии ненарушения совместимости, которая влечет к поднятию мажорной версии
Обычно происходит при исправлении внутренних ошибок, улучшении или изменении, которые не влияют на публичный интерфейс и обратную совместимость и не вносят дополнительного функционала. А также все внутренние изменения непубличных / открытых интерфейсов.
Проанализировав изменения, которые влияют на повышение версии, мы можем приступать к поиску решения, как обойти нарушения обратной совместимости. И заодно рассмотреть наиболее частые случаи этих нарушений.
Нарушение обратной совместимости
Добавление обязательных методов/свойств в публичный протокол
❌error: ABI breakage: func OldProtocol.makeSomething2() has been added as a protocol requirement
public protocol OldProtocol {
func makeSomething()
+ func makeSomething2()
}
Решения:
1. Еще раз подумать, а нужен ли вам этот метод. Это не шутка. Бывает, понимаешь, что данная доработка и вовсе не нужна.
2. Объявление метода в расширении протокола. Простой вариант обратной совместимости, но имеет ограничение — можно использовать только интерфейсы, объявленые в протоколе, нельзя переопределять.
public protocol OldProtocol {
func makeSomething()
}
extension OldProtocol {
public func makeSomething2() {
// do something
makeSomething()
}
}
3. Дефолтная реализация в расширении протокола. Простой вариант обратной совместимости, можно переопределять, но имеет ограничение — можно использовать только интерфейсы, объявленные в протоколе.
public protocol OldProtocol {
func makeSomething()
+ func makeSomething2()
}
extension OldProtocol {
public func makeSomething2() {
// do something
makeSomething()
}
}
4. Создать новый протокол. Такое решение можно применять, если новый метод не имеет зависимостей на старый протокол.
public protocol OldProtocol {
func makeSomething()
}
public protocol NewProtocol {
func makeSomething2()
}
extension SomeViewModel: NewProtocol {
public func makeSomething2() {
// do something
}
}
Данное решение позволяет расширять функционал, не затрагивая старых клиентов.
let value: OldProtocol & NewProtocol
5. Сделать метод опциональным. Такое решение можно применять только для objc протоколов, если вы хотите расширить функционал необязательным в реализации методом.
@objc public protocol OldProtocol {
func makeSomething()
+ @objc optional func makeSomething2()
}
Добавление обязательных свойств в публичный протокол
Способы аналогичны решению по добавлению методов в публичный протокол.
Добавление параметра в публичный метод или инициализатор
❌error: ABI breakage: constructor init(title:identifier:) has been removed
❌error: ABI breakage: func perform(title:) has been removed
public init(
title: String? = nil,
+ subtitle: String? = nil
identifier: String = String(describing: Self.self)
) {
self.title = title
+ self.subtitle = subtitle
self.identifier = identifier
}
public func perform(
title: String,
+ subtitle: String? = nil
) {
// do something
}
Установка дефолтного значения не помогает избежать ошибки. Решением является создать отдельный инициализатор / метод.
public struct ViewModel {
public let title: String?
+ public let subtitle: String?
public let identifier: String
// Will be removed in the future major versions.
public init(
title: String? = nil,
identifier: String
) {
self.init(
title: title,
subtitle: nil,
identifier: identifier
)
}
+ public init(
+ title: String? = nil,
+ subtitle: String? = nil,
+ identifier: String
+ ) {
+ self.title = title
+ self.subtitle = subtitle
+ self.identifier = identifier
+ }
// Will be removed in the future major versions.
public func perform(
title: String
) {
// do something
}
+ public func perform(
+ title: String,
+ subtitle: String? = nil
+ ) {
+ // do something
+ }
}
Для классов подход аналогичен, только старый инициализатор класса обязательно нужно помечать как convenience.
public class ViewModel {
public let title: String?
+ public private(set) var subtitle: String?
public let identifier: String
// Will be removed in the future major versions.
public convenience init(
title: String? = nil,
identifier: String
) {
self.init(
title: title,
subtitle: nil,
identifier: identifier
)
}
+ public init(
+ title: String? = nil,
+ subtitle: String? = nil,
+ identifier: String
+ ) {
+ self.title = title
+ self.subtitle = subtitle
+ self.identifier = identifier
+ }
// Will be removed in the future major versions.
public func perform(
title: String
) {
// do something
}
+ public func perform(
+ title: String,
+ subtitle: String? = nil
+ ) {
+ // do something
+ }
}
⚠️ Обратите внимание! При вызове нового инициализатора внутри старого обязательно нужно указывать все параметры, иначе может произойти рекурсивный вызов, и компилятор выведет: warning - Function call causes an infinite recursion.
Удаление public протокола/сущности/метода/свойства
Если функционал устарел и существует новый аналог.
Публичные свойства, методы и кейсы из сущностей стоит помечать 'deprecated', чтобы клиенты не использовали их в новом функционале, а также указывать сообщение или название замены, если такое имеется.
❌error: ABI breakage: protocol MyProtocol has been removed
@available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2`")
public protocol MyProtocol {...}
public protocol MyProtocolV2 {
@available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2.makeSomething2()`")
func makeSomething()
func makeSomething2()
@available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2.somePropertyV2`")
var someProperty { get }
var somePropertyV2 { get }
}
Изменение названия public протокола
❌error: ABI breakage: protocol MyProtocol has been renamed to protocol MyProtocolV2
Добавляем 'typealias' со старым названием и помечаем протокол 'deprecated', чтобы клиенты не использовали в новом функционале.
@available(*, deprecated, message:"Will be removed in the future major versions. Use `MyProtocolV2`")
public typealias MyProtocol = MyProtocolV2
public protocol MyProtocolV2 { ... }
Поднятие версии платформы
Поднятие версии платформы, например, версии iOS в настройках SPM пакета — это мажорное изменение. Хоть явного упоминания о мажорной версии может и не быть.
Заключение
Ведя разработку таким образом, мы соблюдаем принцип Open / Closed. Не затрагиваем существующий функционал, а только расширяем его, добавляя дополнительные инициализаторы и методы, делая расширения на протоколы и сущности.
Это позволяет нам иметь более стабильную систему и не провоцировать поток правок во всех частях среды.
Мы проводили анализ в нашей библиотеке дизайн-системы и по ходу дела отлавливали изменения, которые нарушали обратную совместимость, искали причину, находили решение и вливали уже минорную или патч версию.
За три месяца в мастер было влито 27 мердж реквестов.
Из них 1 Major, 17 Minor, 9 Patch версий
Удалось исправить 6 мердж реквестов с мажорных на минорные
Учитывая данные, количество времени на процесс поднятия версии у клиента и их количество, мы сэкономили 6 мердж реквестов * 3 часа * 2 модуля ≈ 24 - 36 часов
Надеюсь, что предложенные решения будут вам полезны и помогут избежать повышения мажорной версии. В дальнейшем планирую делиться новыми интересными случаями нарушений совместимости. Также буду благодарен за дополнения!