МНОГОМОДУЛЬНОСТЬ В ANDROID С ТОЧКИ ЗРЕНИЯ АРХИТЕКТУРЫ. ОТ
А ДО Я. ЧАСТЬ ПЕРВАЯ Мацюк Е.В.
Мацюк Евгений Викторович - эксперт-разработчик, направление: Android, Google, г. Москва
Аннотация: в статье анализируются проблемы, которые возникают при построении многомодульного Android-проекта, влияние этих проблем на Dependency Injection, и первые решения обозначенных проблем.
Ключевые слова: Android, Многомодульность, Dependency Injection, Dagger2, Жизненный цикл, Java.
Не так давно мы с вами осознали, что мобильное приложение — это не просто тонкий клиент, а это действительно большое количество самой разной логики, которое нуждается в упорядочивании. Именно поэтому мы прониклись идеями Clean architecture, прочувствовали, что такое DI, научились использовать Dagger 2, и теперь с закрытыми глазами способны разбить любую фичу на слои.
Но мир не стоит на месте, и с решением старых проблем приходят новые. И имя этой новой проблемы — мономодульность. Обычно об этой проблеме узнаешь, когда время сборки улетает в космос. Именно так и начинаются многие доклады про переход на многомодульность ([1], [2]).
Но почему-то все при этом как-то забывают, что мономодульность сильно бьет не только по времени сборки, но и по вашей архитектуре. Вот ответьте на вопросы. На сколько у вас AppComponent большой? Не встречаете ли вы периодически в коде, что фича А зачем-то дергает репозиторий фичи Б, хотя вроде такого быть не должно, ну или оно должно быть как-то более верхнеуровнево? Вообще у фичи есть какой-то контракт? А как вы организовываете общение между фичами? Есть какие-то правила?
Вы чувствуете, что мы решили проблему со слоями, то есть вертикально все вроде хорошо, но вот горизонтально что-то идет не так? И просто разбиением на пакеты и контролем на ревью не решить проблему.
И контрольный вопрос для более опытных. Когда вы переезжали на многомодульность, не приходилось ли вам перелопачивать половину приложения, вечно перетаскивать код с одного модуля в другой и жить с несобирающимся проектом приличный отрезок времени?
В своей статье я хочу вам рассказать, как дошел до многомодульности именно с архитектурной точки зрения. Какие проблемы меня беспокоили, и как я их старался поэтапно решать. А в конце вас ждет алгоритм перехода с мономодульности на многомодульность без слез и боли.
Отвечая на первый вопрос, на сколько у меня большой AppComponent, я могу признаться — большой, реально большой. И это меня постоянно терзало. Как так вышло? Прежде всего это из-за такой организации DI. Именно с DI мы и начнем.
Как я делал DI раньше
Думаю, у многих в голове сформировалась примерно такая схема зависимостей компонентов и соответствующих скоупов:
©Subcomponent @ScreenScope_1 ScreenComponent_1
©Subcomponent @ScreenScope_2 ScreenComponent_2
©Subcomponent @ScreenScope_3 ScreenComponent_3
©Subcomponent
@FeatureScope_1
FeatureComponent_1
©Subcomponent
@FeatureScope_2
FeatureComponent_2
©Component
@Singleton
AppComponent
Рис. 1. Старый подход к организации DI
Что мы тут имеем
AppComponent, который вбирал в себя абсолютно все зависимости со скоупом Singleton. Думаю, этот компонент есть практически у всех.
FeatureComponents. Каждая фича была со своим скоупом и являлась сабкомпонентом AppComponent или старшей фичи.
Давайте немного остановимся на фичах. Прежде всего, что такое фича? Постараюсь своими словами. Фича — это логически законченный, максимально независимый модуль программы, решающий конкретную пользовательскую проблему, с четко обозначенными внешними зависимостями, и который относительно легко использовать снова в другой программе. Фичи могут быть большими и маленькими. Фичи могут содержать другие фичи. А могут также использовать или запускать другие фичи через четко обозначенные внешние зависимости. Если взять наше приложение (Kaspersky Internet Security for Android), то фичами можно считать Антивирус, Антивор, и т.д.
ScreenComponents. Компонент для конкретного экрана, также со своим скоупом и также являющийся сабкомпонентом от соответствующего фиче-компонента.
Теперь список из «почему так»
Почему сабкомпоненты?
В component dependencies мне не нравилось прежде всего то, что компонент может зависеть сразу от нескольких компонентов, что, как мне казалось, могло в конечном счете привести к хаосу компонентов и их зависимостей. Когда же у тебя строгая зависимость вида «один ко многим» (компонент и его сабкомпоненты), то так безопаснее и очевиднее. Кроме того сабкомпоненту по умолчанию доступны все зависимости родителя, что также вроде удобнее.
Почему на каждую фичу свой скоуп?
Потому что тогда исходил из соображений, что каждая фича — это какой-то свой ЖЦ, который не такой, как у других, поэтому логично создать свой скоуп. Есть еще один момент за много скупов, о котором упомяну ниже.
Так как говорим мы про Dagger 2 в разрезе Clean, то упомяну и про момент, как доставлялись зависимости. В Презентеры, Интеракторы, Репозитории и прочие вспомогательные классы зависимости поставлялись через конструктор. В тестах мы тогда через конструктор подставляем стабы или моки и спокойно тестируем наш класс.
Замыкание графа зависимостей происходит обычно в активити, фрагменты, иногда ресиверы и сервисы, в общем, в корневые места, с которых андроид может что-то стартовать. Классическая ситуация, когда для фичи создается активити, в активити стартует и живет компонент фичи, а в самой фиче есть три экрана, которые имплементированы в три фрагмента.
Итак, вроде все логично. Но как всегда жизнь вносит свои коррективы.
Жизненные проблемы
Задача-пример
Давайте рассмотрим простой пример из нашего приложения. У нас есть фича Сканирования (Scanner) и фича Антивора (Antitheft). В обеих фичах есть заветная кнопка «Купить». Причем «Купить» — это не просто послать запрос, а еще очень много всякой разной логики, связанной с процессом покупки. Это чисто бизнес-логика с некоторыми диалогами для непосредственной покупки. То есть налицо вполне себе отдельная фича — Покупка (Purchase). Таким образом, в двух фичах нам нужно задействовать третью фичу.
С точки зрения ui и навигации имеем следующую картину. Запускается главный экран, на котором две кнопки:
Й 0 0 0 о а 10:32
dagger_arch
Рмс. 2. Главный экран тестового приложения
По нажатию на эти кнопки мы попадаем на фичу Сканера или Антивора. Рассмотрим фичу Сканера:
а е © ® □ VA 0
dagger_arch
Scanner screen
START ANTIVIRUS SCANNING
BUY ME!
HELP
Рис. 3. Экран Сканера тестового приложения
По нажатию на «Start antivirus scanning» выполняется какая-то работа по сканированию, по нажатию на «Buy me» мы как раз хотим купить, то есть дергаем фичу Покупки, ну а по «Help» — попадаем на простой экран с хэлпом.
Фича Антивора выглядит практически аналогично. Потенциальные решения
Как нам реализовать данный пример с точки зрения DI? Есть несколько вариантов. Первый вариант
Фичу покупки выделить в независимый компонент, зависящий только от AppComponent.
Рис. 4. Вариант с независимым компонентом
Но тогда мы сталкиваемся с проблемой: как в один класс заинжектить зависимости сразу от двух разных графов (компонентов)? Только через грязные костыли, что, конечно, такое себе. Второй вариант
Фичу покупки выделяем в сабкомпонент, зависящий от AppComponent. А компоненты Сканера и Антивора сделать сабкомпонентами уже от компонента Покупки.
©Subcomponent ScannerFeature
©Subcomponent AntitheftFeature
©Subcomponent PurchaseFeature
©Component AppComponent
Рис. 5. Вариант с сабкомпонентом
Но, как вы понимаете, подобных ситуаций может быть довольно много в приложениях. А это означает, что глубина зависимостей компонентов может быть воистину огромной и сложной. И подобный граф будет скорее запутывать, нежели делать ваше приложение более стройным и понятным.
Третий вариант
Фичу покупки выделяем не в отдельный компонент, а в отдельный даггеровский модуль. Далее возможны два пути.
Первый путь
Огавим всем зависимостям фичи Покупки скоуп Singleton и подключаем к AppComponent.
©Subcomponent
@ScannerScope
ScannerFeature
©Subcomponent @AntitheftScope AntitheftFeature
г ©Component ч ©Singleton PurchaseModule
@Singleton
AppComponent
Рис. 6. Вариант с отдельным модулем
Вариант популярный, но влечет к раздуванию AppComponent. В итоге он раздувается в размерах, содержит в себе все классы приложения, и вся суть использования Dagger сводится только к более удобной доставке зависимостей в классы — через поля или конструктор, а не через синглтоны. В принципе, это же и есть DI, но мы упускаем архитектурные моменты, и получается, что все знают обо всех.
Вообще в начале пути, если не знаешь, куда отнести какой-то класс, к какой фиче, то проще его сделать глобальным. Такое довольно распространено, когда работаешь с легаси и пытаешься привнести туда хоть какую-то архитектуру, плюс ты не знаешь еще хорошо весь код. И там действительно глаза разбегаются, и данные действия оправданны. Ошибка в том, что когда все более-менее вырисовывается, никто не хочет браться за этот AppComponent.
Второй путь
Это сведение всех фичей к единому скоупу, например PerFeature.
Рис. 7. Вариант с единым ск оупом
Тогда мы сможем даггеровский модуль Покупки подключать к необходимым компонентам легко и просто.
Вроде удобно. Но архитектурно получается не изолированно. Фичи Сканера и Антивора знают абсолютно все о фиче Покупки, все ее потроха. По неосторожности что-то может быть задействовано. То есть у фичи Покупки отсутствует четкий API, граница между фичами размытая, отсутствует четкий контракт. Это плохо. Ну и в многомодульность гредловую будет тяжело потом. Архитектурная боль
Честно признаюсь, долгое время я использовал третий вариант.первый путь. Это была вынужденная мера, когда мы наше легаси начали постепенно переводить на нормальные рельсы. Но, как я уже упоминал, при данном подходе ваши фичи начинают немного смешиваться. Каждый может знать о каждом, о деталях реализации и вот этом всем. И раздувание AppComponent явно говорило о том, что нужно что-то делать.
Кстати говоря, с разгрузкой именно AppComponent хорошо помог бы третий вариант.второй путь. Но вот знания об имплементациях и смешение фичей никуда не денутся. Ну и понятное дело, переиспользование фичей между приложениями было бы весьма непростым делом. Промежуточные выводы
Итак, что же в итоге нам хочется? Какие проблемы мы хотим решить? Давайте прямо по пунктам, начиная от DI и переходя к архитектуре:
• Удобный механизм DI, позволяющий использовать фичи в рамках других фичей (в нашем примере мы хотим в рамках Сканера и Антивора использовать фичу Покупок), без костылизации и боли.
• Тончайший AppComponent.
• Фичи не должны знать об имплементациях других фичей.
• Фичи не должны быть доступны по умолчанию кому угодно, хочется иметь какой-то строгий механизм контроля.
• Возможно отдать фичу в другое приложение с минимальным количеством телодвижений.
• Логичный переход на многомодульность и лучшие практики по этому переходу.
В следующей части будет рассмотрено, как решить данные проблемы, и как Многомодульность в этом поможет.
Список литературы
1. Marvin Ramin. Modularizing Android Applications. [Электронный ресурс], 2018. Режим доступа: https://www.youtube.com/wateh?v=TWLkswxjSr0/ (дата обращения: 25.10.2021).
2. Тагаков Владимир. Многомодульность и Dagger 2. [Электронный ресурс], 2018. Режим доступа: https://www.youtube.com/watch?v=pMEAD6jjbaI/ (дата обращения: 25.10.2021).
3. Документация по Dagger 2. [Электронный ресурс], 2021. Режим доступа: https://dagger.dev/ (дата обращения: 25.10.2021).