МНОГОМОДУЛЬНОСТЬ В ANDROID С ТОЧКИ ЗРЕНИЯ АРХИТЕКТУРЫ.
ОТ А ДО Я. ЧАСТЬ ВТОРАЯ Мацюк Е.В.
Мацюк Евгений Викторович - эксперт-разработчик, направление: Android, Google, г. Москва
Аннотация: в статье описывается новый способ построения многомодульного Android-проекта на основе проблем и гипотез, освещенных в первой части.
Ключевые слова: Android, Многомодульность, Dependency Injection, Dagger2, Жизненный цикл, Java.
В первой статье было выполнено введение в тему построения многомодульного Android-проекты и подсвечены проблемы, которые необходимо решить при организации многомодульного кода. Далее приступим к решению обозначенных в первой статье проблем. Улучшения DI
Отказ от большого количества скоупов
Как я писал выше, раньше мой подход был таким: на каждую фичу свой скоуп. На самом деле каких-либо особых профитов от этого нет. Только получаете большое количество скоупов и некоторое количество головной боли.
Вполне достаточно такой цепочки: Singleton — PerFeature — PerScreen. Отказ от Subcomponents в пользу Component dependencies
Уже более интересный момент. С Subcomponents вы имеете вроде более строгую иерархию, но при этом у вас полностью связаны руки и нет возможности хоть как-то маневрировать. Кроме того, AppComponent знает обо всех фичах, и еще вы получаете огромный сгенерированный класс
DaggerAppComponent.
С Component dependencies вы получаете одно суперкрутое преимущество. В зависимостях компонентов вы можете указывать не компоненты, а чистые интерфейсы. Благодаря этому вы можете подставлять какие угодно имплементации интерфейса, Даггер все съест. Даже если этой имплементацией будет компонент с таким же скоупом:
public abstract class AnotherFeatureComponent implements FeatureDependencies {
Рис. 1. Пример Component dependencies От улучшений DI к улучшению архитектуры
Давайте еще раз повторим определение фичи. Фича — это логически законченный, максимально независимый модуль программы, решающий конкретную пользовательскую проблему, с четко обозначенными внешними зависимостями, и который относительно легко переиспользовать в другой программе. Одно из ключевых выражений в определении фичи — это «с четко обозначенными
внешними зависимостями». Поэтому давайте все, что мы хотим от внешнего мира для фичи, будем описывать в специальном интерфейсе.
Вот, допустим, интерфейс внешних зависимостей фичи Покупки:
Рис. 2. Исходный код PurchaseFeatureDependencies Или интерфейс внешних зависимостей фичи Сканера:
public interface ScannerFeatureDependencies {
DbClientApi dbClient(); HttpClientApi httpClient(); SomeUtils someUtilsO;
// Фиче Сканера нужна возможность осществлять покупки Purchaselnteractor purchaselnteractor();
}
Рис. 3. Исходный код ScannerFeatureDependencies
Как уже было сказано в разделе о DI, зависимости могут имплементироваться кем угодно и как угодно, это же чистые интерфейсы, и наши фичи освобождены от этих лишних знаний.
Другая важная составляющая «чистой» фичи — это наличие четкого апи, по которому внешний мир может обращаться к фиче. Вот апи фичи Покупки:
public interface PurchaseFeatureApi {
Purchaselnteractor purchaselnteractor();
>
Рис. 4. Исходный код PurchaseFeatureApi
То есть внешний мир может получить Purchaselnteractor и через него пробовать осуществить покупку. Собственно выше мы и увидели, что Сканеру нужен Purchaselnteractor для осуществления покупки.
А вот апи фичи Сканера:
public interface ScannerFeatureApi {
ScannerStarter scannerStarter();
>
Рис. 5. Исходный код ScannerFeatureApi И сразу привожу интерфейс и имплементацию ScannerStarter:
public class ScannerStar^ {
Рис. 6. Исходный код ScannerStarter и ScannerStarterlmpl
Тут интереснее. Дело в том, что Сканер и Антивор довольно замкнутые и изолированные фичи. В моем примере эти фичи запускаются на отдельных Активити, со своей навигацией и т. д. То есть нам здесь достаточно просто стартовать Активити. Умирает Активити — умирает и фича. Вы же можете работать по принципу "Single Activity", и тогда через апи фичи передавать, допустим, FragmentManager и какой-нибудь колбек, через который фича сообщает, что она завершилась. Вариаций много.
Можно также сказать, что такие фичи, как Сканер и Антивор, мы в праве рассматривать как независимые приложения. В отличие от фичи Покупки, которая является фичей-дополнением к чему-либо и сама по себе как-то не особо может существовать. Да, она независимая, но является логичным дополнением к другим фичам.
Как вы догадываетесь, должна существовать какая-то точка, которая связывает апи фичи, ее имплементацию и необходимые фиче зависимости. Этой точкой является даггеровский компонент. С примером компонента фичи Сканера можно ознакомиться по ссылке [5].
Переход к многомодульности
Итак, нам с вами удалось четко обозначить границы фичи через апи ее зависимостей и внешнее апи. Также мы разобрались, как это все провернуть в Даггере. А теперь мы подходим к следующему логичному и интересному шагу — разбиению на модули.
Сразу открывайте тестовый пример [4] — дело пойдет проще.
Давайте посмотрим на картину в общем:
Рис. 7. Общая схема модулей И еще посмотрим на структуру пакетов примера:
Ш- арр ,i, core-db-api jf, core-db-impl , core-network-api 11 core-network-impl ,i, соге-utils , feature-antitheft-api , feature-antitheft-impl , feature-purchase-api , feature-purchase-impl , feature-scanner-api
feature-scanner-example , feature-scanner-impl
Рис. 8. Общая схема пакетов
А теперь проговорим внимательно каждый пункт. В первую очередь мы видим четыре больших блока: Application, API, Impl и Utils. В API, Impl и Utils вы можете заметить, что все модули начинаются или на core-, или на feature-. Давайте для начала поговорим про них.
Разделение на core и feature
Все модули я разделяю на две категории: core- и feature-.
В feature-, как вы могли догадаться, наши фичи. В core- находятся такие вещи, как утилиты, работа с сетью, бд и т. д. Но там нет каких-то интерфейсов фич. И core — не монолит. Я за разбиение core-модуля на логические кусочки и против загрузки его еще какими-то интерфейсами фич.
В названии модуля первым пишем core или feature. Далее в названии модуля идет логическое название (scanner, network и т.д.).
Теперь про четыре больших блока: Application, API, Impl и Utils. API
Каждый feature- или core-модуль разбивается на API и Impl. В API находится внешнее апи, через которое можно обращаться к фиче или core. Только это, и ничего более:
Рис. 9. Расположение интерфейсов в структуре пакетов
Кроме того, api-модуль ни о ком ничего не знает, это абсолютно изолированный модуль. Utils
Единственным исключением из правила выше можно считать какие-то ну совсем утилитные вещи, которые разбивать на апи и имплементацию бессмысленно. Impl
Тут у нас есть подразбиение на core-impl и feature-impl.
Модули в core-impl также абсолютно независимы. Единственная их зависимость — это api-модуль. Для примера взглянем на build.gradle модуля core-db-impl:
Рис. 10. Зависимости gradle-модуля core-db-impl
Теперь про feature-impl. Тут уже находится львиная доля логики приложения. Модули группы feature-impl могут знать про модули группы API или Utils, но точно ничего не знают о других модулях группы Impl.
Как мы помним, все внешние зависимости фичи аккумулируются в апи внешних зависимостей. Например, для фичи Скана это апи выглядит следующим образом:
public interface ScannerFeatureDependencies {
// соге-db-api DbClientApi dbClientO; // core-network-api HttpClientApi httpClient*); // core-utils SomeUtils somelltils(); // feature-purchase-api Purchaselnteractor purchaseInteractor();
Рис. 11. Исходный код ScannerFeatureDependencies Соответственно, build.gradle feature-scanner-impl будет таким:
// bla-bla-bla dependencies {
implementation projectt1:core-utils1) implementation projectt1:core-network-api") implementation projectt1:core-db-api') implementation projectt1:feature-purchase-api') implementation projectt1:feature-scanner-api') II bla-bla-bla
Рис. 12. Зависимости gradle-модуля feature-scanner-impl
Вы можете спросить, а почему апи внешних зависимостей не в апи-модуле? Дело в том, что это деталь имплементации. То есть именно конкретной имплементации нужны какие-то определенные зависимости. Для Сканера апи зависимостей находится вот здесь:
арр
core-db-api core-db-impl core-network-api core-network-impl core-utils
feature-antitheft-api feature-antitheft-impl feature-purchase-api feature-purchase-impl feature-scanner-api feature-scanner-example feature-scanner-impl manifests java
▼ com.example.scanner ► В data
▼ Efcdi
с ScannerFeatureCompol
ScannerFeatureDependencies
с ScannerFeatureModule i ScannerScreenComponent с ScreenNavigationModule domain presentation tM routing El start
с ScannerApplication com.example.scanner [androidTes com.example.scanner (test)
► f res
Рис. 13. ScannerFeatureDependencies в структуре пакетов Небольшое архитектурное отступление
Давайте переварим все вышесказанное и уясним для себя некоторые архитектурные моменты, касающиесяfeature-...-impl-модулей и их зависимостей от других модулей.
Я встречал два наиболее популярных паттерна выставления зависимостей для модуля:
- Модуль может знать о ком угодно. Никаких правил нет. Тут даже комментировать нечего.
- Модули знают только о core-модуле. А в core-модуле сосредоточены все интерфейсы всех фич. Такой подход мне не очень импонирует, так как есть риск превратить core в очередную помойку. Кроме того, если мы захотим перенести наш модуль в другое приложение, то должны будем скопипастить эти интерфейсы в другое приложение, и также его поместить в core. Сам по себе тупой копипаст именно интерфейсов не очень привлекателен и реюзабелен в дальнейшем, когда интерфейсы могут обновиться.
В нашем же примере я выступаю за знание модулями апи и только апи (ну и utils-группы). Фичи абсолютно ничего не знают об имплементациях.
Но получается, что фичи могут знать о других фичах (через api, конечно же) и их запускать. Не получится ли в итоге каша?
Справедливое замечание. Тут тяжело выработать какие-то суперчеткие правила. Во всем должна быть мера. Мы немного уже касались этого вопроса выше, разделяя фичи на независимые (Сканер и Антивор) — вполне независимые и обособленные, и фичи «в контексте», то есть запускаемые всегда в рамках чего-то (Покупка) и обычно подразумевающие под собой бизнес-логику без ui. Именно поэтому Сканер и Антивор знают о Покупках.
Другой пример. Представим, что в Антиворе есть такая штука, как wipe data, то есть очищение абсолютно всех данных с телефона. Там много бизнес-логики, ui, оно вполне обособлено. Поэтому логично выделить wipe data в отдельную фичу. И тут развилка. Если wipe data всегда запускается только с Антивора и всегда присутствует в Антиворе, то логично, чтобы Антивор знал бы о wipe data и самостоятельно запускал ее. А аккумулирующий модуль, app, знал бы тогда только об Антиворе. Но если wipe data может запускаться еще где-то или не всегда присутствует в Антиворе (то есть в разных приложениях может быть по-разному), то логично, чтобы Антивор не знал об этой фиче и просто говорил чему-то внешнему (через Router, через какой-то колбек, это неважно), что пользователь нажал такую-то кнопку, а что под ней запускать — это уже дело потребителя фичи Антивора (конкретное приложение, конкретный app).
Также еще есть интересный вопрос о переносе фичи в другое приложение. Если мы, допустим, захотим перенести Сканер в другое приложение, то мы должны также перенести помимо модулей :feature-scanner-api и :feature-scanner-impl и модули, от которых Сканер зависит (:core-utils, :core-network-api, :core-db-api, :feature-purchase-api).
Да, но! Во-первых, все ваши api-модули абсолютно независимы, и там только интерфейсы и модели данных. Никакой логики. И эти модули четко разделены логически, а :core-utils — обычно общий модуль для всех приложений.
Во-вторых, вы можете апи-модули собирать в виде aar и поставлять через мавен в другое приложение, а можете подключать в виде гитового саб-модуля. Но у вас будет версионирование, будет контроль, будет цельность.
Таким образом, переиспользование модуля (точнее модуля-имплементации) в другом приложении выглядит гораздо проще, понятнее и безопаснее.
Application
Вроде у нас вырисовывается стройная и понятная картина с фичами, модулями, их зависимостями и вот этим всем. Теперь мы подходим к кульминации — это соединение апи и их имплементаций, подставление всем необходимых зависимостей и т. д., но уже c точки зрения гредловых модулей. Точкой соединения служит обычно сам app.
Кстати, в нашем примере такой точкой еще является feature-scanner-example. Вышеописанный подход позволяет вам запускать каждую свою фичу как отдельное приложение, что сильно экономит время сборки во время активной разработки. Красота!
Рассмотрим для начала, как все через app происходит на примере уже полюбившегося Сканера.
На основе этого мы можем создать даггеровский компонент, имплементирующий api внешних зависимостей:
^Component(dependencies = { CoreUtilsApi.class, CoreNetworkApi.class, CoreDbApi.class, PurchaseFeatureApi.class
»
(aPerFeature
interface ScannerFeatureDependenciesComponent extends ScannerFeatureDependencies {
Рис. 14. Исходный код ScannerFeatureDependenciesComponent Данный интерфейс я разместил в ScannerFeatureComponent для удобства:
@Component(modules = {
ScannerFeatureModule.class, ScreenNavigationModule.class }, dependencies = ScannerFeatureDependencies.class) (aPerFeature
public abstract class ScannerFeatureComponent implements ScannerFeatureApi {
// bla-bla-bla
(aComponent (dependencies = { CoreUtilsApi.class, CoreNetworkApi.class, CoreDbApi.class, PurchaseFeatureApi.class
})
(aPerFeature
interface ScannerFeatureDependenciesComponent extends ScannerFeatureDependencie
>
Рис. 15. Исходный код ScannerFeatureComponent Теперь App. App знает обо всех необходимых ему модулях (core-, feature-, api, impl):
// bla-bla-bla dependencies { implementation implementation implementation implementation implementation implementation implementation implementation implementation implementation implementation // bla-bla-bla
}
project(':core-utils') project(':core-db-api') project(':core-db-impl') project(':core-network-api') project(1¡core-network-impl') project(1:feature-scanner-api') project(':feature-scanner-impl') project(':feature-antitheft-api') project(':feature-antitheft-impl' project(':feature-purchase-api') project('¡feature-purchase-impl')
Рис. 16. Зависимости модуля App
Далее создаем вспомогательный класс. Например, FeatureProxyInjector. Он будет помогать правильно инициализировать все компоненты, и именно через этот класс мы будем обращаться к апи фичей. Давайте посмотрим, как у нас инициализируется компонент фичи Сканера:
DaggerScanner^
Рис. 17. Исходный код класса FeatureProxyInjector
Наружу мы отдаем интерфейс фичи (ScannerFeatureApi), а внутри как раз инициализируем весь граф зависимостей имплементации (через метод ScannerFeatureComponent.initAndGet(...)).
DaggerPurchaseComponentPurchaseFeatureDependenciesComponent — это сгенерированная Даггером имплементация PurchaseFeatureDependenciesComponent, про который мы говорили выше, где в билдер подставляем имплементации апи-модулей.
Вот и вся магия. Посмотрите еще раз пример [4].
Кстати, об example. В example мы также должны удовлетворить все внешние зависимости :feature-scanner-impl. Но так как это пример, то мы можем подставить классы-пустышки.
Как это будет выглядеть:
// создаем вот такую реализацию ScannerFeatureDependencies
public class ScannerFeatureDependenciesFake implements ScannerFeatureDependencies {
(aOverride
public DbClientApi dbClientO { return new DbClientFake();
}
@0verride
public HttpClientApi httpClientO { return new HttpClientFakei);
}
(aOverride
public SomeUtils someUtilsO {
return Co relit ilsComponent. get (). somelltils();
>
^Override
public Purchaselnteractor purchaseInteractor() { return new PurchaselnteractorFaket);
}
}
// и где-нибудь в Application-файле инициализируем граф public class ScannerExampleApplication extends Application {
^Override
public void onCreateO { super.onCreatef);
ScannerFeatureComponent.initAndGet(
// да, Даггер отлично съедает это =} new ScannerFeatureDependenciesFake()
>
Рис. 18. Исходный код ScannerFeatureDependenciesFake и ScannerExampleApplication
А саму фичу Сканера в example запускаем через манифест, чтобы не городить дополнительных пустых активити:
cmanifest xmlns: android="http://schémas.android.com/apk/res/android"
<activity android:name="com.example.scanner.présentation.view.ScannerActivi <category android:name="android.intent.category.LAUNCHER" />
Рис. 19. Манифест ScannerExampleApplication
Алгоритм перехода с мономодульности на многомодульность
Жизнь — суровая штука. И реальность такова, что работаем мы все с легаси. Если кто-то сейчас пилит новенький проект, где можно все сразу заправославить, то я тебе завидую, бро. Но у меня не так, и у того парня тоже не так =).
Как переводить ваше приложение на многомодульность? Я слышал в основном про два варианта.
Первый. Разбиение приложения на модули здесь и сейчас. Правда, у вас проект может месяц-другой вообще не собираться =).
Второй. Стараться вытаскивать фичи постепенно. Но заодно тянутся еще всякие зависимости этих фичей. И тут начинается самое интересное. Код зависимостей может тянуть еще другой код, все это дело мигрирует в соттоп-модуль, в соге-модуль и обратно, и так по кругу. В итоге вытягивание одной фичи может повлечь работу с еще доброй половиной приложения. И снова в начале ваш проект не будет собираться приличный отрезок времени.
Я выступаю за постепенный перевод приложения на многомодульность, так как нам параллельно нужно еще пилить новые фичи. Ключевая идея в том, что если вашему модулю нужно что-то из зависимостей, не стоит сразу этот код физически также перетаскивать в модули. Давайте рассмотрим алгоритм выноса модуля на примере Сканера:
- Создать апи фичи, поместить его в новый api-модуль. То есть полностью создать модуль :feature-scanner-api со всеми интерфейсами.
- Создать :feature-scanner-impl. В этот модуль физически перенести весь код, относящийся к фиче. Все, от чего зависит ваша фича, студия сразу подсветит.
- Выявить внешние зависимости фичи. Создать соответствующие интерфейсы. Эти интерфейсы разбить на логические api-модули. То есть в нашем примере создать модули :core-utils, :core-network-api, :core-db-api, :feature-purchase-api с соответствующими интерфейсами. Советую все-таки сразу вкладываться в название и смысл модулей. Понятно, что с течением времени интерфейсы и модули могут немного перетасовываться, схлопываться и т. д., это нормально.
- Создать апи внешних зависимостей (ScannerFeatureDependencies). В зависимости :feature-scanner-impl прописать созданные недавно api-модули.
- Так как в app у нас находится все легаси, то вот что делаем. В app мы подключаем все созданные для фичи модули (api-модуль фичи, impl-модуль фичи, api-модули внешних зависимостей фичи).
- Супер важный момент. Далее в app создаем имплементации всех необходимых интерфейсов зависимостей фичи (Сканера в нашем примере). Данные имплементации будут скорее просто проксями от ваших апи зависимостей к текущей реализации этих зависимостей в проекте. При инициализации компонента фичи подставляете данные имплементации.
- Сложно словами, хотите пример? Так он уже есть! По сути что-то подобное уже есть в feature-scanner-example. Еще раз приведу его немного адаптированный код:
// создаем вот такую реализацию ScannerFeatureDependencies в арр-модуле public class ScannerFeatureDependenciesLegacy implements ScannerFeatureDependen
Рис. 20. Исходный код ScannerFeatureDependenciesLegacy
То есть основной посыл здесь такой. Пусть весь необходимый для фичи внешний код живет в app, как и жил. А сама фича уже будет с ним работать по-нормальному, через апи (имеется в виду апи зависимостей и api-модули). В дальнейшем имплементации будут постепенно переезжать в модули. Но зато мы избежим бесконечной игры с перетаскиванием из модуля в модуль необходимого внешнего для фичи кода. Мы сможем двигаться четкими итерациями!
- Profit
Дополнительные советы
Насколько большими/мелкими должны быть фичи?
Все зависит от проекта и т.д. Но в начале перехода на многомодульность я советую дробить по крупным кускам. Далее уже при необходимости будете из этих модулей выделять еще модули. Но не мельчите. Не делайте вот это: один/несколько классов = один модуль.
Чистота app-модуля
При переходе на многомодульность app у нас будет довольно большим, и оттуда в том числе будут дергаться ваши выделенные фичи. Не исключено, что в ходе работ вам придется вносить правки в это легаси, что-то там допиливать, ну или у вас просто релиз, и вам не до распилов на модули. В этом случае вы хотите, чтобы app, а вместе с ним и все легаси, знали о выделенных фичах только через апи, никакого знания об имплементациях. Но ведь app, по сути, соединяет в себе api- и impl-модули, а потому app знает обо всех.
В этом случае вы можете создать специальный модуль :adapter, который как раз и будет соединительной точкой api и impl, а уже app тогда будет знать только об api. Думаю, идея понятна. Пример вы можете посмотреть в ветке clean_app [6]. Добавлю, что с Moxy, а точнее MoxyReflector, есть некоторые проблемы при дроблении на модули, из-за которых пришлось создать еще один дополнительный модуль :stub-moxy-java. Легкая щепотка магии, куда уж без нее. Единственная поправка. Это сработает только тогда, когда ваша фича и соответствующие зависимости уже вынесены физически в другие модуля. Если вы вынесли фичу, но зависимости живут еще в app, как в алгоритме выше, то такое не получится. Заключение
В данной статье был рассмотрен алгоритм модуляризации Android-проекта с подробным освещением настройки DI и Gradle-модулей.
Список литературы
1. Marvin Ramin. Modularizing Android Applications. [Электронный ресурс], 2018. Режим доступа: https://www.youtube.com/watch?v=TWLkswxjSr0/ (дата обращения: 01.11.2021).
2. Тагаков Владимир. Многомодульность и Dagger 2. [Электронный ресурс], 2018. Режим доступа: https://www.youtube.com/watch?v=pMEAD6jjbaI/ (дата обращения: 01.11.2021).
3. Документация по Dagger 2. [Электронный ресурс], 2021. Режим доступа: https://dagger.dev/ (дата обращения: 25.10.2021).
4. Проект-пример "Clean-multimodel-arch" на GitHub. [Электронный ресурс], 2018. Режим доступа: https://github.com/matzuk/Clean-multimodel-arch/ (дата обращения: 01.11.2021).
5. Проект-пример "Clean-multimodel-arch" на GitHub. Исходный код ScannerFeatureComponent.java. [Электронный ресурс], 2018. Режим доступа: https://github.com/matzuk/Clean-multimodel-arch/blob/master/feature-scanner-
impl/src/main/java/com/example/scanner/di/ScannerFeatureComponent.java/ (дата обращения: 01.11.2021).
6. Проект-пример "Clean-multimodel-arch" на GitHub. Исходный код ветки clean_app. [Электронный ресурс], 2018. Режим доступа: https://github.com/matzuk/Clean-multimodel-arch/tree/clean_app/ (дата обращения: 01.11.2021).