Connect with us

Разработка

От Dagger к Metro

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

Опубликовано

/

     
     

Metro — современный фреймворк для внедрения зависимостей в Kotlin, созданный Заком Свирсом. И мы, Android-разработчики из Vinted, официально и полностью перешли на него! Для нашей огромной кодовой базы это был довольно непростой путь.

Наша история начинается!

Эра до Metro

У нас огромная кодовая база, состоящая из нескольких сотен модулей Gradle, за последние 14 лет мы собрали как хороший, так и устаревший код. Мы с самого начала приняли идею внедрения зависимостей: сначала это была версия Dagger, выпущенная Square, затем вторая полностью статическая версия, выпущенная Google. Пару лет спустя мы перешли на dagger.android, и идея наличия подкомпонентов для каждого фрагмента тогда казалась фантастической (спойлер: это не так). Позже появилась более простая, но более мощная идея внедрения зависимостей в виде фреймворка Hilt, но было уже слишком поздно переделывать все фрагменты.

После того, как модульность набрала обороты, а количество модулей быстро выросло, мы начали завидовать способу установки зависимостей в Hilt вместо предоставления их через большие модули Dagger. Но было сложно оправдать для бизнеса время, потраченное на переписывание кода.

Пока однажды мы не нашли Anvil — плагин компилятора Kotlin, который воплощает идею Hilt добавления зависимостей через аннотации. И это было быстрее за счет генерации фабрик Dagger. Мы с энтузиазмом начали его внедрять и даже перевести фрагменты с Android Injector на внедрение зависимостей через конструктор.

Но технологии развиваются быстро, Kotlin выпустил K2, и поскольку Anvil был плагином компилятора Kotlin, потребовались огромные усилия для внедрения K2, а позже Anvil перешел в режим поддержки.

Таким образом, даже обновив Kotlin до версии 2.x, мы все еще оставались на K1 и языковых функциях версии 1.9 без инкрементальной компиляции. Приближающаяся к концу поддержка K1 также создавала дополнительное давление. У нас было много вариантов: Hilt, Kotlin-inject и… Metro.

Почему Metro?

Metro был создан на основе уроков, извлеченных из других DI-фреймворков, объединив множество надежных идей. Он хорошо поддерживает идиомы Kotlin, быстр, стабилен и прост в освоении. Однако на тот момент было сложно оправдать переход — на первый взгляд, полагаться на совершенно новый фреймворк казалось слишком рискованным.

У Metro есть существенное преимущество при миграции, которого нет у других фреймворков: надежная, многофункциональная совместимость с популярными DI-решениями, такими как Dagger, Anvil и kotlin-inject. Такой уровень совместимости отсутствует у его конкурентов. Фактически, Metro был для нас самым быстрым способом перейти на K2, поскольку другие фреймворки потребовали бы миграции большего количества бизнес-кода — что добавило бы не только время, но и риск.

Мы оценивали все варианты, но Metro быстро развивался, и направление его развития очень точно соответствовало нашим потребностям. Это еще больше укрепило наш выбор.

Непростая миграция

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

- import javax.inject.Inject
+ import dev.zacsweers.metro.Inject

Забавно, что javax.inject.Inject часто «просачивается» из других библиотек! Поэтому IDE всегда пытается предложить его в автозаполнении. В какой-то момент нам пришлось настроить отдельный обработчик KSP для проверки корректности сборки, если в исходном коде обнаруживалась аннотация @Inject из неправильной библиотеки, поскольку это могло привести к незаметным и труднообнаружимым ошибкам. Позже, однако, нам удалось полностью удалить его из пути компиляции, что решило проблему с автозаполнением.

Также мы удалили @JvmSuppressWildcards, поскольку они больше не нужны. Более сложной задачей была замена @ContributesMultibinding, так как в Metro есть две аннотации: @ContributesIntoSet и @ContributesIntoMap. Но не волнуйтесь, Metro сообщит вам, если вы допустите ошибку!

- @ContributesMultibinding(FragmentComponent::class)
+ @ContributesIntoMap(FragmentScope::class)
  @ViewModelKey(AddressPluginViewModel::class)
  class AddressPluginViewModel @Inject constructor(): ViewModel

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

boundType = (.*)::class
️⬇️
binding = binding<$1>()

Дальше все тоже оказалось непросто. Помните Android Injectors из dagger.android, о котором я упоминал ранее? У нас осталось еще более 100 фрагментов… Но нет ничего, с чем бы не справилась генерация кода! Мы сделали простую реализацию для генерации расширений графа на основе похожих аннотаций (здесь мы использовали некоторые упрощения). Вот этот код:

// Container only for Android Injector contributions
@InjectorModule(ActivityScope::class)
abstract class LegacyFragmentsModule {
    @FragmentScope
    @ContributesAndroidInjector(modules = [LegacyModule::class])
    abstract fun contributesLegacyFragment(): LegacyFragment
}

Мы сгенерировали это:

@FragmentScope
@GraphExtension(
    FragmentScope::class,
    bindingContainers = [LegacyModule::class]
)
public interface LegacyFragmentInjectorGraph {
    // Still using member injection
    public fun inject(instance: LegacyFragment)

    @ContributesTo(ActivityScope::class)
    @GraphExtension.Factory
    public interface Factory {
        fun create(
            @Provides instance: LegacyFragment
        ): LegacyFragmentInjectorGraph
    }
}

@Inject
@ContributesIntoMap(
    ActivityScope::class,
    binding = binding<InstanceInjector<Fragment>>()
)
@ClassKey(LegacyFragment::class)
public class ShippingFragmentInjector(
    private val graphFactory: ShippingFragmentsInjectorGraph.Factory,
) : InstanceInjector<Fragment> {
    override fun inject(instance: Fragment) {
        graphFactory.create(instance as LegacyFragment).inject(instance)
    }
}

И вуаля, еще один большой кусок кода готов! Остальное было проще. Мы воспользовались нашей существующей системой генерации кода на основе KSP. У нас была собственная генерация кода для многих вещей, поскольку нам нужно было генерировать шаблонный код для Anvil (да, мы генерируем шаблонный код для всего). Изменить генерацию кода было несложно, в основном это касалось импортов и имен аннотаций, и вот еще пара сотен случаев исправлены!

@ContributesFragment
class InfoFragment @Inject constructor() : Fragment

@ContributesViewModel
class InfoViewModel @Inject constructor() : ViewModel

Не всё прошло гладко. Мы на собственном опыте узнали, что значит внедрять инструмент версии 0.x. Столкнулись с рядом случаев, когда компилятор падал со StackOverflowException, а сгенерированный код работал слишком медленно.

Начинали мы с версии 0.7.x, а закончили на 0.9.2 (0.9.3 для нас оказалась нерабочей). Большинство проблем было связано с MemberInjector — использовать его мы не рекомендуем.

Кроме того, как и Anvil, Metro является плагином компилятора и не генерирует большого количества артефактов в build-директориях, как это делали Anvil и Dagger. Сначала это усложняло дебаг: меньше «осязаемого» кода — сложнее понять, что происходит. Но после того как мы привыкли к подробным diagnostic-отчётам (которые по умолчанию скрыты и включаются через свойство Gradle-плагина Metro), отладка стала даже проще, чем раньше.

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

Подводя итог — мы ни о чём не жалеем. Да, путь был тернистым, но он того стоил. Мы глубоко разобрались в работе компиляторов, научились проводить масштабные миграции и строить качественный codegen. Отдельная благодарность Заку за оперативные фиксы и вовлечённость — надеемся, что и наши небольшие контрибьюции в Metro помогут другим пройти миграцию более гладко.

Результаты

Спустя два месяца после завершения миграции и стабилизации всех проблем мы смогли включить долгожданный K2, а чуть позже — и инкрементальную компиляцию (тема, достойная отдельной статьи).

Результаты для нас выглядят весьма убедительно. Помимо избавления от экзистенциальной тревоги из-за неизбежного отказа от поддержки K1, мы получили ощутимую экономию времени сборки в CI.

Для нашей крупной кодовой базы показатели стали следующими:

Сценарий сборки Metro Dagger/Anvil Reduction Savings
Наилучший вариант сборки: большинство задач кэшируются 3m 23s 4m 33s 25.64% 1m 10s
Наихудший сценарий сборки; ни одна задача не кэшируется 24m 12s 27m 05s 10.65% 2m 53s
Наихудший сценарий релизной сборки; ни одна задача не кэшируется 37m 43s 40m 09s 6.06% 2m 26s
Изменение ABI в основном модуле, от которого зависят все функциональные модули 15m 46s 17m 22s 9.21% 1m 36s

Эти статистические данные были получены с использованием Metro 0.9.x. Metro продолжает развиваться и совершенствоваться, улучшая генерируемый код и, следовательно, время сборки, поэтому, если бы мы измеряли их с последней версией, результаты, безусловно, были бы еще лучше!

Время локальной сборки также значительно улучшилось, инкрементальная компиляция — это не шутка! Однако, ради краткости, мы не будем приводить данные здесь.

Заключение

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

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

Удовлетворенность и уверенность разработчиков в контексте внедрения зависимостей также возросли с появлением Metro. Проще рассуждать об одной системе внедрения зависимостей, чем о двух, особенно когда эта система Kotlin-first и является Kotlin-ориентированной.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: