[Перевод] Многомодульность в Android и Dagger: пошаговый пример

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

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

От автора: прежде, чем мы начнем, стоит отметить, что статья будет длинной. Вместе мы шаг за шагом найдем различные решения возникающих проблем и рассмотрим требования к продукту. Целью было поэтапно показать, как с помощью Dagger внедрять зависимости в многомодульном проекте. Для каждого шага будет дана ссылка на коммит, в котором сделаны эти изменения, кроме того, покажем некоторые фрагменты кода.

Перед прочтением статьи необходимо хотя бы на начальном уровне знать:

  • kotlin

  • что такое component в dagger

  • что такое module в dagger

  • что такое scope в dagger

Примечание: о своей работе с Kotlin мы писали здесь, а по Dagger рекомендуем серию статей — части 1, 2, 3

Также мы будем следовать рекомендациям Google по использованию Dagger (они будут процитированы ниже). Если вам лень читать, можно сразу посмотреть репозиторий GitHub, однако, не упустите самое важное.

Наконец, последний комментарий!

  • О модулях Dagger мы будем говорить Module, а о модулях Android — просто «модули» (без выделения). 

  • Как правило, когда вы видите подобное выделение, мы говорим о классах Dagger.

Итак, начнем!

image-loader.svg

Проект

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

На первом этапе мы создадим модуль для вычисления суммы, и это кажется совсем простым, не так ли?

Представим, что мы любим хайп и хотим сделать приложение модульным. При этом будет два модуля:

  • Модуль App — основной модуль нашего приложения, использующий другие модули.

  • Модуль Calculator, реализующий математические вычисления (на данном этапе это просто сумма).

Поехали дальше!

Создание модуля Calculator

Модуль App будет образован автоматически при создании нового проекта в Android Studio. 

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

Итак, начнем:

  • Мы создаем Activity для ввода данных.

  • Мы создаем Use Case, отвечающий за сложение двух чисел.

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

Use Case и Activity будут выглядеть следующим образом:

lateinit var sumUseCase: SumUseCase

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    sumUseCase = SumUseCase()
}

CalculatorActivity (calculator module)

class SumUseCase {

    fun execute(firstNumber: Int, secondNumber: Int): Int {
        return firstNumber + secondNumber
    }

}

SumUseCase (calculator module)

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

  • Есть некоторые изменения в UI и обработке ввода.

  • Добавлен код для взаимодействия между Activity и Use Case.

  • Добавлен модуль Calculator в зависимости модуля App, чтобы мы могли использовать Activity из MainActivity (рассмотрим это позже).

  • Добавлена кнопка на MainActivity, при нажатии которой запустится CalculatorActivity (позже мы разберем и это).

Эти фрагменты кода не описаны в статье. Скорее всего, вы уже знакомы с созданием Activity, OnClickListener и запуском Activity.

Изменения можно увидеть в этом коммите

Добавление Dagger2 в модуль Calculator

Добавим зависимости:

apply plugin: 'kotlin-kapt'

...

dependencies {
  ...
  
  api "com.google.dagger:dagger:${dagger.version}" //I'm using 2.27
  kapt "com.google.dagger:dagger-compiler:${dagger.version}" //I'm using 2.27
}

build.gradle (calculator module)

Перейдем к внедрению. Google рекомендует:

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

Итак, нам понадобится Activity для пользовательского интерфейса, и нам понадобится Component для внедрения зависимостей в нее, поэтому мы создадим Component и Module:

@Singleton
@Component(modules = [CalculatorModule::class])
interface CalculatorComponent {

    fun inject(calculatorActivity: CalculatorActivity)

}

CalculatorComponent (calculator module)

@Module
class CalculatorModule {

    @Provides
    @Singleton
    fun sumUseCase(): SumUseCase = SumUseCase()

}

CalculatorModule (calculator module)

Затем используем Dagger для внедрения UseCase в Activity:

class CalculatorActivity : AppCompatActivity() {
 
  @Inject lateinit var sumUseCase: SumUseCase
  
  ...
  
  override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        ...
        DaggerCalculatorComponent.builder().build()
            .inject(this)
        ...
   }
  
  ...
}

CalculatorActivity (calculator module)

Вот и все! Мы добавили Dagger в модуль Calculator, чтобы внедрить наши зависимости.

Изменения можно увидеть в этом коммите

Добавление валидации данных в Use Case

В требованиях к Use Case говорится, что для сложения двух чисел оба из них должны быть положительными, больше нуля. Если это условие не выполняется, мы должны сообщить пользователю об ошибке.

Для результатов проверки (успешно/ошибка) в Use Case я буду использовать Sealed Class (не будем углубляться, т.к. это не основная задача статьи). Теперь код будет выглядеть так:

class SumUseCase {

    fun execute(firstNumber: Int, secondNumber: Int): Result {
        return if (firstNumber > 0 && secondNumber > 0) {
            Result.Success(firstNumber + secondNumber)
        } else {
            Result.Failure("Both numbers must be greater than 0!!")
        }
    }

    sealed class Result {
        class Success(val result: Int) : Result()
        class Failure(val message: String) : Result()
    }

}

SumUseCase (calculator module)

Отображение результата в Activity:

  • Успешная проверка (Result.Success) — покажем пользователю сумму введенных чисел.

  • Ошибка валидации (Result.Failure) — пользователь увидит сообщение с описанием ошибки (не будем разбирать обработку результатов, вы можете посмотреть её в коде).

Изменения можно увидеть в этом коммите

Захардкоженные строки — это не есть хорошо

У нас первая проблема! Строка, которую я возвращаю, чтобы показать пользователю ошибку при проверке введенных чисел, захардкожена, но мы знаем, что это не очень хорошая практика и что строки нужно размещать в strings.xml.

Для доступа к строкам из strings.xml нужен Context, но у Use Case нет доступа к Context.

Чтобы решить эту проблему, я создаю StringsProvider, который может получить строку по ее id:

class StringsProvider(
    val application: Application
) {

    fun getString(@StringRes id: Int): String = application.getString(id)

}

StringsProvider (calculator module)

Почему этот класс использует Application, а не Context? Потому что таким способом я могу использовать StringsProvider как Singleton, и я исключаю возможность того, что этот компонент будет зависеть от Activity или Fragment.

Теперь добавим зависимость StringsProvider в Use Case:

class SumUseCase(
    val stringsProvider: StringsProvider
) {

    fun execute(firstNumber: Int, secondNumber: Int): Result {
        ...
            Result.Failure(stringsProvider.getString(R.string.both_numbers_must_be_positive))
        ...
    }
    
    ...
}

SumUseCase (calculator module)

Конечно, для сборки проекта мне нужно изменить Module, который предоставляет Use Case. Без проблем! С Dagger это будет просто:

  • Сначала я добавляю конструктор для CalculatorModule, через который внедряется объект Application.

  • Затем я добавляю в Module два Provides-метода, один будет предоставлять экземпляр Application, а другой — провайдить StringsProvider.

  • Я меняю метод, предоставляющий экземпляр SumUseCase, т.к. теперь он получает на вход StringProvider.

  • Наконец, мне нужно изменить способ получения CalculatorComponent, потому что теперь мне нужно сообщить Dagger, как создать CalculatorModule.

После внесения изменений код будет выглядеть так:

@Module
class CalculatorModule(
    val application: Application
) {

    @Provides
    @Singleton
    fun application(): Application = application

    @Provides
    @Singleton
    fun stringsProvider(
        application: Application
    ): StringsProvider = StringsProvider(application)

    @Provides
    @Singleton
    fun sumUseCase(
        stringsProvider: StringsProvider
    ): SumUseCase = SumUseCase(stringsProvider)

}

CalculatorModule (calculator module)

class CalculatorActivity : AppCompatActivity() {
  ... 
  
  override fun onCreate(savedInstanceState: Bundle?) {
    ...
    
    DaggerCalculatorComponent.builder()
            .calculatorModule(CalculatorModule(application))
            .build()
            .inject(this)
    
    ...
  }
  
  ...
}

CalculatorActivity (calculator module)

Изменения можно увидеть в этом коммите

Почему не CoreModule?

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

Итак, можем ли мы создать CoreModule и поместить в него StringsProvider? Таким образом, мы избегаем наличия у каждого модуля своего StringsProvider, решая несколько проблем:

  • Не будет Module для каждого модуля нашего приложения, который дублирует получение StringsProvider.

  • Доступ к strings.xml будет централизованным. Теперь у нас не будет StringsProvider во всех частях приложения, и, возможно, мы решим проблему с Context-ом (например, связывание Activities и Fragments).

Давайте создадим CoreModule и переместим туда StringsProvider (так мы удалим его из модуля Calculator).

Это создает новую проблему: модуль Calculator больше не может получить ссылку на StringsProvider, поэтому Dagger выдаст ошибку при попытке собрать проект.

Как мы решаем эту проблему? Воспользуемся рекомендациями Google:

Для модулей Gradle, которые предназначены для использования в качестве утилит или хэлперов и не нуждаются в построении графа (вот почему вам понадобится компонент Dagger), создайте и предоставьте общедоступные модули Dagger с методами, отмеченными аннотациями @Provide и @Bind, предоставляющими экземпляры классов, зависимости которых не внедряются через конструктор.

Итак, нам нужен только CoreModule, чтобы предоставить нам StringsProvider, поэтому здесь мы просто создаем Module, а не Component.

@Module
class CoreModule(
    val application: Application
) {

    @Provides
    @Singleton
    fun stringsProvider() = StringsProvider(application)

}

CoreModule (core module)

Примечание: мы должны добавить в Dagger CoreModule, как мы ранее сделали это с другими модулями.

Теперь, чтобы получить StringsProvider, мы должны использовать CoreModule внутри CalculatorComponent. Итак, делаем следующее:

  • Добавляем в модуль Calculator зависимость от CoreModule.

  • Добавляем CoreModule в CalculatorComponent.

  • Изменяем CalculatorModule, чтобы его можно было правильно интегрировать с CoreModule.

  • Изменяем билдер DaggerCalculatorComponent.

@Module
class CalculatorModule {

    @Provides
    @Singleton
    fun sumUseCase(
        stringsProvider: StringsProvider
    ): SumUseCase = SumUseCase(stringsProvider)

}

CalculatorModule (calculator module)

@Singleton
@Component(
    modules = [
        CalculatorModule::class,
        CoreModule::class
    ]
)
interface CalculatorComponent {

    fun inject(calculatorActivity: CalculatorActivity)

}

CalculatorComponent (calculator module)

class CalculatorActivity : AppCompatActivity() {
  ...
    
  override fun onCreate(savedInstanceState: Bundle?) {
      ...
      DaggerCalculatorComponent.builder()
          .coreModule(CoreModule(application))
          .build()
          .inject(this)
      ...
  }
  
  ...
}

CalculatorActivity (calculator module)

Превосходно! У нас есть многомодульный проект с использованием Dagger.

Изменения можно увидеть в этом коммите

Дополнительное требование: подписки пользователей

Примечание от автора: в коде допущена опечатка в слове Subscriptions (вместо этого Suscriptions), приношу извинения за неудобство.

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

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

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

Давайте создадим класс AppSuscription, в котором хранится информация о подписках пользователя. Затем мы будем использовать класс AppSuscription в MainActivity (модуль App) просто чтобы показать текущую подписку пользователя.

Как мы знаем, в будущем у нас появится модуль для работы с подпиской. Значит, у нас наверняка будет SuscriptionModule, верно? Что ж, давайте создадим его. Подождите … если это AppSuscription находится в CoreModule, почему мы не можем использовать CoreModule? На это есть веская причина: если сейчас мы привяжем подписки к CoreModule, позже будет сложнее разделить их (возможно, мы сломаем некоторые зависимости CoreModule).

Тем не менее, давайте создадим SuscriptionModule:

@Module
class SuscriptionModule {

    @Provides
    @Singleton
    fun appSuscription(
        stringsProvider: StringsProvider
    ): AppSuscription = AppSuscription(stringsProvider)

}

SuscriptionModule (core module)

После того, как мы создали Module, мы собираемся использовать AppSuscription. Как я уже сказал, мы собираемся использовать его в MainActivity, поэтому нам нужно:

  • Добавить Dagger в зависимости модуля App.

  • Добавить CoreModule в зависимости модуля App (как я отметил ранее, в коде мы не будем это рассматривать).

После того, как эти зависимости настроены, мы должны создать Component для внедрения зависимостей в MainActivity. Component будет использовать SuscriptionModule, поэтому мы можем внедрить AppSuscription:

@Singleton
@Component(
    modules = [
        SuscriptionModule::class
    ]
)
interface ApplicationComponent {

    fun inject(activity: MainActivity)
    
}

ApplicationComponent (app module)

После создания Component мы можем внедрить AppSuscription в MainActivity:

class MainActivity : AppCompatActivity() {

  @Inject lateinit var appSuscription: AppSuscription
  ...
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    DaggerApplicationComponent.builder()
      .build()
      .inject(this)
    
    ...
  }

  ...
}

MainActivity (app module)

Готово! Давайте попробуем собрать проект и посмотреть, что получилось… и:

com.example.core.resource.StringsProvider cannot be provided without an @Inject constructor or an @Provides-annotated method.
public abstract interface ApplicationComponent {
^
com.example.core.resource.StringsProvider is injected at
com.example.core.di.SuscriptionModule.appSuscription(arg0)
com.example.core.modes.AppSuscription is injected at
com.example.multimodule.MainActivity.appSuscription
com.example.multimodule.MainActivity is injected at
com.example.multimodule.di.ApplicationComponent.inject(com.example.multimodule.MainActivity)

Что? Если я использую только AppSuscription, зачем мне StringsProvider? Хорошо… давайте слегка подправим, чтобы компиляция прошла успешно. Мы добавляем CoreModule, который предоставляет StringsProvider:

@Singleton
@Component(
	modules = [
    	CoreModule::class,
    	SuscriptionModule::class
	]
)
interface ApplicationComponent {

	fun inject(activity: MainActivity)

}

ApplicationComponent (app module)

Теперь, поскольку мы добавили CoreModule в ApplicationComponent, мы должны предоставить Dagger экземпляр класса CoreModule для сборки ApplicationComponent, поэтому мы делаем следующее:

class MainActivity : AppCompatActivity() {

  @Inject lateinit var appSuscription: AppSuscription
  ...
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    DaggerApplicationComponent.builder()
      .coreModule(CoreModule(application))
      .build()
      .inject(this)
    
    ...
  }

  ...
}

MainActivity (app module)

Теперь работает отлично!

Изменения можно увидеть в этом коммите

Хватит терпеть плохой код

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

Однако, пока наш код — с «запашком». Возможно, некоторые из вас уже заметили, что здесь есть две большие проблемы.

Первая проблема — знание всех Module приложения

Первая проблема связана с решением, к которому мы пришли выше. Класс AppSuscription нельзя было внедрить, используя только SuscriptionModule, потому что ей также требовался CoreModule (поскольку ему еще нужен StringsProvider).

В этом случае мы быстро решили проблему, но только потому, что я был единственным разработчиком, который писал код, и я знаю, какие Module предоставляют классы, которые нужны Dagger для успешной компиляции. Но что, если:

  • Я работаю с командой, которая может не знать, что CoreModule предоставляет StringsProvider. Из-за этого они, возможно, создадут новый метод @Provides в каком-то существующем Module (или, что еще хуже, они создадут новый Module). Эти действия приведут к тому, что разные модули будут иметь разные StringsProvider, что грозит трудностями с компиляцией и, конечно же, вызовет проблемы с поддержкой проекта.

  • В моем приложении будет появляться больше модулей: если мое приложение начнет расти, появятся новые модули. Во всех этих модулях мне и команде придется вставлять «костыли», чтобы решить проблемы компиляции. Это не выход.

Вторая проблема — дублированные Module в приложении

Возможно, некоторые из вас заметили, что Module в коде дублируются.

DaggerCalculatorComponent.builder()
  .coreModule(CoreModule(application))
  .build()
  .inject(this)

DaggerCalculatorComponent (calculator module)

DaggerApplicationComponent.builder()
  .coreModule(CoreModule(application))
  .build()
  .inject(this)

DaggerApplicationComponent (app module)

Эта ситуация противоречит идее StringsProvider как централизованного источника данных. При таком подходе у каждого модуля есть свой экземпляр StringsProvider.

В случае StringsProvider это, возможно, не проблема. Но давайте представим, что речь идет о Module, отвечающем за статус авторизации пользователя в приложении. Хотим ли мы иметь несколько экземпляров этого Module в приложении?

Мы должны решить эти проблемы

Мы должны решить обе проблемы. Вот некоторые возможные решения:

Первое возможное решение

Самый простой выход: оставить все как есть. Всегда есть альтернатива — не трогать проблему и не брать на себя ее решение. В моем случае, и я надеюсь, что ваш случай такой же, мы не будем рассматривать этот вариант.

Второе возможное решение

Может быть, у нас будет централизованное хранилище, куда мы будем помещать разные Module, а затем получать их по мере необходимости. Но у этого решения есть как минимум три проблемы:

  • Это не решает проблему взаимозависимостей Module, которую мы рассматривали ранее неявное требование @Provide-методов в других Module).

  • Когда мы создаем и как долго храним Module? Как мы сообщаем CoreModule, что он должен создать и закешировать свои Module в централизованном хранилище?

  • Некоторые Module нелегко создать. Например, CoreModule нуждается в Application и не имеетлегкого доступа к этому объекту.

Третье возможное решение

Вероятно, мы сможем создать Module с @Provides-методами, которые предоставляют другие Component-ы?

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

Так что это не вариант, но … вероятно, мы сможем создать Module с методами @Provides, которые предоставляют другие Module? Честно говоря, я не пробовал. Я думаю, что у него будут проблемы со скоупами и зависимостями между Component, что является достаточно веской причиной не пробовать это.

Так что же нам делать?

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

Проблемы зависимости Module друг от друга

Как я ранее упоминал, мы вообще не хотим знать, какие именно зависимости нужны Module. По крайней мере не в таких подробностях, как ошибки с отсутствующими @Provides-методами.

У аннотации @Module есть атрибут, указывающий, какие Module предоставляют зависимости для него. Этот атрибут includes.

С помощью этого атрибута мы можем в нашем приложении:

  • Добавить зависимость CoreModule в SuscriptionModule, чтобы использовать предоставляемые им зависимости.

  • Убрать использование CoreModule в ApplicationComponent.

@Module(includes = [CoreModule::class])
class SuscriptionModule {
  ...
}

SuscriptionModule (core module)

@Singleton
@Component(
  modules = [
    SuscriptionModule::class
  ]
)
interface ApplicationComponent {

  fun inject(activity: MainActivity)

}

ApplicationComponent (app module)

Внимание! Некоторые проблемы еще не решены: Dagger по-прежнему спрашивает, какую зависимость CoreModule он должен использовать.

DaggerApplicationComponent.builder()
  .coreModule(CoreModule(application))
  .build()
  .inject(this)

DaggerApplicationComponent (app module)

Итак, мы не нашли решения всей проблемы. Но, в отличие от предыдущей ситуации, мы можем увидеть, что внутри SuscriptionModule уже используется CoreModule.

И, как бонус, у нас есть ошибка еще лучше. Ранее ошибка заключалась в том, что мы забывали предоставить StringsProvider. На этот раз при таком подходе в рантайме мы получаем ошибку:

Caused by: java.lang.IllegalStateException: com.example.core.di.CoreModule must be set

Намного лучше, правда? :-D

Изменения можно увидеть в этом коммите

Хватит дублировать Module

Я должен признать, что описанное решение не идеально, но, как мы знаем, идеальное — враг возможного.

Все решения, которые я нашел, пока искал и читал документацию, подразумевают связь между всеми модулями, имеющими Component, и объектом Application. Скоро мы это увидим.

Мы собираемся использовать подход, рекомендуемый Google.

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

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

Хватит теории и объяснений, давайте кодить!

Каждый модуль с Component, который будет внедрять зависимости, предоставит интерфейс с методом, возвращающим необходимый ему Component.

Например, в модуле Calculator нашим Component является CalculatorComponent, поэтому мы собираемся создать интерфейс CalculatorComponentProvider:

interface CalculatorComponentProvider {

  fun getCalculatorComponent(): CalculatorComponent

}

CalculatorComponentProvider (calculator module)

Мы собираемся использовать этот интерфейс для получения CalculatorComponent. Кто будет имплементить CalculatorComponentProvider? Единственный объект, к которому мы можем получить доступ из Activity/Fragment, и тот же самый, что и в приложении в целом: Application.

Какой Application? Тот, который я создаю сейчас! (да, его до сих пор не было).

class CustomApplication : Application(),
    CalculatorComponentProvider {

    override fun getCalculatorComponent(): CalculatorComponent {
        return DaggerCalculatorComponent.builder()
            .coreModule(getCoreModule())
            .build()
    }

    private fun getCoreModule(): CoreModule = CoreModule(this)

}

CustomApplication (app module)

При таком подходе мы можем получить CalculatorComponent, используя ранее созданный интерфейс:

class CalculatorActivity : AppCompatActivity() {
  ...

  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      ...
    
      (application as CalculatorComponentProvider)
        .getCalculatorComponent()
        .inject(this)
    
      ...
  }
  
  ...
}

CalculatorActivity (calculator module)

Сделаем то же самое для MainActivity. Мы создаем ApplicationComponentProvider, затем в Application реализуем этот интерфейс и, наконец, получаем ApplicationComponent в MainActivity:

interface ApplicationComponentProvider {

    fun getApplicationComponent(): ApplicationComponent

}

ApplicationComponentProvider (app module)

class CustomApplication : Application(),
  CalculatorComponentProvider,
  ApplicationComponentProvider {
  ...

  override fun getApplicationComponent(): ApplicationComponent {
      return DaggerApplicationComponent.builder()
          .coreModule(getCoreModule())
          .build()
  }

  private fun getCoreModule(): CoreModule = CoreModule(this)

}

CustomApplication (app module)

class MainActivity : AppCompatActivity() {
  ...
  
  override fun onCreate(savedInstanceState: Bundle?) {
    ...
    
    (application as ApplicationComponentProvider)
            .getApplicationComponent()
            .inject(this)
    
    ...
  }
  
  ...
}

MainActivity (app module)

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

Есть много способов решить эту проблему. Я выберу самый простой способ и сохраню CoreModule в CustomApplication. В любом случае, я настоятельно рекомендую вам выбрать другое решение, потому что данное не масштабируется. Вы можете использовать Map для хранения Modules или создать новый класс … есть множество вариантов.

В итоге код будет выглядеть так:

class CustomApplication : Application(),
  CalculatorComponentProvider,
  ApplicationComponentProvider {

  private val coreModule: CoreModule by lazy {
    CoreModule(this)
  }

  override fun getCalculatorComponent(): CalculatorComponent {
    return DaggerCalculatorComponent.builder()
      .coreModule(coreModule)
      .build()
  }

  override fun getApplicationComponent(): ApplicationComponent {
    return DaggerApplicationComponent.builder()
      .coreModule(coreModule)
      .build()
  }

}

CustomApplication (app module)

Изменения можно увидеть в этом коммите

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

У каждой команды может быть свой модуль в отдельном проекте на основе App-модуля, как у нас. У этого модуля возможно наличие собственного класса Application, который способен реализовывать наш интерфейс ComponentProvider, возвращая необходимый Component. Итак, команды не зависят от работы других команд.

Подводим итог: как мы решили проблемы?

  • Зависимости Module от других Module будут указаны с помощью @Module(includes = [...]).

  • Каждый модуль, у которого есть Component, также будет иметь интерфейс ComponentProvider с методом, который возвращает этот Component. Такой интерфейс должен быть реализован нашим кастомным Application.

  • Если модуль имеет более одного Component, мы можем использовать один и тот же интерфейс ComponentProvider для всех. Единственное, что мы должны сделать, это … изменить имя интерфейса.

  • В каждой Activity, где мы хотим использовать Component, нам просто нужно привести объект Application к типу ComponentProvider и получить нужный Component.

Важное замечание: Component-ы с разными Scope

В подавляющем большинстве реализаций Dagger мы можем видеть Component с разными Scopes.

Например, у нас может быть Component,  привязанный к скоупу Activity. А модуль, входящий в этот Component, может в конструкторе получать ссылку на Activity, которая ограничивает его скоуп. В этом случае мы не можем использовать интерфейс ComponentProvider для получения Component, потому что Application не имеет ссылки на Activity.

В таком случае нам необходим родительскийComponent, который создается в Application

А Component со скоупами, привязанными к Activity, можно сделать его Subcomponent.

Итак, мы можем сделать родительскийComponent с помощью ComponentProvider, а через него уже получать Subcomponent со скоупом Activity

Вывод

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

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

© Habrahabr.ru