Pull to refresh
«Лаборатория Касперского»
Ловим вирусы, исследуем угрозы, спасаем мир

Еще раз про многомодульность Android-приложений

Reading time11 min
Views34K

Разбивка монолитного Android-приложения на модули не нова, и такой способ организации кода встречается все чаще. Мы уже затрагивали эту тему на встрече, посвященной лучшим практикам работы с модулями среди коллег. Собрали этот опыт, опробовали на нашем проекте и хотим поделиться выводами и советами, к которым пришли. Поэтому эта статья может быть полезна как тем, кто только задумывается о разделении, так и тем, кто его уже начал.



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


Стоит также помнить, что многомодульность не панацея и несет в себе отрицательные последствия. Среди них, например, увеличенное время конфигурации проекта. Для этого даже пытаются поменять систему сборки Gradle на что-то более производительное на стадии конфигурации, типа Buck или Bazel. Подробнее об этой проблеме и способах ее решения можно узнать у коллег, в чьем проекте больше 300 модулей.


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


Когда начали выделять первые модули, у нас использовалась только Java, а значит, не было полезного модификатора доступа internal. Каждый модуль делился на два: api + impl. Это позволяло более явно обозначить контракт, доступный внешним пользователям. Об этой первоначальной концепции более подробно написано в статье Евгения Мацюка.


Она вышла два года назад, и в ней достаточно хорошо и подробно рассказано про то, как подружить модули с Dagger, а также про организацию их постепенного выделения. Поэтому, если еще не видели, советую прочитать и постараюсь не повторять ее содержимое. Но с тех пор в проекте появился Kotlin, и мы выявили точки для оптимизации — они привели к новому решению, о котором и написана эта статья.


Ссылки на другие полезные доклады и публикации, которые легли в основу нашего подхода, приведены в предыдущей статье про многомодульность.


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


Перед тем как я расскажу про наш подход, перечислю проблемы, которые помогает решить многомодульность.


Во-первых, скорость сборки. Мобильные приложения перестают быть тонким клиентом, решающим одну конкретную проблему пользователя. Вместо этого наблюдается тенденция к превращению в супераппы. Если при этом оставить весь код в одном модуле, а все зависимости в одном AppComponent, время сборки (включая работу KAPT) будет улетать в космос. Также не будем забывать, что многомодульность — не единственный выход, и стоит периодически обновлять версии Gradle и Android Gradle Plugin, включая различные оптимизационные конфиги, а также обновлять железо.


Во-вторых, архитектура. Большинство разработчиков так или иначе научились разбивать приложение на слои. Но такие архитектуры не помогают обособлению отдельных фич и сохранению их контракта. Что может как обернуться неожиданными побочными эффектами в работе приложения и багами, так и помешать возможному выделению фичи для шаринга кода между проектами. И с развитием Kotlin Multiplatform это могут быть совсем неожиданные для вас проекты. Также хотелось бы более явно управлять временем жизни фичей, которые не нужны на всем протяжении работы приложения.


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


Отдельный модуль (или их набор, как в нашем случае) — лишь один из вариантов решения. Можно также выделить общую фичу в библиотеку и выложить ее во внутренний Maven-репозиторий. Но тогда ее придется поддерживать отдельно от кода основного приложения, что влечет дополнительные трудозатраты.


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


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


Контракты и структура модулей


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


  1. App-модуль — остатки монолита, который связывает в себе все модули и который имеет зависимости на Feature-модули.


  2. Feature-модуль — модуль, содержащий конкретную фичу, изолированную от остальных в соответствии с бизнес-логикой. В общем случае он включает в себя все слои вашей архитектуры, но может быть и вырожденным, без какой-то части слоев (например, когда это чисто UI-фича либо, наоборот, фича без UI). Feature-модуль может иметь зависимости только на API других Feature-модулей либо на Core-модули.
    Зависимость одного Feature-модуля от API другого может быть достаточно спорной, когда API и имплементация фичи объединены в одном модуле. Разработчики могут забывать использовать internal для всех классов, не входящих в API, и они могут «утечь» в другие Feature-модули. С другой стороны, частое изменение имплементации этой фичи будет вести к пересборке всех зависимых модулей. Для решения этих проблем можно подумать о разделении API и Impl фичи, от которой зависят другие, в два модуля.
    Пример такого разделения будет ниже.


  3. Core-модуль — модуль, содержащий вспомогательный код, необходимый для нескольких Feature-модулей. Это может быть логгер или полезные обертки над используемыми библиотеками, или иные утилиты. Core-модули ни от кого не зависят. Но есть исключение: module-injector.


  4. Module-injector — основное отличие от структуры, предложенной в статье Жени. В нем хранятся три базовых интерфейса, которые наследуют интерфейсы всех других модулей. Поэтому все они зависят от него.



Второе отличие — склейка API и Impl в Feature-модулях, если они не используются другими Feature-модулями и нет проблемы регулярно утекающих деталей имплементации. Это стало возможным благодаря internal-видимости в Kotlin.


Также бывает полезно иметь в приложении отдельные Example-модули для фич, которые по сути являются App-модулями. Они также предоставляют в себе зависимости (обычно моки) для конкретной фичи и позволяют разрабатывать ее изолированно, без необходимости пересобирать всё приложение. Это сильно экономит время и нервы при разработке.


Схематично эту структуру можно изобразить так:


Module-Injector


Начнем с модуля, о котором знают все другие. Как было упомянуто ранее, нам важно управлять жизненным циклом компонентов. То есть нужно уметь их создавать, предоставлять им зависимости и освобождать, когда они больше не нужны. При это совсем не обязательно, что этот компонент писали люди из вашей команды, которые так же сильно любят Dagger (или подставьте сюда любой другой DI-фреймворк). На основе этих соображений появился набор из трех интерфейсов, показанных ниже:


interface ComponentHolder<C : BaseAPI, D : BaseDependencies> { 
    fun init(dependencies: D) 
    fun get(): C
    fun reset() 
} 

interface BaseDependencies

interface BaseAPI

Данный модуль не содержит ничего больше. В каждом Feature-модуле нужно имплементировать эти интерфейсы. При этом все остальное содержимое (кроме классов, используемых в интерфейсе) можно пометить модификатором internal, так как про них остальные модули знать не должны.


Для иллюстрации работы этого приема я не стал придумывать что-то новое, а просто взял пример приложения из статьи Жени, форкнул, перевел на Kotlin (как же без этого в 2020-м) и применил module-injector. Теперь оно выглядит так. В этом же репозитории по коммитам можно восстановить весь путь трансформации.


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


Эти фичи выделены в Feature-модули и используют общую базу данных, код для походов в сеть и какие-то утилиты, которые вынесены в Core-модули. Чтобы стало понятнее, покажу схему этого примера в модулях:


Здесь можно увидеть пример фичи покупок, про API которой должны знать два других Feature-модуля. Поэтому фича разбита на два Feature-модуля: :feature_purchase_api и :feature_purchase_impl. API-модуль не знает ни про какие другие модули, кроме :module-injector. Фичи сканера и антивора знают только про API-модуль.


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


Теперь предлагаю посмотреть на имплементацию одного из ComponentHolder-ов:


object PurchaseComponentHolder : ComponentHolder<PurchaseFeatureApi, PurchaseFeatureDependencies> {
    private var purchaseComponentHolder: PurchaseComponent? = null

    override fun init(dependencies: PurchaseFeatureDependencies) {
        if (purchaseComponentHolder == null) {
            synchronized(PurchaseComponentHolder::class.java) {
                if (purchaseComponentHolder == null) {
                    purchaseComponentHolder = PurchaseComponent.initAndGet(dependencies)
                }
            }
        }
    }

    override fun get(): PurchaseFeatureApi {
        checkNotNull(purchaseComponentHolder) { "PurchaseComponent was not initialized!" }
        return purchaseComponentHolder!!
    }

    override fun reset() {
        purchaseComponentHolder = null
    }
}

Этот объект состоит из четырех частей:


  • Нулабельная переменная для хранения компонента.
  • Функция init(), которая принимает на вход интерфейс с зависимостями этого модуля и инициализирует переменную с компонентом.
  • Функция get(), которую остальные модули могут использовать после инициализации для доступа к реализации API модуля.
  • Функция reset(), которая зануляет компонент, когда он больше не нужен.

В остальных модулях этот объект будет примерно таким же. Эта структура позволяет хранить ссылку на компонент в единственном месте, и при ее обнулении сборщик мусора сможет удалить весь компонент из памяти.


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


Внутри модуля ComponentHolder может включать в себя непосредственно Dagger-компонент:


@Component(dependencies = [PurchaseFeatureDependencies::class], modules = [PurchaseModule::class])
@PerFeature
internal abstract class PurchaseComponent : PurchaseFeatureApi {

    companion object {
        fun initAndGet(purchaseFeatureDependencies: PurchaseFeatureDependencies): PurchaseComponent {
            return DaggerPurchaseComponent.builder()
                    .purchaseFeatureDependencies(purchaseFeatureDependencies)
                    .build()
        }
    }
}

Здесь нам важна только функция initAndGet(), которую использует ComponentHolder. Еще раз отмечу, что Dagger тут только для примера. Вместо него может быть любой DI-фреймворк или вообще ручное предоставление зависимостей для несложных модулей.


Наконец, модули склеиваются внутри app следующим образом:


@Module
class AppModule {

    @Singleton
    @Provides
    fun provideScannerFeatureDependencies(featurePurchase: PurchaseFeatureApi): ScannerFeatureDependencies {
        return object : ScannerFeatureDependencies {
            override fun dbClient(): DbClient = CoreDbComponent.get().dbClient()
            override fun httpClient(): HttpClient = CoreNetworkComponent.get().httpClient()
            override fun someUtils(): SomeUtils = CoreUtilsComponent.get().someUtils()
            override fun purchaseInteractor(): PurchaseInteractor = featurePurchase.purchaseInteractor()
        }
    }

    // Тут не должно быть скоупа - об этом дальше
    @Provides
    fun provideFeatureScanner(dependencies: ScannerFeatureDependencies): ScannerFeatureApi {
        ScannerFeatureComponentHolder.init(dependencies)
        return ScannerFeatureComponentHolder.get()
    }
    ...
}

Здесь первая функция provideScannerFeatureDependencies() используется для заполнения интерфейса ScannerFeatureDependencies, который применяется внутри второй функции provideFeatureScanner() для инициализации ComponentHolder-а фичи.


В таком случае получается, что app-модуль знает обо всех остальных. С другой стороны, все компоненты не дублируются и остаются в единственном экземпляре. А если app-модуль содержит в себе только непосредственно склейку модулей, то его пересборка не сильно влияет на скорость сборки в целом. При разрастании даггеровского app-модуля его можно логически разбивать на отдельные модули.


Осталось рассмотреть более детально, как обнуляются компоненты.


Как было сказано выше, в ComponentHolder каждого модуля есть функция reset(), которая зануляет ссылку на компонент внутри него. Если время жизни модуля должно быть связано с UI, можно вызвать reset() внутри соответствующей функции условного Lifecycle Observer-а (который может быть также презентером или непосредственно Activity, как в нашем примере):


public override fun onPause() {
   super.onPause()
           ...
   if (isFinishing) {
       AntitheftFeatureComponentHolder.reset()
   }
}

Казалось бы, все хорошо, и компонент можно удалить сборщиком мусора. Но ссылка на компонент утекает из модуля через функцию get().


Подробнее про утечку
// get() в Feature-модуле:
object PurchaseComponentHolder : ComponentHolder<PurchaseFeatureApi, PurchaseFeatureDependencies> {
    private var purchaseComponentHolder: PurchaseComponent? = null
...
    override fun get(): PurchaseFeatureApi {
        checkNotNull(purchaseComponentHolder) { "PurchaseComponent was not initialized!" }
        return purchaseComponentHolder!!
    }

    override fun reset() {
        purchaseComponentHolder = null
    }
// get() в app-модуле:
// если тут поставить @Singleton или другой скоуп и не использовать Provider, то ссылка, возвращаемая в get() закешируется Dagger-ом
@Provides
   fun provideFeatureScanner(dependencies: ScannerFeatureDependencies): ScannerFeatureApi {
       ScannerFeatureComponentHolder.init(dependencies)
       return ScannerFeatureComponentHolder.get()
   }
}

Поэтому важно, чтобы DI-фреймворк в app-модуле каждый раз пытался проинициализировать и получить ссылку на компонент вместо ее кеширования (например, при использовании Singleton в Dagger). Функция init() проверяет, был ли инициализирован компонент, поэтому до вызова reset() будет возвращаться одна и та же ссылка.


Второе важное условие для корректного освобождения ссылки на API-компонент — использование Provider<T> в месте инжекта:


class GlobalNavigator @Inject constructor(
       // применяем Provider с последующим вызовом get() в месте использования для проверки актуальности ссылки на компонент       
       private val featureScanner: Provider<ScannerFeatureApi>,
       private val featureAntitheft: Provider<AntitheftFeatureApi>,
       private val context: Context
) : Navigator {
   ...
   featureScanner.get().scannerStarter().start(context) // использование компонента
   ...
}

Склейка UI


На этом этапе мы научились формировать модули, выделять их API и зависимости, а также склеивать их в единое приложение. И если с вынесением обычных классов в API все должно быть понятно, то остается нетронутым вопрос навигации между UI-компонентами, такими как Activity, фрагменты, или отдельные View.


Для старта Activity можно вынести в API интерфейс такого типа:


interface AntitheftStarter {
    fun start(context: Context)
}

С его помощью можно передать контекст текущего Activity внутрь модуля, который сам сформирует и запустит необходимый Intent:


internal class AntitheftStarterImpl @Inject constructor() : AntitheftStarter {
    override fun start(context: Context) {
        val intent = Intent(context, AntitheftActivity::class.java)
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        context.startActivity(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. Поэтому сейчас мы планируем вынести эти компоненты и тонкие обертки над ними в отдельный модуль, который будет редко пересобираться и позволит ускорить время сборки.




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


Хочу также поблагодарить за помощь в создании статьи Евгения Мацюка, Мансура Бирюкова и Павла Сидякина.

Tags:
Hubs:
Total votes 13: ↑12 and ↓1+11
Comments23

Articles

Information

Website
www.kaspersky.ru
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия