В многомодульном приложении часто возникает ситуация, когда в одном модуле находится интерфейс, а вот его реализация находится в совершенно другом модуле. Как следствие, возникает потребность как-то получить реализацию при наличии лишь интерфейса. Всё так или иначе сводится к тому, чтобы обратится к какой-либо сущности, которая отдаст нам реализацию. Но ведь сначала этой сущности надо сообщить, где взять реализацию. Этот процесс я называю связыванием, так как мы связываем интерфейс с его реализацией. Видел, что многие называют это склейкой. Сегодня мне бы хотелось проговорить, какие есть для этого способы. И да, спойлер, их несколько.
По всем правилам приличия представлюсь — меня зовут Перевалов Данил, а теперь давайте перейдём к теме.
А что вообще и с чем надо связывать? В своей предыдущей статье я рассказал, какие типы модулей я для себя выделяю. Сегодня нас интересуют два из них:
api-модуль — особый модуль, который содержит в себе только интерфейсы, а также модели данных, исключения и прочие классы для работы с этим интерфейсом. При этом такой модуль не содержит в себе никакой логики. Реализация интерфейсов и вся логика работы содержится в другом модуле;
фичёвый модуль — содержит логику работы какой-то фичи. В нашем случае в нем будет лежать реализация интерфейсов api-модуля.
В итоге наша задача: связать интерфейс из api-модуля с реализацией в фичёвом модуле.
Сразу оговорюсь, что эта статья значительно сложнее для восприятия, чем предыдущая. Тем не менее, тема, поднимаемая в ней, мне кажется даже более важной.
Пример
Чтобы всё было понятнее, сделаем для себя простой пример. У нас будет два фичёвых модуля, два api-модуля и, конечно, app модуль. Фича 1 при этом использует логику фичи 2. Для этого она подключает её api-модуль.
Посмотрим на код. У нас есть интерфейс UseCase, который просто возвращает какое-то число. Этот интерфейс и будет лежать в feature-2-api.
interface MagicNumberUseCase {
fun get(): Int
}
Раз есть интерфейс, то должна быть и его реализация. По сути, она просто возвращает число 5. Всё. Больше она ничего не умеет. Лежит она в feature-2-impl.
class MagicNumberUseCaseImpl : MagicNumberUseCase {
override fun get(): Int {
return 5
}
}
Отлично, интерфейс с реализацией у нас есть. Теперь надо сделать возможность получать их в другом модуле. Для этого создаем новый интерфейс в модуле feature-2-api. Я видел, что где-то интерфейсы зависимостей, которые предоставляет модуль называют <Feature>Api, а интерфейсы зависимостей, которые ожидает модуль называют <Feature>Dependencies. А где-то наоборот, <Feature>Dependencies — это то, что модуль предоставляет. А ещё есть <Feature>ModuleOutput и <Feature>ModuleInput. Весьма запутанно.
Поэтому, дабы никого не путать, в рамках статьи введём отдельные термины:
<Feature>ProvideDeps — зависимости, которые предоставляет модуль;
<Feature>ReceiveDeps — зависимости, которые ожидает модуль.
interface Feature2ProvideDeps {
fun provideMagicNumberUseCase(): MagicNumberUseCase
}
В нем описаны все зависимости, которые наш модуль хочет предоставлять. В нашем случае тут только метод для получения MagicNumberUseCase. В целом таких интерфейсов может быть несколько. Именно реализацию <Feature>ProvideDeps мы и будем получать из хранилища. Собственно, про него.
Для примера сделаем, как мне кажется, максимально простой вариант. Просто в Feature2ProvideDeps добавим companion, в котором будет хранится Provider. Что ещё за Provider то? Лямбда, просто лямбда. При ее вызове нам и вернется наш Feature2ProvideDeps. Provider нужен, так как сохранять в статике реализацию зависимостей не стоит. Это может привести к утечке. А за счёт Provider у нас в статике хранится не реализация интерфейса, а только объект, который умеет создавать эту самую реализацию.
typealias Provider = () -> Feature2ProvideDeps
interface Feature2ProvideDeps {
fun provideMagicNumberUseCase(): MagicNumberUseCase
companion object {
private var provider: Provider? = null
fun get(): Feature2ProvideDeps {
return provider!!.invoke()
}
fun initProvider(provider: Provider) {
this.provider = provider
}
}
}
Ну и, конечно, в companion должны быть методы get() и initProvider. Куда же без них. По сути, вызов initProvider и будет являтся связыванием. Вызывая его, мы как-бы говорим, что для такого-то интерфейса будет использоваться такая-то реализация.
Остался последний класс, который я не упомянул — реализация интерфейса Feature2ProvideDeps. В зависимости от того, какой способ подачи зависимостей у вас используется, реализация интерфейса будет отличаться. Например, для Dagger2 это будет, собственно, его величество Component. Но так как мы все используем разные способы подачи зависимостей, то я сделаю максимально «чистую» реализацию. Чтобы никого не путать.
class Feature2ProvideDepsImpl : Feature2ProvideDeps {
override fun provideMagicNumberUseCase(): MagicNumberUseCase {
return MagicNumberUseCaseImpl()
}
}
В итоге получается как-то так:
Красота, не правда ли?
По сути, у нас осталось всего два нерешенных вопроса:
«Куда положить Feature2ProvideDepsImpl?»;
«Где вызвать initProvider?».
Хорошие вопросы. Ответов на них я вижу целых три:
связывание и реализация интерфейса в главном модуле;
инициализация связывания в главном модуле, но логика связывания и реализация интерфейса в фичёвом модуле;
связывание и реализация интерфейса в фичёвом модуле.
По сути, это и есть способы связывания модулей, упомянутые в заголовке статьи. Именно их мы и будем сегодня рассматривать. У каждого из них есть плюсы и минусы. Хотя, чисто между нами, мне больше всего нравится последний.
Приступим, наконец, к их рассмотрению.
Связывание и реализация в главном модуле
Как можно догадаться по названию, в данном способе всё самое интересное происходит внутри главного app модуля. Внутри нашего app модуля будет класс, который отвечает исключительно за связывание. Назовем его ProvideDepsLinker.
object ProvideDepsLinker {
fun init() {
Feature2ProvideDeps.initProvider { Feature2ProvideDepsImpl() }
}
}
В нём мы подсовываем хранилищу ту реализацию, которую хотим. Сам Feature2ProvideDepsImpl положим где-то рядышком, чтобы не потерялся. Сам метод init будем вызывать где-то в Application.onCreate.
class CianApplication : Application() {
override fun onCreate() {
super.onCreate()
ProvideDepsLinker.init()
}
}
Получается как-то так. Фичёвые модули подключаются к главному как implementation.
В таком подходе app модуль — царь и бог. Он контролирует и решает абсолютно всё.
Теперь давайте взглянем на плюсы, минусы и нюансы такого способа.
Начнем с плюсов. Во первых, мы имеем абсолютный контроль над зависимостями, которые попадают в модуль feature-2-impl и другие фичёвые модули. Мы можем подсунуть ему что угодно. Включая зависимости из тех модулей, о которых он даже не слышал.
Ничто даже не мешает нам подсунуть другую реализацию вместо MagicNumberUseCase, и всё будет хорошо. Можно четко указать, какие зависимости наш модуль попросит у главного модуля. Просто создадим интерфейс <Feature>ReceiveDeps, в котором перечислим ожидаемые зависимости. С помощью такого нехитрого действия главному модулю легко будет понять, какие зависимости от него ждут. Работать станет чуточку проще.
Во вторых, такой подход реализовать проще всего, особенно если вы только начинаете разносить приложение на модули. Рассмотрим такой пример: допустим, у нас всё лежало в одном модуле, и наш MagicNumberUseCaseImpl возвращал значение из константы, лежащей в классе Application.
class MagicNumberUseCaseImpl : MagicNumberUseCase {
override fun get(): Int {
return CianApplication.MAGIC_NUMBER
}
}
При этом это очень важная и постоянно меняющаяся константа, так что просто скопировать ее в MagicNumberUseCaseImpl не выйдет, а выносить может быть очень трудозатратно. Доступа к коду app из фичёвого модуля у нас нет. Но зато можно сделать следующий костыль: просто в MagicNumberUseCaseImpl вместо возвращения конкретного значения вызовем лямбду, которая и вернет нам это значение. При этом сама лямбда передается через конструктор.
class MagicNumberUseCaseImpl(
private val magicNumberProvider: () -> Int
) : MagicNumberUseCase {
override fun get(): Int {
return magicNumberProvider.invoke()
}
}
Теперь при создании экземпляра MagicNumberUseCaseImpl мы просто передадим в него лямбду, в которой и будет идти обращение к Application.
class Feature2ProvideDepsImpl : Feature2ProvideDeps {
override fun provideMagicNumberUseCase(): MagicNumberUseCase {
return MagicNumberUseCaseImpl(
magicNumberProvider = { CianApplication.MAGIC_NUMBER }
)
}
}
Такой нехитрый костыль может сильно упростить вынесение кода в модули на начальных этапах. Правда, нужно быть осторожным, как и с любыми другими костылями. Может сложится ситуация, когда вы разнесли приложение на модули, а код, по сути, остался с таким же большим количеством связей. Просто все связи теперь в отдельном модуле (что конечно, в целом уже неплохо). Так что как только появляется возможность убрать такой костыль, а появится она, скорее всего, когда вы вынесите в отдельный модуль что-то ещё, то сразу выносите.
Но у всего и обратная сторона, а значит, и у данного способа есть минусы. По сути, он один, просто очень и очень большой. Минус в том, что app модуль становится очень огромным, и на его сборку тратится большое количество времени.
В нашем примере Feature2ProvideDepsImpl — это довольно маленький и симпатичный класс, но в реальности вместо него будет, например, Dagger Component. Помимо того, что, скорее всего, он сам будет достаточно массивным, так ещё и требует кодогенерации через kapt, что отъедает приличное количество времени сборки. А ведь таких реализаций у нас будет очень много — минимум по одной на фичу.
Напомню, что все фичёвые модули при таком способе подключаются к app как implementation. Как следствие, при изменении кода в любом из фичёвых модулей наш app будет заново собираться (заранее отмечу, что не все task сборки модуля при этом будут выполнятся, часть из них будет UP-TO-DATE, но лишь часть). И, соответственно, получится ситуация, когда вы меняете буквально одну строчку в вашем модуле, но при этом у вас в app генерируются Dagger Component’ы для всех модулей.
Это сильно ударит по времени сборки. Хотя это всё же лучше, чем с монолитным приложением. А ведь единственное, чем занимается app модуль в таком подходе — связывание.
И чем сильнее вы дробите на модули, тем заметнее становится влияние app на время сборки.
Лично я бы рекомендовал такой способ связывания в следующих ситуациях:
вы только начали разносить приложение на модули. В app и так почти весь код, так что вы не пострадаете от того, что в нем ещё и реализации зависимостей модулей;
вам нужен абсолютный контроль над тем, какие зависимости попадают в модуль. Этот способ дает максимальный контроль;
у вас планируется небольшое количество модулей.
А что же делать, если у вас уже приличное количество модулей и текущий способ начинает быть проблемой? Есть ещё один способ.
Инициализация связывания в главном, но логика связывания и реализация в фичёвом
Данный подход подразумевает, что из главного от модуля мы лишь говорим нашим фичёвым модулям, что пора производить связывание. Feature2ProvideDepsImpl у нас также находится в фичёвом модуле. Дополнительно в фичёвом модуле лежит класс Feature2ProvideDepsLinker, который отвечает за связывание в этом конкретном модуле.
object Feature2ProvideDepsLinker {
fun init() {
Feature2ProvideDeps.initProvider { Feature2ProvideDepsImpl() }
}
}
Роль ProvideDepsLinker в главном модуле сводится к простому вызову методов <Feature>ProvideDepsLinker классов в фичёвых модулях.
object ProvideDepsLinker {
fun init() {
Feature2ProvideDepsLinker.init()
}
}
Общая картина начинает выглядеть вот так:
С помощью такого подхода большая часть кода, отвечающего за связывание, перебралась в фичёвые модули, что заметно облегчило наш app модуль. Это серьезно уменьшит скорость сборки при изменениях кода в фичёвом модуле. Ведь теперь весь код, отвечающий за связывание и за реализацию интерфейса зависимостей, находится в фичёвых модулях. Как следствие, они не будут заново собираться при изменении другого модуля.
Но у всего есть цена. Мы потеряли в контроле. Теперь нельзя так свободно подменять зависимости. Для большего понимания давайте попробуем повторить костыль из первого подхода с помощью данного способа.
Напомню, что MagicNumberUseCaseImpl возвращал константу, лежащую в классе Application — CianApplication.MAGIC_NUMBER. Так как у нас теперь нет физической возможности засунуть код из app в фичёвый модуль напрямую, то придётся делать это в обход. Просто скопировать константу в MagicNumberUseCaseImpl — не выход. Лучше всего вынести константу в какой-то третий базовый модуль или библиотеку. О которых будут знать все, кому она нужна.
Если подобная ситуация возникла с объектом посерьёзнее, чем простая константа, то можно положить в третий модуль лямбду-Provider, а её реализацию выставлять в модуле app.
Также мы больше не можем получить доступ к какой-то зависимости из другого модуля, не зная о нем. Теперь нужно четко прописать, что наш фичёвый модуль хочет подключить другой модуль.
Может показаться, что изменилось не многое. Но на самом деле изменилась сама парадигма. Модули стали более самостоятельными, а роль главного модуля уменьшилась. Как следствие, может начаться анархия, где модули подключают друг друга, как хотят. Чтобы этого не произошло, должна возрасти роль иерархии модулей.
Лично я бы рекомендовал такой способ связывания в следующих ситуациях:
у вас большое приложение, больше половины кода уже разнесено на модули и вы хотите ещё;
полный контроль вам не очень-то и нужен;
долгая сборка app становится для вас проблемой.
Самые внимательные могли заметить, что в данном способе фичёвые модули всё ещё подключаются к app как implementation. А значит, проблема с лишней сборкой app модуля никуда не ушла, просто стала гораздо менее заметной.
Самые-самые внимательно также могли заметить, что помимо обычных модулей, в Android есть также и dynamic-feature модули. Их нельзя подключить как implementation, а значит, для них придётся придумывать что-то отдельно.
Но есть ещё один способ, который, кажется, может решить эти проблемы.
Связывание и реализация в фичёвом модуле
Идея достаточно простая: app модуль вообще ничего не должен знать о коде фичёвых модулей. Для этого можно вместо implementation подключать модули как runtimeOnly. Модули при таком подключении попадут в .apk, но при этом их код не будет доступен в app.
Соответственно, логика работы с обычными и dynamic-feature модулями становится единой.
Вот только встает вопрос: «А как нам сказать модулям, что пора произвести связывание?». Прямого доступа к их коду у нас больше нет.
Тут есть множество вариантов. Начнем с тех, что нам предоставляет Android SDK.
Сходу может показаться, что «У нас же есть BroadcastReceiver!». Он полностью удовлетворяет нашим потребностям: позволяет кидать события, а также прописывать «перехватчиков» событий в AndroidManifest. Прям идеал. Но тут возникает проблема, что на некоторых прошивках выполнение onCreate у Application (напомню, что это идеальное время для связывания) иногда всё ещё считается как работа приложения в фоне. И система на новых версиях Android просто не позволит кинуть Broadcast Intent. Думая, что приложение то ещё не открылось.
В качестве альтернативы можно использовать ContentProvider. Его onCreate вызывается раньше, чем onCreate у Application. Выглядит как очень хороший момент, чтобы произвести связывание. И в целом так оно и есть, но как всегда с нюансами.
Первый нюанс. Экземпляр ContentProvider создается системой, а не нами. Следовательно, меньше контроля за жизненным циклом такого объекта.
Второй нюанс. Вообще-то ContentProvider предназначен не для этого. Это огромный и мощный инструмент для работы с данными, а мы его используем просто чтобы создать лямбду и засунуть ее в переменную.
Третий нюанс. В целом вытекает из второго. Это мощный инструмент с кучей полей и методов, а следовательно, создание его экземпляра — дело затратное. В итоге можно получить большой удар по времени старта приложения.
Вообще, я когда-то видел статью, где автор предлагал использовать ContentProvder для связывания, но заново найти я её не смог. Возможно, автор как-то решил эту проблему. Но и в целом, даже с этими проблемами способ вполне себе хороший. А мы пока двинемся дальше в лес…
Раз стандартные компоненты Android не до конца нас удовлетворяют, то можно рассмотреть что-то самописное.
Google, если мне не изменяет память, в своих примерах работы с dynamic-feature используют рефлексию. То есть где-то в Application классе мы должны будем держать список названий классов <Feature>ProvideDepsLinker. Затем в onCreate пробежимся по этому списку, создадим экземпляры классов и вызовем метод init.
class CianApplication : Application() {
private val linkersClassList = listOf<String>(
"ru.cian.app.feature2impl.Feature2ProvideDepsLinker",
"ru.cian.app.dynamic.DynamicProvideDepsLinker"
)
override fun onCreate() {
super.onCreate()
linkersClassList.forEach {
try {
initLinker(it)
} catch (throwable: Throwable) {
Log.e("Linker", "Cannot create", throwable)
}
}
}
private fun initLinker(name: String) {
val clazz = Class.forName(name).kotlin
clazz.functions.first { it.name == "init" }.call(clazz.objectInstance)
}
}
В целом это будет работать. Создание классов с один единственным методом — дело не очень затратное. На моём устройстве (знаю что личный опыт не доказательство) 1000 таких объектов создаются в среднем за 0,15 мс. Но есть один нюанс… Ладно, даже пара. Вам бы хотелось следить за этим списком? Ведь на каждое перемещение класса, создание или удаление модуля придётся лезть сюда. Это просто магнит для конфликтов. Так ещё и проблема всплывет только при старте приложения.
Можно, конечно, заморочится и подрядить кодогенерацию следить за этим списком, генерируя его при сборке. И вот это уже прям хороший способ. Но… Мне именно идеологически не очень нравится следующий момент. Так как у нас есть dynamic-feature то в теории половина модулей может отсутствовать в .apk на устройстве, а мы будем пытаться создавать экземпляры <Feature>ProvideDepsLinker классов, основываясь на списке, полученном в момент именно сборки. А хочется, чтобы были лишь попытки создания <Feature>ProvideDepsLinker, находящихся в .apk.
Сразу хочу отметить, что это больше именно идеологический момент, а не практический.
Но все-равно давайте разберем, как же нам получить список <Feature>ProvideDepsLinker классов именно из .apk. Нам понадобится AndroidManifest, а именно один из его компонентов — metadata. Если кто не знал, то metadata дает нам возможность прописать в AndroidManifest пару ключ-значение, а затем считать их в коде. Поэтому можно смело в качестве ключа указать имя нашего <Feature>ProvideDepsLinker класса, а в качестве значения — какое-то кодовое слово. Например, linker.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="ru.cian.cian.app.dynamic">
<application>
<meta-data
android:name="ru.cian.app.dynamic.DynamicProvideDepsLinker"
android:value="linker" />
</application>
</manifest>
Дальше достаточно просто считать все значения metadata и выделить нужные нам.
private fun getLinkerClassNameListFromMeta(): Set<String> {
val appInfo = context.packageManager.getApplicationInfo(
context.packageName,
PackageManager.GET_META_DATA
)
val metadata = appInfo.metaData
// Чтобы вызвать unparcel
metadata.getString("")
val metadataMap = metadata.keySet().map { it to metadata.get(it) }.toMap()
val linkerClassNames = metadataMap.filterValues { it == "linker" }.keys
return linkerClassNames
}
Таким нехитрым способом мы получим список <Feature>ProvideDepsLinker, которые в данный момент присутствуют в .apk. Честно говоря, кодогенерация — даже более сложный способ.
Но не могу не предупредить, что на Android 10 в сборках некоторых вендоров может наблюдаться проблема: если в момент обновления придёт push уведомление, то приложение может запуститься с AndroidManifest от новой версии и кодом от старой. Хотя лечится это довольно просто. Можно хранить старый список <Feature>ProvideDepsLinker классов в SharedPreferences.
Как сделать, разобрались. Но что это нам дает?
Во-первых, модуль app теперь ничего не знает о коде подключаемых фичёвых модулей. Они полностью от него отделены. Если удалить какой-то модуль из зависимостей app, он просто не попадет в сборку. При этом приложение соберется и даже будет работать (если у вас нормально обработаны исключения). Модуль становится прям отдельной самостоятельной единицей.
Во-вторых, когда мы внесем изменения в код фичёвого модуля, то у app не будет причин заново собираться. Справедливости ради, часть task сборки app модуля всё равно запустятся, так как некоторые tasks сборщику надо делать вне зависимости от того, изменился ли код app. Например, сборка .dex файлов. Но таких tasks меньшинство в рамках затрачиваемого времени.
В-третьих, флоу работы с dynamic-feature будет соответствовать флоу работы с обычными модулями, что в целом упрощает и стандартизирует подход к работе с любым модулем, не опираясь на его тип. От себя отмечу, что с помощью dynamic-feature модулей можно не только уменьшить размер приложения, но и скорость запуска, так как такие модули можно не загружать в оперативную память при старте приложения. Так, например, сделали ребята из Chrome.
Но за всё приходится платить. И тут мы видим, что контроля из app за тем, какие зависимости и куда попадают, теперь, практически нет. Модуль решает сам, какие зависимости ему связывать. Мы же можем только либо отключить его, либо подключить.
Парадигма изменилась окончательно. Если в первом способе app — папа, а фичёвый модуль — никто. То в этом способе фичёвый модуль уже настоящий гачи-воин, а app — лишь швейцар, открывающий ему дверь в .apk.
Лично я бы рекомендовал такой способ связывания в следующих ситуациях:
у вас очень много модулей и скорость сборки app критична;
контроль над зависимостями вам не нужен;
вы хотите dynamic-feature и чтоб с ними работать, как с обычными модулями.
В конце замечу, что в приведенном мной коде есть опять же небольшая идеологическая нестыковка. Дело в том, что у нас как была во втором способе связь от Application к Linker, так она и осталась. Просто теперь стала неявной. Причем, если создание классов ещё приемлемо, то вот вызов метода init напрямую уже не круто.
Поэтому в реальном проекте стоит поменять направление связи. Пусть Application создаст список обработчиков событий из разных модулей. Затем они просто подпишутся на Observable или Flow, куда Application отправит событие о том, что пора произвести связывание — onDependencyInit. Да и в целом такие обработчики вам понадобятся и для других событий, вроде onForeground и onBackground. Если будут время и силы, то как-нибудь об этом расскажу.
Гибрид
В целом вопрос того, насколько модуль должен быть самостоятельным, достаточно дискуссионный. Возможно, вы будете пользоваться только одним способом. Но, мне кажется, в реальном приложении нет смысла выбирать только один из них. В разных ситуациях могут понадобится разные способы. Для какого-то модуля нужен полный контроль, а для какого-то нет. Эти способы не противоречат друг другу, их можно использовать совместно. Попробуйте представить себя воспитателем детского сада, где модули — это детишки. За кем-то нужен полный контроль, потому что отвернулся, а он играется с «розочкой» у горла другого ребенка. А кто-то может и сам потихонечку играет в углу. Для выживания его просто надо вовремя уведомлять, что пора кушать.
Лично я бы рекомендовал начать с первого способа и если поймете, что у вас с ним проблемы, потом потихоньку перевести на второй способ всё, что переводится. В конце тоже самое провернуть и с третьим способом. Таким образом, если у вас где-то и останется первый способ, то похоже он там реально нужен. В целом же, хоть мне и импонирует последний вариант, но все они достаточно равноценны. Всё зависит от результата, который вы хотите получить.
Надеюсь, у вас в голове сложилась картина того, каким способом лучше всего связывать модули в вашем приложении. Если я вдруг что-то не учел, ошибся или упустил, то добро пожаловать в комментарии. Ну и если ваш способ связывания сильно отличается от озвученных мной, то хотелось бы узнать подробнее.
Другие статьи цикла:
Многомодульный BDSM: стоит ли внедрять Gradle модули и какие типы модулей бывают?