Dagger 2 и жизненный цикл

2e6d41fbb8f676ead6897309f5f27bc6.png

Привет, меня зовут Владимир Феофилактов, я занимаюсь Android‑разработкой мобильного приложения СберБизнес. Я хотел бы поделиться с вами историей поиска ответа на вечный вопрос: «когда и как чистить граф зависимостей?».

У нас было приложение‑монолит с главным ComponentManager, где строился весь граф зависимостей. Файл был немаленький. Также во всех фрагментах необходимо было вручную создавать DI‑компонент и следить за его чисткой, а разработчики то и дело забывали про это или чистили неправильно.

Потом началась кампания по выносу фичёвого кода в отдельные модули. С каждым новым модулем всё чаще выстреливала циклическая зависимость, и приходилось писать обвязку (прокси‑класс). Главный ComponentManager оставался связующим звеном между фичёвыми модулями, через которое происходил переброс зависимостей, так что он всё ещё был большим и сложным. Иногда рефакторинг, связанный с перемещением кода из одного модуля в другой, вызывал многочасовую борьбу за нахождение пути решения проблемы «почему сборка никак не соберётся?». Иногда, чтобы доставить зависимость из одного модуля в другой, нужно было строить сложные конструкции, как при игре в «Твистер».

Вся эта увлекательная и мучительная игра продолжалась довольно долго, пока на помощь, как бы странно это ни звучало, не пришёл новомодный Compose. Тогда мы только начинали его осваивать, и я решил сделать небольшой pet‑проект, состоящий из двух экранов, с применением Compose для его изучения. Естественно, в этот проект подключил всё самое современное из библиотек, включая Dagger 2. Как обычно, немного повозившись с настройкой DI, в голове всплыла вся та боль, которую мы испытываем в нашем проекте.

Составил план и требования:

  1. Создание DI‑компонентов должно быть простым.

  2. Создавать DI‑компоненты стоит только тогда, когда они действительно нужны.

  3. Нужно хранить DI‑компонент ровно до тех пор, пока в нём есть необходимость.

  4. Обеспечить доступность DI‑зависимостей в любом модуле проекта.

  5. Установить связь между «родителями» и дочерними компонентами.

  6. Избавиться от циклической зависимости.

  7. Автоматизировать жизненный цикл DI‑компонентов.

Затем принялся за работу. Хотелось получить что‑то простое. Отмечу, что для Dagger 2 способов ответить на поставленные вопросы на тот момент не было, или я плохо искал. Если вы о них знаете, то поделитесь в комментариях.

Немного прокипятив в голове мысль «как сделать хорошо», у меня родилась вот такая идея:

По мере роста созданных DI-компонентов растёт и сам позвоночник
По мере роста созданных DI-компонентов растёт и сам позвоночник

С чего начинается вся разработка? Правильно, с названия, и имя проекта — ComponentStorage.

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

// Регистрация компонентов
// MyApp/features/feature1/impl
object Feature1ComponentManager {
    fun register() = with(ComponentStorage) {
        register {
            DaggerFeature1Component
                .builder()
                .build()
        }
    }
}


// MyApp/features/feature2/impl
// build.gradle.kts
implementation(project(":features:feature1:api"))


// Получение зависимости
object Feature2ComponentManager {

    fun register() = with(ComponentStorage) {
        register {
            DaggerFeature2Component
                .builder()
                .feature1Api(get()) // получение зависимости
                .build()
        }
    }
}

Всё, что может понадобиться — подключить к фиче API‑модуль feature1, и зависимость станет доступна. Если у вас монолит, то будет ещё проще.

Давайте разберём, как же это работает. Для решения первых трёх пунктов плана потребуется обычный Map, он отлично подошёл для решения этой задачи. Дальше нужно наделить этот Map разными суперспособностями по созданию, кешированию, получению и удалению DI‑компонентов.

Сам кеш выглядит так:

object ComponentStorage {
    @VisibleForTesting
    val cache = ConcurrentHashMap>>()
} 

Так как создание Dagger-компонента (далее DI-компонента) должно быть lazy (не путать с аналогичным lazy, предоставляемым самой библиотекой Dagger 2), то нужно было придумать способ хранения билдера. На эту роль я взял лямбду с параметрами:

class ComponentProvider(
    val parentComponent: ((Params) -> ParentComponent)?,
    val provideComponent: (ParentComponent?, Params) -> Component,
)

Добавим providers в ComponentStorage:

object ComponentStorage {


    private val providers = ConcurrentHashMap>()

    @VisibleForTesting
    val cache = ConcurrentHashMap>>()

} 

Переходим к созданию компонента. В ComponentStorage появляется метод register:

inline fun  register(
    noinline provider: () -> Component,
)

Как уже говорил, provider — это просто лямбда, которая вызывается, когда нужно создать DI-компонент.

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

.feature1Api(get())

На ум, конечно же, пришёл interface. Но по факту через register регистрируется Feature1Component, который как раз и недоступен. Тогда пришла идея такого подхода:  

1. ComponentStorage.register {
    DaggerFeature1Component.builder().build()
   }

   // или
2. ComponentStorage.register(Feature1Api::class) {
    DaggerFeature1Component.builder().build()
   }

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

На деле всё оказалось куда проще. Так как дженерик использует reified, можно кое-что получить из Component, а именно — все интерфейсы, которые есть у компонента.

val interfaces = T.java.interfaces

Таким образом DI-компонент можно получить по любому представленному интерфейсу. Это позволяет создавать один компонент и получать его разным способами, а также исключить проблему неправильного указания интерфейса при получении компонента. Например, внутри фичи можно использовать интерфейс Feature1Component и Feature1API, а снаружи — только Feature1API:

interface Feature2Api {
    fun useCase(): Feature2UseCase
}

@Component
interface Feature2Component : Feature2Api {
    fun presenter(): Feature2Presenter
}


// Будет возвращать один и тот же компонент
ComponentStorage.get()
ComponentStorage.get()

Возможно, у вас появился вопрос: «Да как же get() понимает, какой именно DI-компонент нужно вытащить?» Ответ прост: дженерик и есть ключ сам по себе, и генерируется он просто:

fun  getComponent(
    component: KClass
): ComponentHolder {
    val key = component.hashCode()

Использование hashCode() в качестве ключа рискованно из-за возможности получить одинаковые значения. Но так как риск довольно маленький, а нам нужно сделать максимально просто и удобно, мы всё же рискнули. И за три года проблем здесь не было.

Что касается удаления и чистки, то в качестве параметра используется компонент KClass:

fun clear(
    component: KClass<*>,
)

Одной из важных способностей у ComponentStorage является древовидная связь между DI-компонентами, которая позволяет «вырастить» или «срезать» сразу целый ствол с ветками всего одним методом: get или clear. Например, если завязать все компоненты авторизованной зоны на один родительский DI-компонент, а потом вызвать:  

ComponentStorage.clear(SessionApi::class)

…то все компоненты авторизованной зоны будут удалены.

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

Пример:  

ComponentStorage.registerWithParent {
    DaggerFeature2Component
        .builder()
        .feature1Api(get())
        .build()
}

Если используются сабкомпоненты, то можно упростить регистрацию:

registerWithParent(provider = SessionApi::plusFeature2Component)

А что же насчёт уникальности? Можно ли создать одновременно один и тот же DI-компонент? Можно. Бывают случаи, когда есть один экран, но он открывается по параметрам. То есть, вроде экран один, а DI-компоненты нужны разные. Для этого можно воспользоваться параметром params у метода getComponent:

fun  getComponent(
    component: KClass,
    params: Params = null, // это просто Any?
)

Например:  

ComponentStorage.get("some_params_1”)

ComponentStorage.get("some_params_2”)

ComponentStorage создаёт два разных экземпляра одного DI-компонента на основе входных параметров. И роль этого механизма тут не заканчивается. Это значение прокидывается дальше и попадает в раздел регистрации:

ComponentStorage.register { params ->
        DaggerMyComponent
            .factory()
            .create(params)
}

Это позволяет положить в DI-компонент некие параметры, а после пробрасывать их прямиком в классы через конструктор. Например, ViewModel на этапе её создания уже может знать id статьи

По поводу циклической зависимости: скажу честно, полностью избавиться от неё нам не удалось. Но всё же у нас получилось значительно уменьшить проблему. В остальных случаях приходится делать proxy-класс, или же кто-нибудь злоупотребляет вседоступностью ComponentStorage, вызывая ComponentStorage.getComponent где попало.

Жизненный цикл

Сам ComponentStorage ничего такого не умеет, он является только хранилищем DI-компонентов. И для того чтобы вся эта история заработала, мы написали ещё несколько extention-функций, завязанных как раз на жизненном цикле того места, где вызывается getComponent. Первым подопытным стал fragment. Нужно было избавить разработчиков от тягот ручного управления, создания и очистки DI. Воспользовавшись делегатами, мы получили такую конструкцию для MVP архитектуры:

private val component by component()

private val presenter by presenterBinding(component::presenter)

Сам код делегата для создания или переиспользования DI-компонента выглядит так:

class FragmentComponentPropertyProvider(
    private val component: KClass,
    private val params: (() -> Any)? = null,
    private val owner: Fragment,
) : Lazy, DefaultLifecycleObserver {

    private var cached: ComponentHolder? = null

    private val viewModel by owner.viewModels()

    override val value: Component
        get() {
            return cached?.get() ?: ComponentStorage.getComponent(component, params?.invoke()).also {
                cached = it
                owner.lifecycle.addObserver(this)
            }.get()
        }

    override fun isInitialized(): Boolean = cached != null

    override fun onCreate(owner: LifecycleOwner) {
        viewModel.addCloseable {
            cached?.clear()
            cached = null
        }
    }

}

internal class LifecycleViewModel : ViewModel()

То есть при заходе на fragment у нас создаётся DI-компонент, а при уничтожении fragment у нас чистится и component. Также DI-компонент переживает реконфигурацию Activity. Примерно такие же делегаты мы сделали и для presenter.

Как я уже упоминал, началось всё с Compose. Он у нас только вводился, и были проблемы с жизненным циклом в ранних версиях. Когда всё стало налажено, мы легко смогли адаптировать использование ComponentStorage внутри Compose-функций, вот так:

@Composable
internal fun MyScreen() {
    val viewModel = getViewModel()
}

И, само собой, тут тоже есть привязка к жизненному циклу.

@Stable
@Composable
inline fun  getViewModel(
    params: Any? = null,
): VM {
    return viewModel {
        val holder = ComponentStorage.getComponentHolder(params)
        val key = holder.get().hashCode()
        holder.get().viewModelFactory().create(VM::class.java).apply {
            viewModelLinkToDiStore.set(key, this)
            addCloseable {
                if (viewModelLinkToDiStore.clear(key, this)) {
                    holder.clear()
                }
            }
        }
    }
}

val viewModelLinkToDiStore = ViewModelLinkToDiStore()@Composable
internal fun MyScreen() {
    val viewModel = getViewModel()
}

К сожалению, без хоть и небольшого, но костыля не обошлось. Нам пришлось придумать ViewModelLinkToDiStore. Его задача — следить, чтобы DI-компонент не был уничтожен слишком рано. А это может произойти, если один и тот же DI-компонент используется на двух и более экранах. Поддерживает работу с NavHost (навигацией на Compose).

Как-то у нас был большой рефакторинг с переносом большого кода из одного модуля в разные. И благодаря ComponentStorage он прошёл гладко и без лишних напрягов.

Остался последний нюанс: что делать, если два компонента используют один и тот же интерфейс, а ComponentStorage берёт все интерфейсы, которые есть у DI-компонента, и привязывает их к нему? Для решения этой проблемы есть два пути. Первый — для повсеместных интерфейсов, тех, которые очень часто используются. У нас это ViewModelAPI. У ComponentStorage есть метод excludeAliases, в который можно добавить список такого рода исключений:

ComponentManager.excludeAliases(ViewModelApi::class)

И второй путь: при регистрации DI-компонента явно указать, какие ключи будут использоваться. Тогда ComponentStorage будет использовать только aliases и проигнорирует другие интерфейсы:

ComponentStorage.register(aliases = listOf(Feature1Api::class)) {
    DaggerFeature1Component.builder().build()
}

Этим методом мы воспользовались всего четыре раза.

Преимущества и недостатки использования ComponentStorage

Преимущества:

  • Лёгкий доступ к любому компоненту из любого модуля.

  • Управляемый жизненный цикл компонента с расширениями на автоматизацию.

  • Связанность «родителя» и дочерних компонентов.

  • Автоматическое создание DI-компонентов только при необходимости.

  • ComponentStorage возможно использовать не только с Dagger 2, но и с другими подобными фреймворками. Например, с Toothpick.

Недостатки:

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

  • Регистрация DI-компонентов при запуске приложения, создание и хранение провайдеров (анонимных объектов). Время на выполнение регистрации DI-компонентов, а их у нас 634, равняется 521 мс.

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

Если есть вопросы или идеи, как улучшить этот механизм — пишите в комментариях. 

  • Ссылка на ComponentStorage в Github.

  • Ссылка на весь pet-проект

© Habrahabr.ru