Привет! Я Антон, Android-разработчик в команде Тинькофф Бизнеса. Занимаюсь интеграцией нескольких наших внутренних SDK в приложение и иногда участвую в их разработке.

Хочу поговорить про проблемы обратной совместимости, которые я встречал в своей работе, и предостеречь от выстрела себе или клиентам вашей библиотеки в ногу.

Любое приложение имеет зависимости

Ни одно современное приложение не существует в вакууме. Любое крупное мобильное приложение на Kotlin имеет сотни зависимостей, эти зависимости имеют свои зависимости и так далее. 

В JVM-мире часто бывает, что модуль или библиотека собраны с одной версией зависимости, а в рантайме вынуждены использовать другую. Даже если вы разрабатываете приложение и указываете конкретную версию пакета, не факт, что в рантайме приложение будет использовать именно ее. Допустим, в нашем проекте объявлена вот такая зависимость:

dependencies {
	implementation "my.nice.group:module:1.2.2"
}

В многомодульном проекте разные модули могут компилироваться с разными версиями внешних библиотек. Чтобы точно узнать, какие версии используются и почему выбрана та или иная версия, в Gradle существует таска dependencies.

./gradlew :app:dependencies

И вот мы подключаем библиотеку с помощью кода выше, собираем приложение и видим, что поведение отличается от ожидаемого. Запускаем таску dependencies и видим вот такие строчки в выводе: 

...
apiDependenciesMetadata
+--- my.nice.group:module:1.2.2 -> 1.4.0
...

Получается, что, несмотря на явно объявленную версию, в рантайме приложение использует другую. Но как такое возможно? Тут следует начать издалека.

JVM устроена так, что в рантайме она загружает первый класс, который ей удалось найти в classpath. Он может быть получен из любого доступного jar. В случае с Android есть отличие: при конвертации java-байткода в Dalvik Executable Format в .dex-файл попадает только одна версия каждого класса. Это та версия, которая удовлетворяет всем ограничениям, указанным в самом проекте приложения и его зависимостях. 

Как правило, билд-системы оставляют в рантайме только одну версию .class-файлов — самую последнюю. 

Ожидается, что последняя версия библиотеки обратно совместима. Это значит, что клиенты должны быть способны использовать новую версию там, где ожидают старую. Эдакий Liskov Substitution Principle, только для версий одной и той же библиотеки.

Виды совместимости

Обычно в контексте обратной совместимости под JVM думают только про бинарную совместимость. На самом деле их несколько типов.

Behavioral compatibility — проще всего объяснить и сложнее всего поддерживать. Это такой тип совместимости, при котором существующие классы и методы не меняют своего поведения. Например, если метод fun getCurrentDate(): Date возвращал дату вместе с текущим временем (23.03.24 14:45), но внезапно стал возвращать дату начала дня (23.03.24 00:00), это нарушение behavioral compatibility. На старое поведение может полагаться какая-то значимая часть клиентов.

Source compatibility — тот тип обратной совместимости, при котором все клиенты продолжают успешно компилироваться. Опыт показывает, что этот тип совместимости практически невозможно поддерживать. Всегда найдется пример клиента, который имеет какой-то нестандартный исходный код. Даже добавление нового класса может сломать код клиентам, которые используют wildcard import.

Binary compatibility достаточно сложно дать точное определение для Kotlin в общем случае. Дело в том, что Kotlin умеет компилироваться под несколько разных платформ. В результате компиляции получается бинарный файл: библиотека или исполняемый файл. У каждой платформы свой вид бинарного файла: например, у JVM это файл .class, у Android — .dex, а у Kotlin/Native вообще разные форматы для каждой ОС и архитектуры процессора.

Объекты, доступные в бинарном файле, составляют его Application Binary Interface (ABI). Если ABI отличается от того, который ожидает клиент, произойдет ошибка. Но не любое изменение ABI ломает бинарную совместимость.

Говорить про бинарную совместимость есть смысл, только если четко определено, что такое «бинарный файл» и ABI. В рамках этой статьи я говорю про бинарные файлы для JVM/ART. У этих форматов практически идентичные ABI с точки зрения бинарной совместимости.

На официальном сайте Kotlin есть такое определение:

A binary backward-compatible version of a library can replace a previously compiled version of the library. Any software that was compiled against the previous version of the library should continue to work correctly.

Бинарно обратно совместимая версия библиотеки может заменить ранее скомпилированную версию. Любое ПО, скомпилированное с предыдущей версией библиотеки, должно продолжить работать корректно. То есть, если библиотека ломает какую-либо часть скомпилированных клиентов, то она не бинарно совместима с предыдущей версией. 

В Java Language Specification (JLS) более строгое определение:

A change to a type is binary compatible with <...> pre-existing binaries if pre-existing binaries that previously linked without error will continue to link without error.

Изменение в типе бинарно совместимо с <...> ранее существовавшими бинарными файлами, если они линковались без ошибок и продолжают это делать.

У Scala оно слегка попроще сформулировано, но суть такая же, как и в JLS:

Two library versions are Binary Compatible with each other if the compiled bytecode of these versions can be interchanged without causing Linkage Errors.

Две версии библиотеки бинарно совместимы друг с другом, если скомпилированный байт-код этих версий может быть заменен между собой без появления ошибок линковки.

Несмотря на то, что написано на сайте Kotlin, будем использовать определение из JLS (чуть позже будет понятно, почему).

Для проверки, что бинарная совместимость не нарушена, существует множество разных инструментов. Сейчас рекомендуемый инструмент для Kotlin — binary-compatibility-validator. У него нет никаких особенных фич специально для Kotlin, кроме того, что он учитывает модификатор доступа internal, экспериментальной поддержки KLib. Но основной сценарий его использования - проверка совместимости ABI при компиляции под JVM. Именно потому, что официальный binary-compatibility-validator работает таким образом, я и решил использовать определение из JLS.

В интернете есть множество статей о том, как поддерживать бинарную совместимость. Спойлер: с Kotlin это не всегда так легко и очевидно, как с Java. Конечно, команда Kotlin работает над этим, предоставляя, например, Explicit API Mode и вышеупомянутый binary-compatibility-validator.

Даже бинарно обратно совместимая библиотека в некоторых случаях может сломать скомпилированный код, который ее использует. То есть, согласно определению JLS, линкуется она успешно, но бросает исключения в неожиданных местах. Я буду называть такие изменения runtime-breaking changes. Кажется, это название еще не занято.

Самый очевидный вид таких изменений — изменения в нуллабельности полей, возвращаемых значений методов, аргументов. К сожалению, с помощью binary-compatibility-validator не увидеть даже такие изменения. 

Вот несколько примеров менее очевидных runtime-breaking changes.

Добавление элементов в enum или добавление наследников в sealed class или sealed interface

В Kotlin есть очень удобный оператор — выражение when. Часто оно применяется вместе с операндом, являющимся перечислением или запечатанным (sealed) типом. Но если такое выражение зависит от типа из внешней библиотеки, то есть определенные особенности.

Если в рантайме lib-when получит незнакомый элемент типа Planet, то будет выброшено исключение kotlin.NoWhenBranchMatchedException
Если в рантайме lib-when получит незнакомый элемент типа Planet, то будет выброшено исключение kotlin.NoWhenBranchMatchedException

Я с этим столкнулся на собственном опыте. Одна из библиотек, которые я использую, реализовывала переключатели функциональности через enum. При добавлении новой фичи необходимо перекомпилировать модуль с интеграцией, то есть lib-when в моем примере. В многомодульном проекте не всегда можно за этим уследить.

Изменение типа возвращаемого значения у suspend-функции

Тут все достаточно прозаично: если изменится тип возвращаемого значения у функции, то клиентский код, не готовый к такому нарушению контракта, начнет крашиться. Чуть-чуть интереснее то, почему так происходит и почему такое изменение бинарно совместимое. Все дело в том, как suspend-функции выглядят в ABI. А именно — вот так:

// оригинал
public suspend fun niceFun(): String
// ABI в формате, который использует binary-compatibility-validator
public final fun niceFun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

Для тех, кто немного разбирался, как корутины работают "под капотом", это не будет сюрпризом. suspend-функции передают свои возвращаемые значения через Continuation. Continuation — это параметризированный тип. Поскольку JVM и ART в рантайме ничего не знает про дженерики, то при изменении возвращаемого значения в niceFun она будет принимать все так же Continuation и возвращать Object. Поэтому это бинарно совместимое изменение. Но в рантайме мы увидим ClassCastException.

Добавление метода в интерфейс

Даже такое невинное изменение, как добавление метода в интерфейс, может сломать скомпилированные библиотеки в случае с Kotlin. Приведенный ниже пример довольно надуманный, но я столкнулся с ним в реальном коде.

Допустим, есть два интерфейса, один из них сразу с реализацией:

// lib a-decl
interface A {
    fun foo()
}

// lib b-decl-impl
interface B {
    fun foo()
}

class BImpl: B {
    override fun foo() { ... }
}

Чтобы не плодить бойлерплейт, мы реализуем интерфейс A с помощью вот такой конструкции:

// use-site
object AImpl: A, B by BImpl()

Мы ожидаем, что все методы интерфейса A будут автоматически реализованы через одноименные методы B. Так оно и происходит до тех пор, пока в A не добавится новый метод. Если AImpl не будет перекомпилирована, то она не будет переопределять новый метод, даже если он добавится в B и BImpl. Это связано с тем, что конструкция ... : B by BImpl() превращается в реальную реализацию на этапе компиляции. Вот так это выглядит в декомпилированном виде:

public final class AImpl implements A, B {
   private final BImpl $$delegate_0 = new BImpl();
   ... 

   public void foo() {
      this.$$delegate_0.foo();
   }
}

Так что, если в B и в BImpl добавится новый метод, класс AImpl не будет его реализовывать, так как реализации через by генерируются в compile-time.
На самом деле подобной проблемой страдают не только Kotlin-библиотеки. С Java может произойти похожая проблема при соблюдении трех условий:

  1. Библиотека объявляет интерфейс.

  2. Клиент его реализует и предоставляет реализацию библиотеке.

  3. Библиотека вызывает методы этой реализации, предполагая, что все они реализованы.

Проблему нарушения совместимости при добавлении метода в java-интерфейс для примера обсуждали на GitHub.

Удаление и переименование ресурсов

Эта и следующая проблемы Android-специфичные их достаточно легко объяснить. В отличие от jar-библиотек, большинство библиотек для Android поставляются в виде .aar-библиотек. В них, помимо кода, также присутствуют ресурсы и файл AndroidManifest.xml, знакомый всем, кто хоть раз создавал android-приложение.

Так вот, если вы удалите ресурс из вашей библиотеки, то клиенты, которые попытаются получить к нему доступ, упадут в runtime. То же самое касается и переименования ресурсов. Такое изменение является бинарно совместимым, поскольку .aar-библиотеки не содержат R.class-файлов. Они генерируются только во время сборки приложения. Так что с точки зрения байткода классов в AAR все изменения легальны.

Изменения в AndroidManifest

С Android Manifest проблема немного другая. Он ни во что не компилируется, его формат окончателен. Это XML. Когда системе надо получить информацию о вашем приложении, она парсит XML. Но всё же это не тот же самый файл, что лежит в вашем app-модуле. Дело тут в том, что манифест, который попадает в APK, собирается из манифеста приложения и манифестов всех зависимостей. При этом XML-теги объединяются по определенным правилам. Правила разрешения конфликтов описаны в документации. 

Проблема в манифесте может возникнуть при объявлении пермишшенов. Допустим, библиотека X использует пермишшен "android.permission.WRITE_EXTERNAL_STORAGE". Но в какой-то момент появляется другая реализация функционала, которая уже не требует этого пермишшена. Реализация работает только на API > 28, поэтому в манифесте библиотеки пермишшен начинает выглядеть так:

<uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />

Всё бы ничего, но согласно правилам разрешения конфликтов, атрибут android:maxSdkVersion попадет в манифест приложения. Вполне может быть, что оно к этому не готово и такое изменение приведет к ошибкам в рантайме. К счастью, это можно обнаружить автотестами (если тестировать на нужной версии Android). И так же легко эту ошибку исправить: просто добавить tools:remove="android:maxSdkVersion" в манифест приложения.

Тестирование в многомодульных проектах

Приложения Тинькофф — большие проекты, над которыми работают десятки разработчиков. Чтобы сборка происходила за адекватное время, проекты разделены на Gradle-модули. При этом хорошей практикой считается писать UI-тесты в модуле фичи. Для этого мы используем специальное демоприложение только для тестов. Про то, как мы готовим демоприложения, писали мои коллеги из Мобильного банка:

Тесты в разных feature-модулях можно запускать параллельно сразу в нескольких CI-джобах — каждый модуль в своей. При их написании меньше лагает IDE, потому что можно отключить лишние модули. Ну чем не сказка?

Чтобы понять, в чем может быть проблема, надо посмотреть, как Gradle разрешает зависимости у модулей. Допустим, в build.gradle или в version catalog объявлена зависимость из начала статьи: 

implementation "my.nice.group:module:1.2.2"

Тогда Gradle поступит следующим образом: 

  1. Попробует подключить к модулю зависимость с версией как в build.gradle.

  2. Если модуль транзитивно зависит от более высокой версии `my.nice.group:module` — подключит ее.

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

Ui-тесты могут гоняться не на той версии библиотеки, которую приложение использует в релизе. С этой проблемой я столкнулся лично и совершенно случайно. При открытии экрана фичи приложение крашилось, но тесты были зелеными. Дело было в runtime-breaking-изменении одной из наших внутренних библиотек. К счастью, баг не дошел до прода и дело ограничилось тем, что пришлось отложить релиз на день.

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

  1. Бинарно несовместимое или runtime-breaking-изменение в библиотеке.

  2. Транзитивно подключена более новая версия библиотеки, чем объявлена в build.gradle.

Если первое обстоятельство целиком на совести мейнтейнеров библиотеки, то от второго легко защититься — достаточно переопределить то, как Gradle выбирает версии библиотек. Нужно сделать так, чтобы он не мог выбрать версию больше, чем объявлена в проекте. К счастью, это несложно: Gradle имеет функцию strictly, которая делает ровно это. Ее даже можно использовать в version catalog.

[versions]
# проект не соберётся, если будет хоть одна транзитивная зависимость на dagger большей версии
dagger = { strictly = "2.50" } 
[libraries]
dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" }

К сожалению, у этого способа есть и недостаток. На практике не всегда будет возможно обновить только одну библиотеку. По цепочке придется обновлять и все ее зависимости, а иначе проект просто не сможет собраться. И не всегда достаточно поменять версию в файле сборки. Так что за более качественное тестирование мы платим более сложным процессом обновления библиотек.

Вывод

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

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

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


  1. Rusrst
    11.06.2024 16:32

    Было интересно, спасибо!