Всем привет! Я Денис Загаевский из Android-разработки Яндекс.Карт. Если вы развиваете многомодульное приложение или хотите разбить на части пока ещё одномодульное, этот туториал для вас.


Под катом расскажу, как удобно разбить приложение на модули, зачем это нужно и как потом приготовить в нём DI (dependency injection). Кто-то мог слышать мой доклад на Mobius 2021 Piter или в Школе мобильной разработки, а для всех остальных я написал эту статью.


Смотреть доклады

На Mobius:



В ШРИ:



Местами буду ссылаться на опыт Яндекс.Карт. Кстати, рабочий пример нашего подхода есть на GitHub.




Модуляризация


Зачем разбивать приложение на модули?


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


Ускорение разработки


За счёт использования sample-apps, о которых я расскажу позже, получается ускорить разработку фич: больше не нужно постоянно собирать тяжёлое приложение. Также становится легче распараллелить разработку: разработчики будут меньше конфликтовать между собой.


Поставка фич в другие приложения


В прошлом году наша команда начала предоставлять большие фичи, такие как поиск или карточка геообъекта в приложение Яндекс.Навигатор. То, что эти фичи уже были выделены в отдельные feature-модули, здорово облегчило нам жизнь. Возможно, когда-то это поможет и вам (если ещё не).


Уменьшение связанности


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


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


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



Виды модулей


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



App modules


По названию легко догадаться, что эти модули представляют из себя приложение. Они собираются в APK и могут быть запущены на устройстве. В нашем случае это как большие приложения (Карты или Навигатор), так и маленькие лёгкие sample-apps.



Sample-apps решают сразу несколько задач. Например, помогают разработчику фичи тестировать её непосредственно на устройстве или эмуляторе, не собирая всё большое приложение целиком. Также sample-app наглядно демонстрирует, как фича должна интегрироваться в приложение.


Ещё одно преимущество sample-apps раскрывается при разработке мультиплатформенных модулей с Kotlin Multiplatform. Общие части приложения все члены команды пишут на Kotlin, а вот универсальных разработчиков, способных грамотно и быстро сверстать UI и для iOS, и для Android, пока мало. Поэтому мы рекомендуем, чтобы разработчик, который пишет общую часть, реализовывал полную интеграцию фичи на «своей» платформе, а также минимальный пример на «чужой». Это позволяет выявить потенциальные недостатки кроссплатформенного API ещё до передачи его коллеге.


Помимо основных приложений и sample-apps, у нас есть вспомогательные мини-приложения. Например, приложение для открытия всевозможных deep-линков (intents) в основном приложении. Так намного удобнее их тестировать. Возьмите на заметку, если у вас тоже много deep-линков.


App-модули обычно содержат DI в произвольном виде, в этой статье не будем останавливаться на подробностях. Мы используем Dagger Android (точнее, наш самописный аналог, поскольку вместо фрагментов у нас — Conductor), но это может быть просто Dagger Submodules или Hilt. Выберите ваш любимый DI-фреймворк или не используйте их совсем.


Если вы работаете с Kotlin Multiplatform, можно присмотреться к библиотеке Kinzhal. Это «Dagger на минималках» для мультиплатформы.



Feature modules


Feature modules — это законченные фичи. Они подключаются к app-модулям и реализуют основной функционал приложения. Для каждого из feature-модулей тоже полезно завести sample-app, в котором показано, как работает фича и как реализовать необходимые модулю внешние зависимости.



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



В API модуля при этом содержится один публичный корневой фрагмент: именно его будет создавать app-модуль, который использует фичу. А остальные экраны — это дочерние фрагменты, они internal в модуле и никогда не видны коду, который их использует. Если фича содержит один экран, достаточно одного корневого фрагмента, нет необходимости делать дополнительный.


Отмечу также типичную верхнеуровневую структуру пакетов, которой мы придерживаемся. На верхнем уровне в каждом feature-модуле всего два пакета — api и internal. Их названия говорят сами за себя. В api лежит публичный интерфейс модуля — всё то, что можно и нужно использовать клиенту. В internal лежит реализация фичи, все внутренние классы и функции этого пакета обязаны быть помечены модификатором internal. Очень удобно и одновременно просто. Всем рекомендую.


Внутри feature-модуля может и должен быть свой DI-граф. Hilt использовать не получится, потому что библиотечный DI не должен торчать из модуля, а вот Dagger Android — вполне. А самое интересное будет дальше — как организовать DI между app- и feature-модулями.



Core modules


Третий и последний вид модулей — core-модули. Они содержат вспомогательный код, общий для разных фич. Например, полезные экстеншны, общие вьюхи или реализацию базовой архитектуры. Любой утильный код. Также app-модули, конечно, могут ссылаться на core-модули, но для упрощения схемы я не стал изображать это на ней.


Внутри и снаружи core-модулей не должно быть DI ни в каком виде, он там не нужен. Например, не должны торчать какие-либо dagger-module или inject-contructors.



Отличить core-модуль от feature-модуля не всегда просто. Здесь могут помочь следующие признаки:


  • Если модуль целиком состоит из своего API — это core-модуль;
  • Если модуль имеет публичный интерфейс и приватную реализацию, то это, скорее всего, feature-модуль;
  • Если модулю не нужны внешние зависимости — это core-модуль;
  • Если модуль содержит UI в виде конкретных экранов — это feature-модуль.

Ключевое отличие core-модулей от feature-: core-модули имеют право зависеть друг от друга. А feature-модулям предпочтительнее друг от друга не зависеть. Далее по тексту объясню, почему так, а пока давайте попробуем сформулировать, чего мы хотим от межмодульного DI.



Требования к межмодульному DI


Уметь единообразно работать с зависимостями



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


Не зависеть от конкретного DI-фреймворка


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


Также хочется, чтобы если какой-то клиент не использует Dagger, то он про Dagger ничего из наших feature-модулей бы не знал. Это значит, что API модуля не должен показывать клиенту Dagger или любой другой DI-фреймворк. Весь подход, который описан далее, построен так, чтобы не зависеть от конкретного DI-фреймворка на уровне взаимодействия между модулями, поэтому, если вы будете его использовать, то тоже получите эти преимущества.


В отличие от core-модулей, в которых DI вообще нет, в internal-части feature-модуля может и даже должен содержаться свой DI-граф.


Зависимости модуля должны быть легко определимы



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


Итак, мы сформулировали три основных пожелания к межмодульному DI. Теперь покажу, как это реализовать, на примере приложения Яндекс.Карт. Простая и более-менее очевидная часть закончилась, впереди — код, мясо и хардкорные подробности.



Как feature-модуль получает свои зависимости?


И что вообще такое эти зависимости? Как feature-модуль их декларирует? Чтобы ответить на эти вопросы, давайте вспомним, как выглядит наш типичный feature-модуль:



Это два пакета: api и internal. В api лежит корневой Fragment, и описание зависимостей. В internal находится реализация фичи.


В коде это будет выглядеть примерно так:


class SearchFragment: Fragment() {
    //…
}

interface SearchDeps {
    val logger: Logger
    val searchManager: SearchManager
    // Something else… 
}

То есть любая зависимость — просто публичный интерфейс, имеющий по property на каждый объект, который нужен для работы фичи (в примере выше — логгер и некий менеджер). Что они такое, нам сейчас не важно. Главное, что именно они требуются фрагменту для работы.


Проблема заключается в том, что во фрагменте SearchFragment необходимо получить экземпляр интерфейса SearchDeps. Будь это не фрагмент, а какой-то класс, которым мы полностью управляем, можно было бы просто передать зависимости в конструктор. Но из-за восстановления состояния каждый фрагмент обязан иметь пустой конструктор, и уметь восстанавливаться из сохраненного в Bundle состояния. Сохранить SearchDeps в Bundle в общем случае нельзя, потому что там могут быть (и обычно действительно есть) какие-то сложные несериализуемые объекты.


Вспомним, что природа фрагментов иерархична. SearchFragment добавлен куда-то в родительский фрагмент или в Activity. То есть, в общем случае, мы можем считать, что у него есть N родительских фрагментов, первый из которых добавлен в Activity, а последний, самый вложенный, содержит наш feature-фрагмент. N может быть равно нулю, и тогда это значит, что наш фрагмент добавлен непосредственно в Activity.


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


Соответственно, можно написать следующие extensions:


fun Fragment.findDependencies(???):???{
    return parents.find { ??? }
}

//returns [ParentN..Parent1, Activity, App]
val Fragment.parents: Iterable<???>
    get() = /* trivial implementation */

Первый из них, findDependencies, будет использоваться feature-фрагментами для поиска своих зависимостей. Реализация этой функции основана на extension-property parents, которая возвращает родителей в порядке от ближайшего к дальнему. Проблема в том, что ближайший общий предок в иерархии наследования Fragment, Activity и Application — это Any. Возвращать его, конечно, не хочется, поэтому введём специальный интерфейс, который будут реализовывать родители, желающие предоставить зависимости:


interface HasDependencies

//returns [ParentN..Parent1, Activity, App]
val Fragment.parents: Iterable<HasDependencies>
    get() = /* trivial implementation */

А что возвращает функция поиска kotlin fun Fragment.findDependencies(???):????


Конечно, мы не хотим писать вот этот инфраструктурный код каждый раз. Поэтому он должен находиться в core-модуле, который будет подключаться ко всем нашим feature-модулям примерно так:



Функция поиска findDependencies находится в модуле common. А получить мы хотим наследника интерфейса Dependencies, каждый из которых объявляется в своём feature-модуле. Поэтому Dependencies в каждом feature-модуле должны наследоваться от одного интерфейса, который находится в common.


interface Dependencies

fun <D: Dependencies> Fragment.findDependencies(???): D {
    return parents.find { ??? }
}

Это так называемый маркерный интерфейс, который уточняет нам, что может вернуть функция findComponentDependencies. Использовать этот интерфейс нужно так: в каждом feature-модуле наследовать интерфейс зависимостей от него.



То же самое в виде кода
//:search
interface SearchDeps: Dependencies {
    val searchManager: SearchManager
    // Something else… 
}

//:routes
interface RoutesDeps: Dependencies {
    val locationProvider: LocationProvider
    val placesRepository: PlacesRepository
    // Something else… 
}

Теперь давайте разберёмся, что функция findDependencies принимает на вход. При её вызове нам нужно указать как минимум класс желаемых зависимостей, потому что она возвращает дженерик-тип:


class SearchFragment: Fragment() {
    //...
    private fun performInjection() {
        findDependencies<SearchDeps>(???)
    }
 }

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


findDependencies<SearchDeps>(SearchDeps::class)

Или, если использовать reified generic, можно спрятать получение класса внутрь самой функции:


inline fun <reified D: Dependencies> Fragment.findDependencies() {
    val clazz = D::class
    return parents.find { /* find Dependencies by Class<D> */ }
}

//returns [ParentN..Parent1, Activity, App]
val Fragment.parents: Iterable<HasDependencies>
    get() = /* trivial implementation */

Теперь осталось только написать имплементацию, которая, как сказано в этом коде, find Dependencies by Class<D> в интерфейсе HasDependencies. Этот интерфейс пока пустой. Давайте его наполним чем-то, что дословно «хранит зависимости (Dependencies) по ключу Dependencies::class». Заодно допишем реализацию функции:


typealias DepsMap = Map<Class<out Dependencies>, Dependencies>

interface HasDependencies {
    val depsMap: DepsMap
}

inline fun <reified D: Dependencies> Fragment.findDependencies(): D {
   val dependenciesClass = D::class.java
   return parents
     .mapNotNull { it.depsMap[dependenciesClass] }
     .firstOrNull() as D?
     ?: throw IllegalStateException(“No $dependenciesClass in $parents")
}

В функции findDependencies мы ищем родителей фрагмента, которые реализуют интерфейс HasDependencies. Из каждого такого родителя берём словарь depsMap, а из каждого такого словаря по классу D::class.java достаём Dependencies.


Из всех найденных таким иерархическим поиском зависимостей берём первые, то есть ближайшие. А если ничего не нашли, то кидаем исключение с текстовым описанием, тут ничего не поделаешь, такая ситуация — это ошибка, из которой нельзя восстановиться. Сейчас напишу только два слова: «Fail fast», но ниже вернусь к этому подробнее.


На этом инфраструктурный код по поиску зависимостей закончился. В каждой фиче корневой фрагмент использует функцию findDependencies одинаково.


Пример использования
//:search

interface SearchDeps: Dependencies {
    val searchManager: SearchManager
    // Something else… 
}

class SearchFragment: Fragment() {
    //...
    private fun performInjection() {
        val deps: SearchDeps = findDependencies<SearchDeps>()
        // Do something with deps
    }
 }

В итоге мы выполняем все описанные выше требования. Фичи получают зависимости единообразно, нет DI-фреймворков в API, а из интерфейса зависимостей прекрасно видно, какие зависимости хочет получить на вход модуль. Кроме того, каждый feature-фрагмент получает желаемые зависимости вызовом всего лишь одной строки кода.



Предоставление зависимостей feature-модулям


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


Рассмотрим два случая — простой, в котором можно обойтись без DI-фреймоворков, и сложный, где лучше всё же использовать какой-нибудь (рассматривать будем на примере Dagger 2).



Простой случай


Этот случай можно встретить в sample-app простого feature-модуля, когда создать словарь зависимостей руками быстрее и проще, чем возиться с Dagger.


// :search
interface SearchDeps: Dependencies {
    val logger: Logger
}

Итак, есть интерфейс SearchDeps, который мы хотим предоставить. Предположим, что он очень простой, и всё, что хочет SearchFragment для своей работы — это некий логгер.


Для этого реализуем интерфейс HasDependencies в Activity нашего sample-app:


// :search:sample
class SampleActivity: Activity(), HasDependencies {
    private class SearchDepsImpl: SearchDeps {
        override val logger = LoggerImpl()
    }

    override val depsMap: DepsMap = mapOf(
        SearchDeps::class.java to SearchDepsImpl()
    )

    override fun onCreate(…) {
        commit(SearchFragment())
    }
}

depsMap, property интерфейса HasDependencies, содержит словарь: класс интерфейса зависимости к объекту, реализующему этот интерфейс. LoggerImpl — это просто некая реализация логгера.


Затем мы просто коммитим feature-фрагмент, который, пользуясь описанным выше механизмом поиска зависимостей, находит SearchDepsImpl и получает из него логгер.


На этом простой пример закончен. Примерно вот так можно жить в sample-app простых feature-модулей.



Сложный случай


Теперь рассмотрим более сложный пример, обычно встречающийся в крупных приложениях. Вот реальный список зависимостей модуля поиска в приложении Яндекс.Карты. Тут не одна зависимость, их почти целый экран. Это сложный модуль, многое требует на вход.



Кроме того, этот модуль шарится между двумя приложениями и используется в sample-app.


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


//:yandexmaps
class MainActivity: Activity(), HasDependencies {
    override val depsMap: DepsMap = mapOf(
            SearchDeps::class.java to object: SearchDeps { … },
            RoutesDeps::class.java to object: RoutesDeps { … },
            …
    )
}

Как видите, собирать зависимости руками — сомнительное удовольствие. Но так сложилось, что в Dagger есть фича специально для построения подобных мап. Эта фича называется мультибиндинги. Повезло, что сказать:)


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


// :common
@dagger.MapKey
annotation class DependenciesKey(val value: KClass<out Dependencies>)

// :yandexmaps
@dagger.Module
interface SearchDepsBindingsModule {
    @dagger.Binds
    @dagger.IntoMap
    @DependenciesKey(SearchDeps::class)
    fun bindSearchDeps(impl: MainActivityComponent): Dependencies
}

@dagger.Component(modules = [SearchDepsBindingsModule::class])
interface MainActivityComponent: SearchDeps {
    fun inject(activity: MainActivity)
}

Здесь аннотация @DependenciesKey(SearchDeps::class) определяет ключ, возвращаемый тип Dependencies — тип значения, а аргумент impl: MainActivityComponent — это само значение.


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


Первое — кастомная аннотация для ключа. Вообще-то из коробки в Dagger есть аннотация @ClassKey, аналогичная нашей кастомной @DependenciesKey. Но я всё же рекомендую завести свою собственную, чтобы чётко отделять, где происходит работа с зависимостями, а где — всё остальное.


Второй важный момент — значение, которое мы кладём в мапу. Это экземпляр компонента, к которому мы подключаем модуль. Экземпляр компонента доступен в нём самом, его можно инжектить, провайдить, и биндить так же, как все остальные объекты, находящиеся в графе. Чтобы это работало конкретно здесь, MainActivityComponent должен быть наследником Dependencies, и даже больше: должен реализовывать SearchDeps.


Как работает реализация интерфейса компонентом Dagger? Никакой магии тут нет. Для простоты рассмотрим в несколько шагов.


Все зависимости лежат непосредственно в компоненте.


Всё, что есть в компоненте, может торчать из него provision-методами.
@dagger.Component
interface MainActivityComponent {
    val map: Map
    val camera: Camera
    val moshi: Moshi
    val viewPool: RecycledViewPool
    …
   fun inject(activity: MainActivity)
}

Напишем перед этими properties override и реализуем интерфейс непосредственно компонентом.
@dagger.Component
interface MainActivityComponent: SearchDeps {
    override val map: Map
    override val camera: Camera
    override val moshi: Moshi
    override val viewPool: RecycledViewPool
    …
    fun inject(activity: MainActivity)
}

При наследовании интерфейсов явное переопределение properties не требуется.
@dagger.Component
interface MainActivityComponent: SearchDeps {
    fun inject(activity: MainActivity)
}

Как видите, никакой магии действительно нет. Компонент реализует интерфейс зависимостей в треть строчки кода.


Теперь можно реализовать интерфейс HasDependencies:


class MainActivity : Activity(), HasDependencies {
    @Inject
    override lateinit var depsMap: DepsMap

    override fun onCreate(…) {
        DaggerMainActivityComponent.factory().create().inject(this)
    }
}

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



Использование Hilt в app-модуле


Использовать в нашей схеме Hilt повсеместно невозможно, потому что в нём все фрагменты by design используют либо один компонент, либо кастомные компоненты, в декларации которых необходимо указывать родителя. А выше мы договорились, что API модуля не содержит конкретных DI-фреймоворков, и app-модули зависят от фич, а не наоборот. Так что в feature-модулях использовать Hilt точно невозможно. Но можно в app-модулях.


Почему я рассматриваю этот вариант отдельно? На самом деле всё можно сделать абсолютно аналогично, за исключением одного — реализации интерфейса зависимостей компонентом. Преимущество Hilt в том, что вы не создаёте компоненты руками, он делает это за вас, и поэтому вот такой код написать нельзя:


Повторенье мать ученья
// :yandexmaps
@Module
interface SearchDepsBindingsModule {
    @Binds
    @IntoMap
    @DependenciesKey(SearchDeps::class)
    fun bindSearchDeps(impl: MainActivityComponent): Dependencies
}

@Component(modules = [SearchDepsBindingsModule::class])
interface MainActivityComponent: SearchDeps {
    fun inject(activity: MainActivity)
}

Вместо этого нужно создать новую точку входа, и торчать из хилтового графа зависимостями:


// App module
@EntryPoint
@InstallIn(ActivityComponent::class)
interface FeatureModuleDepsImpl: FeatureModuleDeps

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


@InstallIn(ActivityComponent::class)
@Module
object SearchDepsBindingsModule {
    @Provides
    @IntoMap
    @DependenciesKey(SearchDeps::class)
    fun provideSearchDeps(activity: Activity): Dependencies {
        return EntryPointAccessors.fromActivity(
            activity, 
            FeatureModuleDepsImpl::class.java
        )
    }
}

В этом месте количество кода в Hilt и непосредственно Dagger практически не различается.



Подведём итоги по предоставлению зависимостей


  • Узлы иерархии реализуют интерфейс HasDependencies. Fragment, Activity и Application наследуются от HasDependencies в зависимости от того, есть ли на их уровне все нужные зависимости;
  • Удобно реализовывать property, предоставляющую DepsMap, с помощью kotlin @Inject override lateinit var;
  • Component реализует Dependencies;
  • Экземпляр Component доступен в нём самом.

Благодаря мощи Dagger, кода для предоставления зависимостей каждому конкретному модулю получается не сильно больше, чем если бы мы использовали сабкомпоненты: одна строчка на каждую зависимость + ещё порядка 10 строк для реализации интерфейса HasDependencies.



Использование зависимостей в feature-модуле


Использование зависимостей со всеми вышеописанными знаниями кажется тривиальным. Вызываем во фрагменте описанную в common функцию:


findDependencies<FeatureDeps>()

//:feature.api
interface FeatureDeps: Dependencies {
    val myDependency1: Dependency1
    val myDependency2: Dependency2
    ...
}

Конечно, можно и так. И для простых фич это подойдёт. Но можно и лучше.


Вспомним ещё одну фичу Dagger. Она многим знакома, но не все в курсе одной её интересной особенности. Речь пойдёт о Component Dependencies.


Component Dependencies


Обычно Component Dependencies используют для связи непосредственно компонентов, в качестве альтернативы subcomponents:


@Component
interface ProviderComponent {
    val dependency1: Dependency1
    ...
    val dependencyN: DependencyN
}

@Component(dependencies = [ProviderComponent::class])
interface DependentComponent {
    @Component.Factory
    interface Factory {
        fun create(deps: ProviderComponent): DependentComponent
    }
}

В этом случае есть два компонента, и всё, что предоставляется в виде provision-методов из ProviderComponent, становится доступно в графе DependentComponent. Этот способ связать компоненты в Dagger — известная альтернатива subcomponents.


Но не все знают, что в массив dependencies можно передавать не только компоненты, но и обычные интерфейсы или классы с provision-методами. Например, так:


//:search.api
interface SearchDeps: Dependencies {
    val searchManager: SearchManager
    val logger: SearchLogManager
    ...
}

//:search.internal
internal @Component(dependencies = [SearchDeps::class])
interface SearchFragmentComponent {
     @Component.Factory
     interface Factory {
        fun create(deps: SearchDeps): SearchFragmentComponent
    }
    fun inject(fragment: SearchFragment)
}

И это будет работать точно так же, как с компонентами — всё, что предоставляется properties SearchDeps, попадёт в граф SearchFragmentComponent. Во фрагменте код будет выглядеть так:


class SearchFragment : Fragment(){
    @Inject internal lateinit var logManager: SearchLogManager

    override fun onCreate(…) {
        DaggerSearchFragmentComponent.factory()
                .create(findDependencies())
                .inject(this)
        logManager.writeLog()
    }
}

Dagger ещё раз помог нам избежать большого количества рутинного кода.



Минусы, ограничения, исключения


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



Связывание во время выполнения


Очевидный и однозначный минус — это потеря статических гарантий.


inline fun <reified D: Dependencies> Fragment.findDependencies(): D {
   return parents
     .mapNotNull { it.depsMap[D::class.java] }
     .firstOrNull() as D?
     ?: throw IllegalStateException(“No ${D::class.java} in $parents")
}

Обратите внимание на throw IllegalStateException. Исключение кидается во время выполнения программы, и это не очень хорошо. Как я уже говорил выше, это вариант Fail Fast, потому что восстановиться из такой ситуации нельзя. Нужно упасть, дав понять разработчику, что и где пошло не так.


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



Если при тестировании фичи хотя бы открывают, то всё будет ок. За те несколько лет, что мы используем этот подход, наше продовое приложение падало с этим исключением всего один раз, совсем недавно. Причём это был сложный кейс, когда экран, поддерживающий server-driven UI и открытый по deep-link, попробовал открыть WebView, а приложение не было готово к этому. К счастью, наше логирование позволило быстро локализовать проблему и внести необходимые правки.



Сложность восприятия


Подход, по сути, повторяет концепцию Dagger Android с Component Dependencies вместо subcomponents. И, точно так же как Dagger Android, кажется сложным из-за использования мультибиндингов. Впрочем, если разобраться в том, как это работает, сложность перестаёт быть таким уж существенным минусом.


Также есть вопрос о том, кто именно в иерархии должен предоставлять зависимости. Здесь я обычно руководствуюсь простым правилом — предоставлять зависимости должен максимально близкий родитель, владеющий всеми зависимостями. То есть, если стоит выбор между фрагментом и Activity, следует выбирать фрагмент, а между Application и Activity — Activity



Ограничения на зависимость фич друг от друга


Следующее ограничение заключается в том, что фичам лучше не зависеть друг от друга.



У нас это напрямую не запрещено, но и не рекомендуется. Давайте посмотрим, почему.


Напомню, что зависимости большой фичи (в данном случае — поиска в Яндекс.Картах) могут выглядеть вот так:



А зависимости другой большой фичи (карточки места/организации) — вот так:



А именно эта карточка открывается, когда вы тапаете результат поиска в списке или пин на карте:



У карточки есть собственный PlacecardFragment, который хочет найти в иерархии собственные PlacecardDependencies. Первое, и, казалось бы, самое простое, что приходит в голову — сделать зависимость поиска от карточки:


//:search/build.gradle

dependencies {
    api/implementation project(':placecard’)
}

Конфигурацию “api/implementation” gradle, конечно, не поддерживает, поэтому надо выбрать что-то одно. К сожалению, как мы увидим ниже, суть этого выбора отражает аксиома Эскобара. Если вы не знаете разницы между api и implementation в gradle, рекомендую почитать этот ответ на SO.



Фичи зависят друг от друга по api


Первый вариант: поиск зависит от карточки по api. То есть клиенты поиска смогут увидеть весь публичный интерфейс модуля карточки.



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



Фичи зависят друг от друга по implementation


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



В этом случае компонент поиска должен реализовывать зависимости карточки. Но он не сможет реализовать всё: зависимостей слишком много, и компонент точно не владеет некоторыми из них.


Придётся протаскивать зависимости через интерфейс поиска. Т.е. интерфейс может разрастись примерно в полтора-два раза. Станет значительно сложнее разобраться, что нужно непосредственно поиску, а что — нет.


Кроме того, представьте, что случится, когда понадобится добавить в карточку новую зависимость: её нужно будет добавить и в PlacecardDeps, и в SearchDeps. Потребуется изменить два модуля вместо одного.


Оба эти варианта чреваты проблемам, поэтому мы пойдём другим путём.



Разрываем зависимость между фичами


Фичи в этом случае не будут зависеть друг от друга.



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


// :search.api
interface PlacecardProvider {
    fun placecard(data: GeoObject): Fragment
}

interface SearchDeps {
    … 
    val placecardProvider: PlacecardProvider
}

Надо заметить, что предоставляется не конкретный PlacecardFragment, а некий абстрактный Fragment. То есть фича поиска не знает, какой именно фрагмент будет открывать, и, соответственно, не имеет представления о его зависимостях.


При этом клиент не может «забыть» предоставить фрагмент, потому что он обязан предоставить PlacecardProvider как одну из зависимостей. А реализует он этот провайдер с помощью другой фичи:


class PlacecardProviderImpl: PlacecardProvider {
    override fun placecard(data: GeoObject): Fragment 
         = PlacecardFragment.newInstance(data) 
} 

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


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



Не UI-фича


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


// :search.api
interface SearchManager {
    fun search(text: String): String
}

// :search.internal
internal class SearchManagerImpl(
    val logger: Logger,
    val format: Formatter,
        …
): SearchManager {
    override fun search(text: String): String {
        //...
    }
}

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


Можно было бы поступить просто: передать зависимости и создать реализацию. Фабрика могла бы выглядеть следующим образом:


Просто, но неэффективно
object SearchManagerFactory {
    fun create(
            logger: Logger,
            format: Formatter,
            …
    ): SearchManager {
        return SearchManagerImpl(
            logger, format, …
        )
    }
}

В функцию приходят зависимости, которые прокидываются в конструктор имплементации руками. Это не очень удобно, поскольку при изменении этих зависимостей придётся править код в нескольких местах. И, ко всему прочему, такой подход сильно отличается от всех случаев, которые разобраны выше. А значит, нарушается единообразие.


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


// :search.api
interface SearchDeps {
    val logger: Logger
    val format: Formatter
    …
}

Поскольку тут нет дерева фрагментов, искать зависимости мы нигде не будем. И, соответственно, нам не нужно наследовать SearchDeps от общего интерфейса Dependencies.


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


Теперь рассмотрим внутренний код feature-модуля:


// :search.internal
internal class SearchManagerImpl @Inject constructor(
    val logger: Logger,
    val format: Formatter,
        …
): SearchManager {
    override fun search(text: String): String {
        //...
    }
}

@Component(dependencies = [SearchDeps::class])
internal interface SearchComponent { 
    val impl: SearchManagerImpl
}

Мы сделали инжект имплементации в конструктор и создали компонент с единственным provision-методом, который возвращает эту имплементацию. Не совсем типичный подход, но использовать компонент в качестве фабрики сервиса удобно.


Далее используем компонент в публичной фабрике, чтобы создать экземпляр реализации:


object SearchManagerFactory {
    fun create(deps: SearchDeps): SearchManager {
        return DaggerSearchComponent
            .factory()
            .create(deps)
           .impl
    }
}

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



Подводя итоги


  • Разбивайте ваше приложение на модули. Но разбивая, делайте это грамотно. Не обязательно так, как предлагаю в этой статье я, но выработайте себе какой-то план, и придерживайтесь его. Помните, что плохое разбиение на модули может быть хуже, чем никакое.
  • Продумайте, как осуществить DI между модулями и каковы ваши требования к нему.
  • Присмотритесь к описанному мной подходу, возьмите лучшее и кастомизируйте то, что вам не нравится (не забудьте рассказать об этом в комментариях).
  • Думайте о своём пользователе.

Напоминаю, что рабочий пример, а также полную реализацию описанного механизма поиска зависимостей можно посмотреть у меня на GitHub. Не бойтесь и не хейтьте Dagger 2, а лучше попробуйте и полюбите его так же, как наша команда. Всем успехов в нашем нелёгком деле!

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


  1. IlnurStybayev
    23.11.2021 18:38
    +2

    Очень полезная статья ! Спасибо за труд !


  1. mishkarodionov
    15.12.2021 13:18

    Вопрос автору - использование разбиения на api и internal в рамках одного модуля не приведет ли к долгому процессу сборки приложения грэдлом в ситуации, когда в internal какой-то фичи мы что-то поменяли, и из-за этого будут пересобираться все остальные фичи и модули, которые ссылаются на api (не изменившееся!) этой фичи? Не будет ли более правильным подоходом создавать отдельные модули для api фичи и его реализации, типа feature1_api, feature1_impl? Или сборщик понимает, что изменения коснулись только модулей фичи помеченных internal и значит не надо пересобирать те модули, которые зависят от api этой фичи?


    1. zagayevskiy Автор
      15.12.2021 13:51

      Добрый день. Насколько мне известно, gradle не знает про Kotlin internal, поэтому пересборка тех модулей, которые зависят от фичи, действительно будет происходить, но это не точно, надо проверять. Кроме того, есть ещё инкрементальность на уровне компилятора Kotlin.
      Но, как упоминалось в статье, фичам не рекомендуется зависеть друг от друга, поэтому их пересборка происходить не будет.
      Развитие идеи, подобное тому, что вы предлагаете, известно, и есть команды, которые им пользуются. Уточню, что, по классике, один фиче-модуль превращается в три, а не в два:


      • feature-api — только публичный интерфейс
      • feature-impl — реализация
      • feature-factory — то, от чего будут зависеть клиенты, предоставляет имплементацию, закрытую интерфейсом.

      Зависимости между ними будут такие:
      feature-api не зависит ни от -impl, ни от -factory.
      feature-impl зависит от feature-api по api или implementation, не важно:


      // :feature-impl/build.gradle
      api/implementation project(':feature-api')

      feature-factory зависит от feature-api по api, а от feature-impl по implementation:


      // :feature-factory/build.gradle
      api project(':feature-api')
      implementation project(':feature-impl')

      Клиенты зависят только от feature-factory.
      Смысл всего этого как раз в том, чтобы при изменениях в реализации feature-impl пересобирались только два модуля — feature-impl и feature-factory. Gradle знает, что сам модуль feature-factory, и его публичный api не изменились, поэтому может не пересобирать зависящие от него модули.


      Конкретно мы не используем такой подход повсеместно, потому что он троекратно увеличивает количество модулей, а от этого начинает страдать туллинг. Android studio тормозит, замедляется sync, индексация.
      Мы начали использовать ещё большее развитие этого подхода в мультиплатформенных фичах, где используется Kotlin multiplatform. Но не для избежания пересборки, а для того, чтобы api модулей был более чистым. Но об этом как-нибудь в другой раз:)