Яндекс выпускает Yatagan — опенсорс-фреймворк для внедрения зависимостей, позволяющий ускорить сборку

gbosk6103azqcls5anirre_tok8.jpeg

Меня зовут Фёдор Игнаткевич, я делаю приложение Яндекс и мобильный Яндекс Браузер для Android. Примерно год назад я предложил команде идею фреймворка для внедрения зависимостей, который более чем вдвое ускорил сборку обоих проектов и который мы сегодня выложили на Гитхаб — чтобы разработчики других приложений тоже могли улучшить скорость сборки. Я с нуля реализовал фреймворк, а затем мы вместе с командой интегрировали его в проекты и сейчас активно используем.

Как раз про свой опыт разработки я и хочу рассказать. Давайте попробуем разобраться, какие есть факторы замедления сборки, как Yatagan, совместимый с Dagger по API, с ними справляется и какие ещё задачи могут стоять перед DI-фреймворком — например, в части зависимостей под рантайм-условиями. Кстати, нативная поддержка этих зависимостей в Yatagan избавила нас от ручной обработки состояний A/B-экспериментов в DI.

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

Специфика проекта


Чтобы полностью понять технические решения, которые я принимал при разработке, предлагаю сначала слегка погрузиться в специфику проекта, для которого Yatagan проектировался изначально. Наш проект имеет примерно 150 Gradle-модулей, 2 млн LoC на Java/Kotlin и богатую «историю». Проект большой, и проблемы скорости сборки для него стоят остро.

Продукты и эксперименты


Из кодовой базы проекта могут собираться несколько приложений, каждое со своей спецификой. Также в нём обильно используются A/B-эксперименты для проверки гипотез и оценки влияния тех или иных изменений на ключевые метрики приложений.

Для обработки состояний экспериментов мы применяем следующую структурную модель: части кода в программе могут быть включены или выключены по условию, которое может быть известно уже во время сборки (статически) или вычисляется уже во время выполнения программы (динамически).

  1. Статические условия возникают из возможности собираться в те самые несколько конечных проектов и необходимости менять поведение для некоторых из них.
  2. Динамические условия обуславливаются состоянием A/B-экспериментов на клиенте в момент времени.


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

В контексте использования DI в Java/Kotlin эта модель реализовывается так: условно включаемые части кода — это классы, которые включены в DI-графы. Присутствие/отсутствие определённого класса в DI-графе обуславливается определённым флагом — включен ли определённый эксперимент (динамическое условие). Статические условия выражаются в том, включен ли определённый класс в специфичный для приложения DI-граф или нет.

От рефлексии к кодогенерации: минутка истории DI в проекте


Кратко пройдёмся по истории DI в проекте, чтобы понять, каким образом и с какой стороны мы пришли к Dagger. Dagger имеет непростой API, который позволяет организовывать DI разными способами. Я бы скорее отнёс это к минусам, так как опыт показывает, что действительно правильно организовать на нём DI достаточно сложно, а перекраивать уже однажды написанный DI-код для больших проектов будет дорого и больно.

Для справки: JSR-330 — стандарт DI для Java
Многие DI-фреймворки для Java, например Spring или Dagger, частично используют стандарт JSR-330. Он регламентирует основные аннотации, которые должен использовать DI — @Inject, @Scope, @Qualifier, и описывает базовые контракты, которым совместимый DI должен следовать.

Dagger использует аннотации из JSR-330 и реализовывает контракты поведения из стандарта, за исключением некоторых не очень важных деталей.


1. Рефлексия — IoContainer

Проект разрабатывается довольно давно, с 2013 года, и свой путь начинал ещё в «додаггерные» времена, когда уже был Square/Dagger, но не было Google/Dagger. Первая рефлексийная версия даггера тогда не была популярна, и ведущие умы в команде приняли решение написать свой простой по функционалу DI-фреймворк — IoContainer. Это классический сервис-локатор, в котором требовалось явно регистрировать все классы, участвующие в DI.

Пример класса:

public class MyImpl implements MyApi {
    @Inject public MyImpl(
            Activity context,  // Обычная (прямая) зависимость
            Optional optionalDetail  // Опциональная зависимость
    ) {}
}


Пример регистрации на старте Android-приложения:

registar.register(Activity.class, activityInstance);  // Готовый экземпляр класса
registar.register(MyApi.class, MyImpl.class); // Регистрация на интерфейс aka @Binds
if (myFeatureA || myFeatureB) {
    // Регистрация под условием
    registar.register(FeatureSpecific.class);
}
IoContainer.complete(registar);  // Завершает регистрацию


И использование:

IoContainer.resolve(context, MyApi.class)  // отдаёт созданный MyImpl
IoContainer.resolveOptional(context, AnyUnregisteredClass.class)  // Отдаст Optional.empty()


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

Основной недостаток такого подхода — нет проверки, что реальный граф валиден при всех условиях. Если класс запрашивает зависимость, а зависимость по каким-то причинам не занесли в контейнер, то будет выброшен Missing Dependency Exception. Автотесты и QA могут проверить только несколько конфигураций из комбинаторно большого числа возможных. Соответственно, альфа-версия приложения могла регулярно взрываться такими крэшами, что было очень неприятно.

2. Dagger — начало

Постепенно Dagger 2 становился популярным, и команда задумалась о миграции на него. Он решал основную проблему IoContainer — устранение Missing Dependency Exception, так как весь граф зависимостей проверялся во время сборки проекта. Также от него ожидали улучшения времени старта приложения.

Минусом было то, что Dagger не предоставляет никакой поддержки динамических графов. Если статические условия ещё можно кое-как выразить через @BindsOptionalOf (хотя мы так и не воспользовались этим), с динамическими условиями сложностей было больше. Самое лаконичное, что приходилось писать (в терминах примера про IoC), выглядело примерно так:

@Provides 
Optional optionalOfFeatureSpecific(Provider provider) {
    if (Features.myFeatureA.isEnabled() || Features.myFeatureB.isEnabled()) {
        return Optional.of(provider.get());
    }
    return Optional.empty();
}


И ещё рядом такой же биндинг на Optional>, если кому-то нужен Lazy-вариант.

Но в таком виде никто не запрещал зависеть от FeatureSpecific-класса напрямую и случайно использовать его, даже если условия не выполнялись. Другие варианты оформления Dagger-модулей для симуляции условий так же имели свои минусы, в том числе не отличались компактностью.

В ретроспективе: как ещё можно делать опциональные зависимости в Dagger
Конечно, есть хорошая идея, как можно оформить архитектуру для решения таких задач. У опциональной функциональности необходимо выделить интерфейс и далее, помимо реальной, написать «заглушечную» реализацию этого интерфейса. И внутри одного @Provides-метода отдавать либо реализацию, либо заглушку, в зависимости от условия. Так внешний код не должен никак обрабатывать отсутствие/присутствие функциональности, и отпадает необходимость использовать Optional.

То есть вместо кода из примера выше лучше делать следующим образом:

@Provides MyApi provideMyApi(Provider implProvider) {
    if (...) return implProvider.get() else return new MyApiStub();
}

И насколько можно судить, такой подход — отличное решение для проекта, в котором умеренно используются условные части в DI. Главная сложность тут заключается в верном разделении таких интерфейсов под каждое условие и проектировании их API так, чтобы была возможность сделать noop-реализацию, которая будет соответствовать контрактам API.

Конкретно в нашем проекте, который местами полностью состоит из переключаемых частей с большим количеством условий, формулировать такие API-фасады и заглушки было бы очень нетривиальным рефакторингом с неопределённо большой стоимостью — сыграло роль IoContainer-прошлое, где использование Optional было фактически бесплатным и весь код был усеян этими optional-зависимостями.


По мере перевода проекта на Dagger, DI-код было всё сложнее поддерживать из-за необходимости писать огромное количество таких «условных» биндингов. Пространство для ошибки было большим, а код условий — труднодоступным.

3. Dagger + Whetstone

Миграция на Dagger только началась, но проблемы с условиями уже были очевидны. Тогда я решил сделать фреймворк-компаньон Dagger, который будет уметь в наши runtime-условия (плюс ещё несколько наших хотелок) и генерировать вспомогательные модули для Dagger с кодом, как если бы мы сами писали его руками, но делать это безопасно. Называлось это счастье Whetstone. Не путайте с публичными инструментами с таким названием — наш никуда не публиковался.

Основой Whetstone был специальный Condition API, который позволял объявлять динамические условия прямо на биндингах и классах. Для начала нужно было объявить фичу — специальную конструкцию, помеченную @Condition-аннотациями. Например:

@AnyCondition(
   // Означает: у класса Features найти статический метод/поле MY_FEATURE, 
   // у полученного значения найти метод/поле isEnabled, и результат должен быть boolean.
   // Отрицания выражаются через '!' в начале строки.
   Condition(Features::class, "MY_FEATURE_A.isEnabled"),
   Condition(Features::class, "MY_FEATURE_B.isEnabled"),
)
annotation class FeatureAorB

@AllConditions(
   Condition(Features::class, "MY_FEATURE_A.isEnabled"),
   Condition(Features::class, "MY_FEATURE_B.isEnabled"),
)
annotation class FeatureAandB


Они позволяли закодировать любую булеву функцию в конъюнктивной нормальной форме. @Condition кодировал булеву переменную под отрицанием или без. Чтобы применить оператор «И», нужно было просто аннотировать фичу несколькими условиями (для Java) или применить @AllConditions (для Kotlin). Если нужен был оператор «ИЛИ», то применялась аннотация @AnyCondition. Использовать такую фичу можно было так:

@BindIn(SomeModule::class, condition = FeatureAorB::class)
class UnderAorB @Inject constructor(/*...*/)

@BindIn(SomeModule::class, condition = FeatureAandB::class)
class UnderAandB @Inject constructor(/*...*/)


SomeModule выглядел так:

@Module(includes = [WhetstoneSomeModule::class])  // Этот модуль как раз и генерировался Whetstone
interface SomeModule { /* */ }


После этого Whetstone собирал все @BindIn из проекта и группировал их по целевым модулям, для каждого из которых он и генерировал модуль-компаньон, где содержался весь boilerplate-код с биндингами условий.

Whetstone умел ещё несколько трюков:

  • Гарантировать, что каждый уникальный @Condition будет вычислен только один раз. Генерировались специальные холдеры, которые кэшировали условия.
  • Конструкции вида @BindsAlternatives binds(a: FeatureSpecificImpl1, b: FeatureSpecificImpl2, c: DefaultImpl): Api, которые позволяли привязывать к интерфейсу первый присутствующий в графе класс. То есть если присутствовал FeatureSpecificImpl1, то по запросу Api возвращался он, если нет, то FeatureSpecificImpl2. А если и для того условия не выполнялись, то брался DefaultImpl. Если даже последний вариант оказывался под условием, то весь Api получался под условием.
  • Прочие вещи, специфичные для DI нашего проекта, которые было несложно автоматизировать, например автоматические подписки на события. Мы не будем рассматривать их здесь, так как они в итоге не попали в Yatagan.


Но самое важное, что давал Whetstone, — это compile-time-гарантию, что зависимости между классами под условиями корректны, то есть класс не может напрямую зависеть от другого класса под несовместимым условием. Для этого внутри фреймворка был написан специальный код валидации условий в общем виде. Задача на проверку конкретной зависимости биндинга A от биндинга B сводилась к доказательству утверждения «Если условие биндинга A выполняется, то и условие биндинга B выполняется, для любых значений входных условий».

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

$F_a(x_1,..,x_n)\mbox{ — условие биндинга A,}\\ F_b(y_1,..,y_n)\mbox{ — условие биндинга B,}\\ \mbox{где }x_i,y_j \in C, C \mbox{— множество всех уникальных условий Condition.}$


Тогда задача на проверку корректности прямой зависимости биндинга A от биндинга B сводится к проверке следующего булева выражения на истинность:

$\bot F_a(x) \rightarrow F_b(y) = \top \overline{F_a(x)} \vee F_b(y)=\bot F_a(x)\wedge \overline{F_b(y)}.$


Такое выражение — задача для nSAT-решателя.


В теории это NP-полная задача, которая решается с помощью Boolean Satisfiability Solver. В Whetstone использовалась не слишком замороченная реализация алгоритма DPLL. На практике проблем со временем выполнения алгоритма не было, ибо размерности были небольшие.

Пример зависимости под условием
Рассмотрим классы из примера выше:
  1. Класс UnderAandB может зависеть от класса UnderAorB, так как если верно выражение A && B, то выражение A || B тоже верно. В таких случаях можно писать прямую зависимость: class UnderAandB @Inject constructor(ab: UnderAorB, ...).
  2. Обратное неверно: класс UnderAorB не может напрямую зависеть от UnderAandB, так как из истинности A || B не следует истинности A && B. В таких случаях необходимо писать optional-зависимость: class UnderAorB @Inject constructor(ab: Optional, ...).


Как безопасно переносили условия из IoContainer в Whetstone

Для финальной миграции на Whetstone нужно было корректно перенести все условия из портянок регистраций IoContainer на @Condition Whetstone. Так как весь код регистрации был на Java, задачу я решил, написав временный код прямо внутри самого Whetstone, используя Tree API из javac. Условие вычислялось из кода IoC для каждой регистрации по окружающим её конструкциям if/else. Далее алгоритм сравнивал условие из регистрации и условие, выраженное в терминах @Condition, и выдавал ошибки, если условия не были эквивалентны. Этот инструмент на порядок упростил миграцию и не давал делать ошибки в процессе. Разумеется, пока мы делали миграцию, нашли неконсистентности в условиях, которые были допущены во времена IoContainer из-за отсутствия жёсткой валидации.


В итоге после успешной миграции мы получили гибридный DI-фреймворк, который не позволяет нам сделать ошибку в наших динамических условиях, генерирует код и даёт профиты по скорости старта приложения в релизной конфигурации в сравнении с рефлексией. Но ложкой дёгтя в бочке мёда стала сильно просевшая скорость сборки проекта. Давайте рассмотрим Dagger + Whetstone с технической стороны, чтобы понять, что привело к ухудшению скорости сборки и как мы можем это исправить.

Медленно, но верно, или Проблемы со скоростью сборки


Пожив некоторое время на Dagger + Whetstone, мы поняли, что что-то не так со скоростью сборки. Миграция происходила постепенно, поэтому никто не заметил резкого скачка, так что осознание приходило постепенно. После многочисленных жалоб на медленную сборку и нескольких рабочих встреч я начал исследовать, какие конкретно технические аспекты подобной конфигурации влияют на скорость сборки.

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

Проблема 1: kapt

Если зайти в какой-нибудь офис мобильной разработки, где используют Dagger, остановить в коридоре случайного разработчика и спросить его, чем Dagger замедляет сборку, то он, вероятно, ответит, что это из-за kapt. И будет прав — kapt вынужден запускать предварительную компиляцию Kotlin в специальном режиме генерации заглушек.

Заглушки (stubs) — это Java-исходники, сгенерированные из Kotlin, но без кода внутри методов. Они нужны для того, чтобы классические AP (процессоры аннотаций), коим является Dagger, могли видеть код на Kotlin. На генерацию стабов требуется значительное время, так как при этом запускается компиляция Kotlin в специальном режиме, что довольно дорого. Это сильно влияет на холодную сборку и меньше — на инкрементальную. Ладно, необходимость использовать kapt — один из главных минусов Dagger, идём дальше.

Проблема 2: Сгенерированные классы-фабрики

Dagger на каждый класс с @Inject-конструктором и на каждый @Provides-метод генерирует специальную фабрику (MyClass_Factory). Это плохо по нескольким причинам:

  • Этих классов очень много, они сильно раздувают объём байткода в приложении (в debug-конфигурации, без proguard/r8), что может ухудшать старт приложения — нужно загружать гораздо больше классов.
  • Их нужно дополнительно компилировать, что негативно влияет на скорость сборки проекта.
  • Они часто видны в различных поисках и списках классов в IDE, что раздражает и мешает комфортной навигации по проекту.


Отдельно стоит отметить, что необходимость генерировать такие фабрики вынуждает пользователя включать Dagger в каждом модуле проекта, где присутствует хоть один @Inject/@Provides. Если этого не делать, то проект всё равно будет собираться и даже корректно работать, но есть нюанс в инкрементальной сборке. Система сборки Gradle поддерживает инкрементальную обработку аннотаций при выполнении определённых условий со стороны самого AP и проекта, который его включает. Для нас важно, что AP имеет право генерировать код только для тех элементов программы, которые находятся непосредственно в текущей единице компиляции, то есть в gradle-модуле. Если пытаться генерировать код для класса из библиотеки (а зависимые подпроекты уже фактически являются библиотеками для текущего), то Gradle выдаст warning про отсутствующие originating element и уведомит, что инкрементальная компиляция далее невозможна. Проблема именно в том, что если в зависимом модуле Dagger не включен, то он пытается догенерировать фабрики для классов из этого модуля в рамках текущего — Gradle такое не нравится. Но включение Dagger в каждом модуле, где есть хоть один @Inject, почти наверняка просадит сборку — нужно генерить ещё больше стабов и ждать AP. А если не удовлетворять систему инкрементальной обработки в Gradle, то мы пожертвуем инкрементальностью. Что ж, похоже на проблему о двух стульях грустно, но и это ещё не всё.

После всего вышеперечисленного я обнаружил вишенку на верхушке торта. Эти классы фабрик вообще никак вразумительно не используются, если Dagger работает в режиме dagger.fastInit (о нём можно почитать здесь). А именно этот режим рекомендуется для больших Android-приложений. Вот оно как! Зачем же Dagger всё ещё генерирует их? Вероятно, для бинарной совместимости. Если кто-то захочет использовать такие классы в зависящих проектах без fastInit, ему будут нужны эти фабрики, и Google, кажется, решил не ломать такой, хоть и странный, use-case. А может быть, на это есть и другие причины, ещё менее очевидные.

От этих фабрик получается много неприятных проблем, которые по факту оказались не нужны.

Проблема 3: Особенности реализации Whetstone

Whetstone состоял из двух почти независимых сущностей: генератора и валидатора. Генератор представлял собой процессор аннотаций для генерации кода для Dagger. Валидатор — плагин для Dagger, использующий Dagger SPI, чтобы проверять корректность условий для построенных Dagger-графов.

Работа генерирующей части Whetstone базировалась на предположении, что Dagger увидит результат её работы.

Для справки: как в apt/kapt обрабатываются зависимости между разными AP

В рамках JSR-269 (оригинальная спецификация обработки аннотаций для Java) порядок вызова AP не определён, и на него нельзя никак влиять. Вместо этого AP могут работать в несколько заходов — раундов. В каждый раунд обработки каждый AP вызывается системой и пытается выполнить свою работу. В случае обнаружения каких-то отсутствующих (unresolved) элементов в коде AP может прекратить работу, сообщив системе, что код неполный, и ему не хватает классов для решения своей задачи. Тогда система помечает этот AP как «ожидающий» и запускает оставшиеся AP в надежде, что они догенерируют нужный первому AP код. Как только все AP отработали и какие-то из них остались «ожидающими», система начинает новый раунд — запускает ожидающие процессоры ещё раз. И так происходит, пока все процессоры не завершатся успешно или в очередном раунде не появится новый код.


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

Стоит отметить, что Whetstone приходилось генерировать много кода для всех вариантов опциональных биндингов. Также он замещал Inject-конструктор класса под условием на @Provides-метод, чтобы контролировать его создание и использование. Всё это привносило ещё больше мусора в общий объём байткода, который отрицательно влиял на скорость сборки и на производительность приложения в debug-конфигурации.

Кроме того, Whetstone был, если говорить в терминах инкрементальной обработки аннотаций Gradle, агрегирущим процессором аннотаций. Это значит, что ему нужно было собрать со всего проекта синтаксически независимые друг от друга конструкции, помеченные @BindIn, и обработать их в совокупности. При изменении или добавлении нового класса под @BindIn было необходимо повторить весь процесс целиком, что почти сводило на нет пользу от инкрементальной компиляции, так как таких классов было очень много. Если вы работали с Dagger Hilt, то он работает таким же образом по отношению к @InstallIn. Удобно, но медленно.

Чтобы написать опциональный биндинг в рамках Dagger (руками или автоматически, неважно), как мы ранее разбирали, необходимо было запросить зависимость от объекта Provider, чтобы затем внутри кода биндинга уже принять решение, вызывать provider.get () или нет. Но во время моих исследований кода, который генерирует Dagger в разных случаях, я выяснил следующий факт. Если класс где-то запрашивается как Lazy/Provider, Dagger меняет стратегию кодогенерации для этого класса на менее оптимальную, чтобы уметь отдавать наружу объект провайдера. Получалось, что опциональные биндинги таким образом портили большое количество классов в графе, так как привносили в граф провайдерные использования. Так что это могло в теории немного ухудшать производительность сгенерированного кода.

Подытожим все проблемы, найденные в Dagger + Whetstone по скорости сборки:

  • Медленно работает на kapt.
  • Dagger медленно генерирует ненужные фабрики. В них больше классов, что заставляет включать Dagger в большем количестве модулей — так дольше генерировать стабы.
  • Whetstone замедляет обработку аннотаций, генерирует больше классов и заставляет Dagger генерировать субоптимальный код.

Yatagan aka Dagger Lite


И вот, когда я откопал все вышеупомянутые корни медленной сборки проекта, я решил предложить написать yet another DI-фреймворк, который бы представлял собой новый движок для Dagger-like API и нативно поддерживал основную функциональность Whetstone для работы с runtime-условиями. При разработке такого фреймворка можно было бы учесть все описанные проблемы классического Dagger и придумать для них решения. Также я решил попробовать нативно поддержать режим работы на Java-рефлексии, чтобы ещё больше ускорить сборку проекта.

Фреймворк получил в моей голове кодовое название Dagger Lite, и назывался так у нас внутри, но далее в статье я буду упоминать публичное название Yatagan, чтобы вас не путать.

Моя мотивация и предложения по решению проблем были следующими:

  • Yatagan не будет генерировать мусорные SomeClass_Factory и MyModule_Method_Factory, тем самым он решает пачку проблем, вызванных этим. Таким образом, фреймворк можно применять только к тем модулям, в которых есть корневые @Component-объявления, так как генерация кода происходит именно для них.
  • Yatagan будет нативно поддерживать runtime-условия с Whetstone-like API и уметь генерировать для них оптимальный код. Это решает проблемы с многораундовой обработкой, генерацией лишних Provider, code-bloat и прочих.
  • Yatagan будет поддерживать и классический kapt, и новый движок KSP, что должно, по заявлению Google, решать проблемы с производительностью kapt.


Также я предложил дополнительные пункты для оптимизации:

  • Yatagan будет нативно поддерживать reflection-only режим. Такой режим можно включать для локальной разработки, где не нужна пиковая производительность приложения, а нужна пиковая скорость пересборки после изменений. При включении такого режима Yatagan будет строить граф полностью в runtime, используя данные Java Reflection. Так мы полностью избавим сборку от шага обработки аннотаций, что значительно её ускорит. Для очень больших графов построение во время выполнения может замедлять старт приложения на несколько секунд, но обычно это не критично по сравнению с ускорением сборки. Улучшается общая метрика «от правки кода до работающего приложения», которая важна при разработке.
  • Yatagan может генерировать код как для однопоточного, так и для многопоточного использования (по требованию). Однопоточная реализация может иметь лучшую производительность из-за отсутствия синхронизации и вспомогательных объектов. Тип thread-safety может быть выбран при использовании для каждой иерархии графов отдельно.


Основной компромисс, на который придётся пойти, заключается в том, что будет реализовано только подмножество API Dagger:

  • Часть Dagger API не использует почти никто, например dagger.producers.*, так что и волноваться незачем.
  • Часть не получится реализовать вообще, например dagger.hilt.*, из-за технических особенностей Yatagan (далее поясню, почему).
  • Часть можно будет реализовать позже по запросу.


Давайте сразу разберём несколько вопросов, которые могут возникнуть по предложениям, сначала — о режиме reflection-only, ведь есть же готовый Dagger Reflect, который реализовывает Dagger на рефлексии. У себя мы не сможем его использовать, потому что в нём нет функциональности Whetstone. К тому же это отдельный проект, для которого нет жёсткой гарантии, что он будет вести себя так же, как и Dagger с кодогенерацией. А мы очень хотим иметь такую гарантию, чтобы убедить разработчиков, что их код будет работать одинаково с рефлексией и без неё.

Дальше рассмотрим вопрос о поддержке KSP. Dagger планирует поддержать KSP у себя. К тому же есть Anvil. Dagger так и не поддерживает KSP на момент написания (и я могу их понять после работы, которую проделал сам), а Anvil как инструмент ускорения Dagger будет давать меньше профита, чем Yatagan, если всё получится реализовать.

Почему Anvil будет давать меньше выигрыша в сборке, чем Yatagan

Anvil — это плагин к компилятору Kotlin, который генерирует эти самые ненужные фабрики, избавляя пользователя от необходимости включать даггер и генерировать стабы в лишних модулях. В Yatagan эти фабрики вообще не генерируются.


Об идейной поддержке Hilt
Dagger Hilt позиционируется как отдельный продукт, целевая аудитория которого, по моему мнению, — небольшие новые проекты мобильных приложений на Android. Hilt разработали специально, чтобы нивелировать высокую стоимость начальной настройки DI для Android-приложений, которая была присуща классическому Dagger. Yatagan же в данный момент целится на решение проблем в больших проектах, где уже используется Dagger. В большинстве небольших приложений проблемы со скоростью сборки не будут стоять так остро. Также в таких приложениях не будет надобности коренным образом решать проблему с условными биндингами.

Готов ли Yatagan в будущем предложить что-то и для таких проектов? Вполне возможно, но пока это только планы.


Самым важным поставленным требованием, пожалуй, было то, что новый фреймворк должен быть совместим с Dagger по API или хотя бы не требовать нетривиальную миграцию c Dagger. API Yatagan базируется на API Dagger 2 и в некоторых местах полностью его повторяет. Также этот API поглотил Whetstone с некоторыми изменениями — @BindIn был заменён на метку @Conditional. Поведение Yatagan в некоторых местах немного отличается от того, что делает Dagger, но все такие места я постарался задокументировать. Для поддержки динамических условий фреймворк использует систему @Condition/@Conditional, а для поддержки статических условий вместо @BindsOptionalOf в нём используется система вариантов, которая чем-то напоминает flavors/variants при сборке Android-приложений. Рассмотрение этих систем в подробностях выходит за рамки статьи, но вы можете найти это в документации к API Yatagan.

Кроме того, для наших проектов критически важна скорость работы приложений, поэтому я много раз проводил замеры производительности и могу уверенно сказать, что код, который генерирует Yatagan, как минимум не уступает коду Dagger по производительности, а в каких-то случаях и выигрывает. Стратегия генерации кода в Yatagan похожа на ту, что использует Dagger в режиме fastInit.

Но при этом важно отметить, что Yatagan изначально не планировался как полностью универсальная замена для Dagger, потому что он не поддерживает некоторые дополнительные возможности Dagger, хотя вся ключевая функциональность в нём реализована.

Архитектура и реализация Yatagan


Чтобы реализовать все заявленные пункты, в особенности поддержку сразу трёх бэкендов (kapt, ksp, reflection), нужна была соответствующая архитектура проекта. Нам было важно, чтобы Yatagan вёл себя одинаково вне зависимости от выбранного бэкенда. Давайте попробуем разобраться, как такое сделать.

Итоговая архитектура Yatagan базируется на нескольких основных слоях абстракции:

  1. : lang — абстракция языковой модели. Она моделирует типы, классы, методы и прочие конструкции ЯП. Языковые элементы в основном имеют семантику языка Java, не Kotlin, так как Dagger работает с типами с точки зрения системы типов Java, и я посчитал нужным не отходить от этого в Yatagan. Когда-то в lang была начальная поддержка некоторых сущностей из Kotlin, например свойств (properties), но потом эту поддержку удалили из-за производительности. В API содержится только то, что необходимо следующим слоям абстракции для работы, а также минимальный набор API, который может понадобиться разработчикам плагинов (да, к Yatagan можно написать плагин, смотрите документацию). У lang есть три основных реализации:
    • lang: jap — реализация для apt/kapt, использующая javax.lang.model.**
    • lang: ksp — реализация для KSP, использующая com.google.devtools.ksp.**
    • lang: rt — реализация на рефлексии, использующая java.lang.Class и java.lang.reflect.*
  2. : core: model — абстракция элементарных сущностей в Yatagan: компоненты, модули, узлы графа (node). Эти модели строятся на основании языковых элементов из lang.
  3. : core: graph — тут происходит полное построение графа биндингов (bindings) на основании элементарных моделей из : core: model. С построенным графом уже можно производить любые операции: проверить на ошибки, отправить в генерацию или сконструировать рефлексийную реализацию через механизм java.lang.reflect.Proxy.


Принципиальная схема структуры Yatagan (которой примерно соответствует разделение проекта на модули, если опустить детали реализации):

fanfprh3b5ecqyyqlv-y8nnp-zk.png

Таким образом, Yatagan обеспечивает гарантию, что все бэкенды будут работать идентично, поскольку:

  • Построение моделей и финального графа привязок происходит в общем коде.
  • Все бэкенд-специфичные вещи изолированы внутри lang-реализации и публичного бэкенд-специфичного артефакта.
  • Чтобы проверять, что бэкенд-специфичный код работает одинаково, в Yatagan есть универсальные интеграционные тесты, которые параметризованы бэкендом. Каждый тест запускается для каждого бэкенда и проверяет, что поведение корректное. Код тестов никак не связан с реализацией и будет актуален, даже если полностью переделать всю реализацию того или иного компонента проекта.


Специфика реализации Reflection


Поддержка построения графов во время выполнения сразу наложила два ограничения на API Yatagan:

  1. Компоненты (@Component) и их фабрики (@Component.Builder) могут быть только интерфейсами, чтобы можно было использовать java.lang.reflect.Proxy для построения реализации компонента/фабрики «на лету».
  2. Компоненты создаются не через прямое обращение к сгенерированному классу, как в Dagger (например, для компонента MyComponent нужно было бы писать DaggerMyComponent.builder().build()), а через специальную точку входа — объект Yatagan, у которого есть методы для создания реализации компонентов — create() и builder(). Применяется это так: Yatagan.builder(MyComponent.Builder.class).build() или Yatagan.create(MyComponent.class) — если у компонента нет фабрики.


Точно такой же подход применяется в Dagger Reflect, и у него присутствует такое же требование интерфейсов и использование специальной точки входа. У Yatagan этот подход унифицирован — класс-точку входа нужно использовать в любом случае, так как сгенерированные имена компонентов задекорированы (mangled), а kapt/ksp-реализация выдаст ошибку, если вместо интерфейса для компонента использовать абстрактный класс.

Почему Yatagan не может даже в теории поддержать Hilt

Реальная причина одна: Hilt — агрегирующий процессор. Если движки обработки аннотаций и предоставляют такой режим, который впрочем ухудшает инкрементальность сборки, как мы разбирали на примере Whetstone, то Java Reflection не умеет в агрегацию. Другими словами, невозможно попросить Java вернуть все классы, помеченные определённой аннотацией, которые он найдёт в runtime classpath. Если очень постараться, то технически можно разработать решени

© Habrahabr.ru