- Modularizing Android Applications
- Application module
- Core modules
- Abstraction modules
- Feature Modules
- Standard application
- Instant support application
- Dynamic support application
- Instant App Module
- Еще раз про многомодульность Android-приложений
- Основные причины для использования многомодульности
- Контракты и структура модулей
- Module-Injector
- Склейка UI
- Лайфхаки по работе с модулями
- Core-ui
- Core-strings
- Core-native
Modularizing Android Applications
We’ve all been there. Single module android applications — at some point in our Android development career it’s likely we’ve worked on, or are working on, a project that consists of a single module. Now, there’s nothing wrong with that at all and in a lot of cases, a single module will most likely do the job.
But in cases where our applications may grow, or we may want to take advantage of new distribution features (Instant apps, app bundles) from Google, or even just create a clear separation of concerns to make our project easier to work with— modularizing our applications can help us to achieve all of these things. And because some of these newer tools from Google are aimed at modularized applications, who knows what may become available in the future for applications that are structured this way.
Recently I’ve playing with Instant Apps as well as the new App Bundles, which means there are now more ways to modularize our projects — and knowing a little bit more about these can help us to know the right ways to configure our projects. Maybe you want to modularize your application, or maybe you want to get a clearer understanding on the differences between the different modules that we can create for our project — in this post we’re going to take a quick look at the different kinds of modules we can create and what these can do for our android applications.
Application module
We’ll start here as it’s likely a module that you’ve already encountered or used at some point during your development career. And if not, then it’s the most important place to start! This is essentially the main module of your application and will often be under the app module, using the application plugin in the modules build.gradle file to signify this:
Now if you’re building a single module application then this will be the only module-type plugin that you will be using throughout your project. In single module projects, this base module will contain all of your applications responsibilities — be in User Interface, Networking, Cache, Data operations — you name it and it will be there.
If you’re building a multi-module application, then the application module will still be the installed module, but the way this application module operates will vary depend on what it is you are building:
- If your multi-module project simply abstracts out data responsibilities of your application into data modules, then the application module will operate in the same way that you have worked with it in single-application modules but just with a clearer seperation of concerns.
- If your multi-module project is supporting dynamic feature modules then this application module will behave in pretty much the same way as the single module application . The only difference is that the module will need to define the module references for the dynamic features that are to be supporting for the application instance.
- If your multi-module project is for instant apps then this application module will simply be used to define the feature-module dependencies for the instant app. This is because when an instant app is being built, this application module does not contain any application code as its responsibility is just consume the feature modules which are declared.
Core modules
You can essentially use these modules to separate out related areas of your project so that they are decoupled from the main module of your project. There’s not really a coined term for these but lets call them core modules.
For example, let’s say you have a project that supports both Android wear and Android Phones, you might have a module for the Wear UI layer, Phone UI layer followed by a core module. This core module will contain all of the shared business logic, data operations, caching etc that is used by both of the UI modules. In this case, you are promoting code re-use as all of these aspects can be shared between the two UI modules.
Re-use isn’t the only reason as to why you might want to create this kind of module though. Separating core logic can also help to create a clear separation of concerns that makes your code easier to understand, test and maintain across your team — you may also benefit from build time improvements in some cases.
So for example, you may have a large application with a complex data layer that communicates with both a remote and cache data source. In cases like this, separating out these parts of logic into their own responsibility-specific modules allows you to achieve these things. This could result in us having a data module (to handle the fetching of data and data source), remote module (to handle remote data source communication) and cache module (to handle local data persistence) alongside a presentation module to handle the user-facing parts of our application.
There are no strict guidelines as to what should be in these core modules. My advice would be to not move stuff out into module for the sake of it, but if you feel there will be some benefit for your team (like the ones mentioned above) then moving a responsibility into a module can help you to achieve some of the benefits of modularization.
Core modules that contain android framework references will need to use the library plugin:
Whilst on the other hand, core modules that do not reference the android framework can simply be pure kotlin / java modules:
Abstraction modules
Core modules may often be used to move chunks of shared logic or independent responsibilities, but sometimes we may want to abstract some third-party responsibility out of our application. This can often be a good idea so that our application will not be tightly coupled to a specific implementation of something— in this situation we’d simply communicate via an interface between the app and abstraction module, allowing us to easily switch out the implementation for another if required. Therefore if some library becomes deprecated, a service shuts down or for whatever reason you need to change the implementation then you can do so with minimal interruptions to your code base.
In these cases, your abstraction modules will either use the android library plugin or simply be a pure kotlin / java module (depending on the library dependencies) that we previously looked at.
Feature Modules
As well as this approach to splitting out back-end related tasks into core modules, you can do the same for user facing features within your app — we call these feature modules. These feature modules are going to contain specific features of our application which can help us to again decouple different responsibilities to achieve the same benefits as we’ve previously looked at. Now, the way that these are defined will depend on the kind of multi-project application that you are building, so let’s take a look at the different scenarios:
Standard application
In a ‘standard’ application where you are not using instant apps or dynamic delivery, you may still want to split individual features out into modules to achieve some of the benefits from modularization, whilst also future-proofing yourself incase your app moves in an instant/dynamic direction along the line. In these cases you will simply package up a feature into an android library module and add it as a dependency to your application module.
In cases where you are not supporting instant apps, or not ready (or need) to support dynamic delivery yet, featuring by library modules can still help you to decouple your codebase and achieve other benefits of modularization. In these cases you can simply declare modules as library modules using the library plugin and add them as a dependency to your application module.
Instant support application
On the other hand, if your app supports Play Instant then you will need to use the feature plugin for your desired feature module:
Each feature module will need to have a reference to your base module (in this diagram, the app module) — the feature module in this case will only contain the required code for the implementation of that feature, any code that is required from the core of your app will be obtained from the base module reference.
Dynamic support application
These kind of modules can be used to decouple specific features and resources of your application into their own module, to then be used as part of the new App Bundle format which allows us to dynamically deliver individual features to users. This means we can reduce the initial install size of our applications, instead allowing users to download specific features at run-time as and when they are required.
Just like the instant app focused feature module, these dynamic-feature modules will contain pure feature implementations whilst still having a reference to the base module of the application. The only difference within this module is that the manifest file contained within it has some additional information regarding the distribution of this module.
We can define a module as a dynamic feature module by using the provided plugin below:
Note: In future app bundles will provide support for instant apps.
Instant App Module
Last but not least, if you’re currently wanting to support instant apps then your application will need to be provide an instant module.
The contents of this module will be pretty empty most of the time — this is because the responsibility of the Instant Module is to simply build the instant app when request. Therefore, its sole purpose is to consume the feature modules that have been referenced as a dependency. When defining an instant module you will need to use the instant app plugin in your modules build.gradle file:
The aim of this article was to provide a brief overview of the different modules that are available for android projects, as well as plant some seeds as to how you could approach modularization within your app. If you have any questions about modularization then please do reach out 🙂
Источник
Еще раз про многомодульность Android-приложений
Разбивка монолитного Android-приложения на модули не нова, и такой способ организации кода встречается все чаще. Мы уже затрагивали эту тему на встрече, посвященной лучшим практикам работы с модулями среди коллег. Собрали этот опыт, опробовали на нашем проекте и хотим поделиться выводами и советами, к которым пришли. Поэтому эта статья может быть полезна как тем, кто только задумывается о разделении, так и тем, кто его уже начал.
Разработчики обычно задумываются об использовании многомодульности, чтобы ускорить время сборки. Но не это было самым важным для нас. Помимо скорости сборки многомодульность дает также более строгую архитектуру и возможность переиспользования фич между проектами.
Стоит также помнить, что многомодульность не панацея и несет в себе отрицательные последствия. Среди них, например, увеличенное время конфигурации проекта. Для этого даже пытаются поменять систему сборки Gradle на что-то более производительное на стадии конфигурации, типа Buck или Bazel. Подробнее об этой проблеме и способах ее решения можно узнать у коллег, в чьем проекте больше 300 модулей.
Для начала расскажу про наш проект до выделения модулей. Внезапно их уже было три штуки. Так сложилось исторически из-за попытки поддержать Android Wear. Для этого был выделен общий модуль, модуль с приложением для часов и непосредственно монолитный модуль основного приложения для смартфонов.
Когда начали выделять первые модули, у нас использовалась только Java, а значит, не было полезного модификатора доступа internal . Каждый модуль делился на два: api + impl . Это позволяло более явно обозначить контракт, доступный внешним пользователям. Об этой первоначальной концепции более подробно написано в статье Евгения Мацюка.
Она вышла два года назад, и в ней достаточно хорошо и подробно рассказано про то, как подружить модули с Dagger, а также про организацию их постепенного выделения. Поэтому, если еще не видели, советую прочитать и постараюсь не повторять ее содержимое. Но с тех пор в проекте появился Kotlin, и мы выявили точки для оптимизации — они привели к новому решению, о котором и написана эта статья.
Ссылки на другие полезные доклады и публикации, которые легли в основу нашего подхода, приведены в предыдущей статье про многомодульность.
Основные причины для использования многомодульности
Перед тем как я расскажу про наш подход, перечислю проблемы, которые помогает решить многомодульность.
Во-первых, скорость сборки. Мобильные приложения перестают быть тонким клиентом, решающим одну конкретную проблему пользователя. Вместо этого наблюдается тенденция к превращению в супераппы. Если при этом оставить весь код в одном модуле, а все зависимости в одном AppComponent , время сборки (включая работу KAPT) будет улетать в космос. Также не будем забывать, что многомодульность — не единственный выход, и стоит периодически обновлять версии Gradle и Android Gradle Plugin, включая различные оптимизационные конфиги, а также обновлять железо.
Во-вторых, архитектура. Большинство разработчиков так или иначе научились разбивать приложение на слои. Но такие архитектуры не помогают обособлению отдельных фич и сохранению их контракта. Что может как обернуться неожиданными побочными эффектами в работе приложения и багами, так и помешать возможному выделению фичи для шаринга кода между проектами. И с развитием Kotlin Multiplatform это могут быть совсем неожиданные для вас проекты. Также хотелось бы более явно управлять временем жизни фичей, которые не нужны на всем протяжении работы приложения.
В-третьих, переиспользование кода между проектами. Частично этот пункт пересекается с предыдущим. Только если там говорилось про готовность уже имеющихся фич к выделению, то здесь хотелось бы затронуть вопрос новых фич, которые нужно разработать с оглядкой на использование в соседних проектах. Так это было у нас, и именно эта причина привела к новому подходу.
Отдельный модуль (или их набор, как в нашем случае) — лишь один из вариантов решения. Можно также выделить общую фичу в библиотеку и выложить ее во внутренний Maven-репозиторий. Но тогда ее придется поддерживать отдельно от кода основного приложения, что влечет дополнительные трудозатраты.
Модуль же может подключаться как Git-сабмодуль из другого репозитория либо вообще храниться в едином монорепозитории. Но даже при таком подходе хотелось учесть различия в используемом стеке технологий и сделать фиче-модуль наиболее универсальным.
Повествование ниже состоит из двух частей: сначала я расскажу об общей концепции модулей, к которой мы пришли, а затем поделюсь полезными соображениями, которые помогают решить часть сопутствующих проблем.
Контракты и структура модулей
Все модули приложения можно условно разделить на четыре категории:
App-модуль — остатки монолита, который связывает в себе все модули и который имеет зависимости на Feature-модули.
Feature-модуль — модуль, содержащий конкретную фичу, изолированную от остальных в соответствии с бизнес-логикой. В общем случае он включает в себя все слои вашей архитектуры, но может быть и вырожденным, без какой-то части слоев (например, когда это чисто UI-фича либо, наоборот, фича без UI). Feature-модуль может иметь зависимости только на API других Feature-модулей либо на Core-модули.
Зависимость одного Feature-модуля от API другого может быть достаточно спорной, когда API и имплементация фичи объединены в одном модуле. Разработчики могут забывать использовать internal для всех классов, не входящих в API, и они могут «утечь» в другие Feature-модули. С другой стороны, частое изменение имплементации этой фичи будет вести к пересборке всех зависимых модулей. Для решения этих проблем можно подумать о разделении API и Impl фичи, от которой зависят другие, в два модуля.
Пример такого разделения будет ниже.
Core-модуль — модуль, содержащий вспомогательный код, необходимый для нескольких Feature-модулей. Это может быть логгер или полезные обертки над используемыми библиотеками, или иные утилиты. Core-модули ни от кого не зависят. Но есть исключение: module-injector.
Module-injector — основное отличие от структуры, предложенной в статье Жени. В нем хранятся три базовых интерфейса, которые наследуют интерфейсы всех других модулей. Поэтому все они зависят от него.
Второе отличие — склейка API и Impl в Feature-модулях, если они не используются другими Feature-модулями и нет проблемы регулярно утекающих деталей имплементации. Это стало возможным благодаря internal -видимости в Kotlin.
Также бывает полезно иметь в приложении отдельные Example-модули для фич, которые по сути являются App-модулями. Они также предоставляют в себе зависимости (обычно моки) для конкретной фичи и позволяют разрабатывать ее изолированно, без необходимости пересобирать всё приложение. Это сильно экономит время и нервы при разработке.
Схематично эту структуру можно изобразить так:
Module-Injector
Начнем с модуля, о котором знают все другие. Как было упомянуто ранее, нам важно управлять жизненным циклом компонентов. То есть нужно уметь их создавать, предоставлять им зависимости и освобождать, когда они больше не нужны. При это совсем не обязательно, что этот компонент писали люди из вашей команды, которые так же сильно любят Dagger (или подставьте сюда любой другой DI-фреймворк). На основе этих соображений появился набор из трех интерфейсов, показанных ниже:
Данный модуль не содержит ничего больше. В каждом Feature-модуле нужно имплементировать эти интерфейсы. При этом все остальное содержимое (кроме классов, используемых в интерфейсе) можно пометить модификатором internal, так как про них остальные модули знать не должны.
Для иллюстрации работы этого приема я не стал придумывать что-то новое, а просто взял пример приложения из статьи Жени, форкнул, перевел на Kotlin (как же без этого в 2020-м) и применил module-injector . Теперь оно выглядит так. В этом же репозитории по коммитам можно восстановить весь путь трансформации.
Чтобы не побуждать прямо сейчас лезть в репозиторий, расскажу, что это приложение — упрощенная модель нашего. В нем есть две выделенные фичи: сканер и антивор, которые используют фичу внутренних покупок. При этом у сканера и антивора есть свой UI, и они могут существовать в изоляции, а у покупок — нет.
Эти фичи выделены в Feature-модули и используют общую базу данных, код для походов в сеть и какие-то утилиты, которые вынесены в Core-модули. Чтобы стало понятнее, покажу схему этого примера в модулях:
Здесь можно увидеть пример фичи покупок, про API которой должны знать два других Feature-модуля. Поэтому фича разбита на два Feature-модуля: :feature_purchase_api и :feature_purchase_impl . API-модуль не знает ни про какие другие модули, кроме :module-injector . Фичи сканера и антивора знают только про API-модуль.
Как уже говорилось выше, такое разделение позволяет явно выделить контракт фичи. При этом изменение имплементации этого контракта не будет вести к пересборке всех зависящих от него фич, что также положительно скажется на времени сборки приложения.
Теперь предлагаю посмотреть на имплементацию одного из ComponentHolder -ов:
Этот объект состоит из четырех частей:
- Нулабельная переменная для хранения компонента.
- Функция init() , которая принимает на вход интерфейс с зависимостями этого модуля и инициализирует переменную с компонентом.
- Функция get() , которую остальные модули могут использовать после инициализации для доступа к реализации API модуля.
- Функция reset() , которая зануляет компонент, когда он больше не нужен.
В остальных модулях этот объект будет примерно таким же. Эта структура позволяет хранить ссылку на компонент в единственном месте, и при ее обнулении сборщик мусора сможет удалить весь компонент из памяти.
Код классов PurchaseFeatureApi и PurchaseFeatureDependencies достаточно простой, и его можно посмотреть в репозитории.
Внутри модуля ComponentHolder может включать в себя непосредственно Dagger-компонент:
Здесь нам важна только функция initAndGet() , которую использует ComponentHolder . Еще раз отмечу, что Dagger тут только для примера. Вместо него может быть любой DI-фреймворк или вообще ручное предоставление зависимостей для несложных модулей.
Наконец, модули склеиваются внутри app следующим образом:
Здесь первая функция provideScannerFeatureDependencies() используется для заполнения интерфейса ScannerFeatureDependencies , который применяется внутри второй функции provideFeatureScanner() для инициализации ComponentHolder -а фичи.
В таком случае получается, что app -модуль знает обо всех остальных. С другой стороны, все компоненты не дублируются и остаются в единственном экземпляре. А если app -модуль содержит в себе только непосредственно склейку модулей, то его пересборка не сильно влияет на скорость сборки в целом. При разрастании даггеровского app -модуля его можно логически разбивать на отдельные модули.
Осталось рассмотреть более детально, как обнуляются компоненты.
Как было сказано выше, в ComponentHolder каждого модуля есть функция reset() , которая зануляет ссылку на компонент внутри него. Если время жизни модуля должно быть связано с UI, можно вызвать reset() внутри соответствующей функции условного Lifecycle Observer -а (который может быть также презентером или непосредственно Activity, как в нашем примере):
Казалось бы, все хорошо, и компонент можно удалить сборщиком мусора. Но ссылка на компонент утекает из модуля через функцию get() .
Поэтому важно, чтобы DI-фреймворк в app-модуле каждый раз пытался проинициализировать и получить ссылку на компонент вместо ее кеширования (например, при использовании Singleton в Dagger). Функция init() проверяет, был ли инициализирован компонент, поэтому до вызова reset() будет возвращаться одна и та же ссылка.
Второе важное условие для корректного освобождения ссылки на API-компонент — использование Provider в месте инжекта:
Склейка UI
На этом этапе мы научились формировать модули, выделять их API и зависимости, а также склеивать их в единое приложение. И если с вынесением обычных классов в API все должно быть понятно, то остается нетронутым вопрос навигации между UI-компонентами, такими как Activity, фрагменты, или отдельные View.
Для старта Activity можно вынести в API интерфейс такого типа:
С его помощью можно передать контекст текущего Activity внутрь модуля, который сам сформирует и запустит необходимый Intent:
В случае с фрагментами можно либо передать внутрь модуля FragmentManager и уже внутри создать и запустить нужный фрагмент, либо вынести в API функцию, возвращающую готовый фрагмент. Такой подход позволит использовать любой инструмент для навигации: будь то Cicerone, Navigation Component либо ручная работа с FragmentManager.
Кажется, что теперь вы знаете все, чтобы начать разбивку приложений на модули. Напоследок хочу только поделиться несколькими лайфхаками, которые мы нашли полезными для себя.
Лайфхаки по работе с модулями
Core-ui
Мы повторно используем некоторые общие UI-компоненты между приложениями, а также общий набор стилей и тем, соответствующих UI-гайдлайнам компании. Для этого задействуем внутреннюю библиотеку UIKit. В проекте эта тема может кастомизироваться, а затем применяется на уровне Application.
Мы заметили, что с выделением модулей стали дублироваться файлы theme.xml , в которых кастомизируется та самая корневая тема (например, для Example-модулей, чтобы выглядеть, как основное приложение). Поэтому решили выделить core-ui модуль с темами, стилями, цветами, которые относятся ко всему проекту. Он подключает UIKit как транзитивную зависимость ( api вместо implementation ) и подключается ко всем Feature-модулям, содержащим UI. Сам по себе этот модуль вообще не содержит кода.
Core-strings
Также можно выделить отдельный модуль для строк, чтобы локализаторам было проще работать в одной директории, а не искать строки по проекту.
Однако у этого подхода возникнут минусы, если нужно будет изолировать фичи, например, для передачи в другой проект: придется отдельно вытаскивать все нужные строки для конкретной фичи. Также добавление каждой строчки будет приводить к пересборке всех зависящих модулей. Поэтому при использовании автоматизированных инструментов работы со строками выделение такого модуля может принести больше проблем, чем удобств. Решение об этом должно приниматься исходя из конкретных процессов работы со строками в вашем проекте.
Core-native
Для шаринга общего кода между платформами в компании мы используем нативные C++-библиотеки. Для работы с ними нужны JNI-обертки, которые вызываются из Java-кода. Те уже собираются и линкуются вместе с нативными библиотеками, приходящими в составе антивирусного SDK. Весь этот код достаточно редко изменяется, но при это занимает значительное время при сборке, особенно если не ограничить локально ABI . Поэтому сейчас мы планируем вынести эти компоненты и тонкие обертки над ними в отдельный модуль, который будет редко пересобираться и позволит ускорить время сборки.
На этом все. Надеюсь, что такая структура многомодульного проекта, а также советы по организации модулей помогут вам при борьбе с монолитом. Напомню, что приложение, на примере которого можно посмотреть структуру нашей многомодульности в деталях, лежит здесь.
Хочу также поблагодарить за помощь в создании статьи Евгения Мацюка, Мансура Бирюкова и Павла Сидякина.
Источник