Тема многомодульности уже давно витает в среде Android-разработчков. За много лет проб и ошибок, выработались определённые подходы к разбиению приложения на модули. В целом о принципах разбиения на модули есть хорошая статья Андрея Берюхова.
В статье Андрея хорошо описано как разбивать приложение на модули, что должны делать модули и как они должны друг от друга зависеть. Собственно, такой же подход к разбиению предполагается и в текущей статье. Для лучшего понимания как начать писать модули или начать делить приложение на модули - ознакомьтесь со статьёй Андрея. Отличие текущей статьи от статьи Андрея - подход к склейке модулей.
Кратко повторим основные принципы деления на модули из статьи Андрея.
Дробить приложение на модули лучше по фичам. В отдельных случаях можно в рамках одной фичи делить на модули по слоям.
У модуля должен быть свой чёткий интерфейс. Т.е. просто выносить классы в модуль и использовать их напрямую как будто они не в другом модуле - бессмысленно. Исключение - модули типа UI-Kit (с независимыми View и ресурсами) или чисто утилитарные модули.
Интерфейсы модулей должны быть отвязаны от конкретного DI. Если говорить про dagger, то у каждого модуля должен быть свой внутренний граф зависимостей, а наружу уже должен предоставляться обычный интерфейс. Плюс часто из уст разработчиков можно услышать, что сабкомпоненты dagger - зло. И Android Injections - зло.
Из пунктов 2-3 вытекает необходимость задуматься о склейке модулей. Т.е. как в итоге пользоваться этим интерфейсом, вынесенным в другой модуль? Как его предоставлять другим модулям? И при этом не зависеть от конкретных DI-фреймворков.
Один из подходов, отвечающих вышеупомянутым требованиям - подход с использованием паттерна Component Holder.
Что такое Component Holder? Для начала определимся с терминологией.
FeatureApi - интерфейс, который предоставляется модулем наружу и содержит конкретные интерфейсы для использования другими модулями. FeatureApi не содержит методов, которые что-то выполняют. В нём только getter’ы других интерфейсов. Например, интерфейс PurchaseFeatureApi.
API модуля - набор конкретных интерфейсов модуля для использования другими модулями. Т.е. это те интерфейсы, которые можно получить из FeatureApi. Например, в PurchaseFeatureApi могут быть getter’ы интерфейсов PurchaseProcessor, PurchaseStatusProvider и т.п.
FeatureDependencies - интерфейс, который предоставляется модулем наружу и содержит конкретные интерфейсы для использования данным модулем. FeatureDependencies не содержит методов, которые что-то выполняют. В нём только getter’ы других интерфейсов. Например, интерфейс PurchaseFeatureDependencies.
Зависимости модуля - набор конкретных интерфейсов модуля для использования данным модулем. Т.е. это те интерфейсы, которые модуль получает из FeatureDependencies. Например, в интерфейсе PurchaseFeatureDependencies могут быть getter’ы интерфейсов PurchaseGooglePlayRepository, PurchaseSettingsRepository и т.п.
Component Holder - это глобальный объект (синглтон), через который можно получить ссылку на FeatureApi и предоставить модулю зависимости через FeatureDependencies.
Один из вариантов реализации Component Holder описан в статье Андрея. Давайте посмотрим на него.
Здесь есть функция init(), куда передаются FeatureDependencies данного компонента и которая создаёт компонент. Есть функция get(), которая возвращает FeatureApi. Есть функция reset(), которую нужно звать, когда компонент не нужен. В имплементации хранится ссылка на компонент. Вызов reset() зануляет её.
Однако, при использовании данного подхода возникают вопросы. Например:
Если компонент используется несколькими другими компонентами, то если один из них позовёт reset(), то что будет с другим? Возможно, тут стоит добавить подсчёт ссылок и занулять компонент в reset() только когда счётчик зануляется.
Когда и где нужно звать reset()? Для компонентов, предоставляющих Activity/Fragment, наверное, при окончательном уничтожении. А что с общими или утилитарными компонентами? Возможно, пользователь модуля никогда не позовёт reset(). Так, на всякий случай. Получаем бесконечно живущие компоненты. Которые ещё и держат свои зависимости.
Ок, мы можем себя обезопасить, если таки добавим подсчёт ссылок в Component Holder. Тогда reset() будет вызывать не страшно. Но опять же есть риск это не сделать.
В итоге этот подход с init()/reset() и подсчётом ссылок чем-то напоминает работу со ссылками в языках со сборщиком мусора, как в Java.
Android использует Java Virtual Machine, и поэтому возникает вопрос - а не могли бы мы не требовать явных вызовов reset() и чтобы компонент сам освобождался, когда он реально не нужен? Т.е. когда на него никто не ссылается и он автоматически будет уничтожен JVM? Ответ на этот вопрос - ДА. В этом нам поможет Component Holder с ленивой инициализацией.
Component Holder с ленивой инициализацией
Посмотрим на интерфейс Component Holder с ленивой инициализацией.
В интерфейсе ComponentHolder есть поле dependencyProvider, в который нужно записать провайдер FeatureDependencies. Почему провайдер, а не просто объект FeatureDependencies? Мы не хотим, чтобы ссылки на зависимости сохранились в Component Holder. Иначе они не освободятся, т.к. конкретный Component Holder - глобальный объект.
Функция get() возвращает FeatureApi. Другой модуль зовёт get() для получения API модуля.
Рассмотрим реализацию конкретного Component Holder. В реальности, этот код не нужно дублировать во все фичи, лучше сделать делегат. В тестовом примере так и сделано. Для удобства чтения, ниже приведён полный код реализации конкретного Component Holder с ленивой инициализацией.
Прежде всего, чтобы компонент мог сам по себе удаляться, мы не должны хранить на него ссылку в Component Holder. Однако, мы хотим получать на него ссылку, если он уже создан. В этом нам поможет WeakReference. Ссылка на компонент хранится в приватном поле componentWeakRef.
Далее, нам нужно предоставить ссылку на сам компонент внутри модуля, т.к. компонент может провайдить внутренние интерфейсы модуля и нам может понадобиться делать inject зависимостей внутри модуля. И также нужно предоставить наружу FeatureApi. Предполагаем, что компонент реализует FeatureApi (dagger тогда вообще из коробки создаёт getter’ы). Поэтому в Component Holder две функции: getComponent(), которая доступна только внутри модуля (internal) и get(), которая доступна извне и просто вызывает getComponent().
Рассмотрим подробнее получение ссылки на компонент (при вызове get() или getComponent()).
При каждом вызове она проверяет, что dependencyProvider задан. Мы не сможем инициализировать компонент, если dependencyProvider не задан, поэтому напомним об этом исключением.
Далее, берём WeakReference на компонент. Если она не инициализирована, то создаём компонент, запоминаем его и возвращаем ссылку на него.
Компонент будет жить, пока на него ссылаются другие компоненты. Сам Component Holder не аффектит время жизни ссылки на компонент.
Если какой-то другой компонент так же захочет этот компонент, то он просто получит уже существующую ссылку на него. Или создаст его заново, если он уже был освобождён.
Как этим всем дальше пользоваться? Очень просто.
В Application.onCreate() в самом начале проставляем dependencyProvider во все Component Holder. Не важно в каком порядке, т.к. они будут вызываться лениво.
Далее показан код из Applicatioin.onCreate(). Код забегает вперёд - тут уже используется DependencyHolder, о котором будет рассказано ниже. Сейчас важно понимать, что внутри dependencyProvider происходит вызов get() для всех используемых компонентов.
Рассмотрим происходящее на схеме.
Пусть есть модуль Feature1, который использует некоторые интерфейсы из модулей Feature2 и Feature3:
Вызов Feature1ComponentHolder.get() будет происходить так:
При первом использовании любого компонента (вызове get() или getComponent()), он по цепочке проинициализирует все нужные ему компоненты, если они ещё не были проинициализированы, и потом проинициализируется сам.
Выглядит всё очень интересно. Но и у этого подхода есть свои проблемы. Проблемы вытекают из того, что мы храним WeakReference на компонент, а значит компонент живёт, только пока на него кто-то ссылается. Отсюда следует, что компонент может внезапно помереть и от этого могут возникнуть неприятности.
Рассмотрим пример.
Пусть есть два модуля: module_foo и module_bar. Пусть module_foo предоставляет интерфейс, который предполагает наличие State в имплементации.
В module_bar создаётся объект интерфейса FooWithState из module_foo и он потом используется. Но! Компонент Foo, который предоставляет FooWithState, тут же погибает, т.к. ссылка на него нигде не сохранилась. Foo отдал свой State и погиб. Печально. В банальном случае простого State типа обычной строки или т.п., тут, возможно, ничего страшного. Но, теоретически, State может быть изменяемым, либо это может быть наблюдаемый State, например, subject в терминах RxJava или channel в терминах корутин. Тогда может случиться так, что компонент наблюдает один subject/channel, а эвенты кидаются в другой.
Ещё мы, скорее всего, хотим чтобы компонент жил, пока мы что-то используем из него. Представим ситуацию, что в API модуля есть интерфейсы interface1 и interface2. Мы получили из компонента ссылку на interface1, компонент тут же погиб. Потом мы берём из того же компонента ссылку на interface2, но он уже будет создан из другого инстанса компонента. Если имплементации интерфейсов 1 и 2 как-то связаны, то пользователи компонента могут столкнуться с неожиданными проблемами.
Что же делать? Очевидно, нужно прикопать ссылку на компонент Foo в Bar. Сформулируем в виде правила: компонент должен прикапывать себе ссылки на все используемые компоненты. А как за этим уследить? Хотелось бы сделать так, чтобы нельзя было создать компонент, если в него не прикопаны ссылки на используемые компоненты. Самый простой вариант - добавить поле в BaseFeatureDependencies на объект, который держит ссылки на используемые компоненты. В этом нам поможет новая сущность - Dependency Holder.
Dependency Holder
Итак, мы договорились, что в BaseFeatureDependencies будет ссылка на объект Dependency Holder, который содержит ссылки на FeatureApi всех своих используемых компонентов. Важно, он держит ссылки именно на FeatureApi используемых компонентов, т.к. в итоге FeatureApi - это наша слабая ссылка на компонент и именно её нужно прикопать для всех используемых компонентов.
Итак, в BaseFeatureDependencies есть ссылка на dependency holder:
Но нам бы ещё хотелось, чтобы dependency holder не нужно было создавать отдельно от FeatureDependencies, т.е. чтобы создание Dependency Holder автоматически влекло за собой создание FeatureDependencies. Иначе можно забыть добавить в dependency holder ссылку на компонент.
Для этого можно сделать такой абстрактный dependency holder:
Использоваться он будет так:
Тут придётся написать много абстрактных DependencyHolder с разным числом используемых компонентов. В примере выше показано для двух используемых компонентов. В реальном проекте используемых компонентов может быть гораздо больше. И для каждого количества нужен свой абстрактный класс. Можно сразу написать много DependencyHolder'ов, принимающих от 0 до, например, 20 параметров и, если нужно, дописывать уже по ходу. Необходимость писать кучу DependencyHolder’ов с разным числом параметров - недостаток такой реализации DependencyHolder’а. Тем не менее, написать такой абстрактный класс - задача тривиальная: просто скопировать и написать для нужного числа аргументов. К тому же, врядли возможна ситуация, когда у компонента очень много других используемых компонентов. Если компонент использует более 20 других компонентов, то, наверное, что-то пошло не так в архитектуре приложения.
Однако, если вы знаете способ сделать Dependency Holder получше - сообщите мне или напишите отдельную статью на эту тему.
Компонент Activity и других сущностей со своим контекстом
Важно ещё упомянуть про компоненты, которые содержат свой контекст, например, Activity.
Что не так с Activity?
Представим себе, что у нас есть Activity, а у неё есть Presenter в случае MVP или другая сущность, отвечающая за логику этой Activity.
Очевидно мы хотим создавать Presenter через компонент. Ок, пусть активити запускается из другой активити. Тогда та, родительская активити прикопает себе компонент новой (см. выше, мы договорились прикапывать ссылки на используемые компоненты) и всё вроде бы хорошо. Да, в этом случае всё хорошо.
Но! Активити может запускаться и не из другой активити. Она может запуститься из ланчера, нотификации и даже из другого приложения. Т.е. получается, что у активити может не быть родительского компонента и некуда прикопать ссылку на её компонент.
Что же делать? Ответ: прикопать в активити ссылку на свой компонент.
Есть нюанс касательно именно активити. Объекты Activity могут пересоздаваться при ещё видимом контенте. Поэтому прикопать ссылку на компонент активити нужно в безопасном месте, т.е. там, где эта ссылка переживёт переворот экрана, например. В случае MVP, если использовать, например, Moxy, ссылку можно прикопать в презентере. В случае MVI, если использовать, например, MVIKotlin, ссылку можно прикопать в InstanceKeeper.
Это нужно делать как для случая, если в приложении используется подход Single Activity, так и в случае Muliple Activity. Любая активити может запускаться извне, будь она одна на приложение, или одна из многих активитей. Поэтому нужно всегда следовать этому правилу.
А не нужно ли так же поступать для фрагментов? Нет. Фрагменты не могут жить вне активити, значит они будут созданы из компонента этой активити или из используемых компонентов, ссылки на которые будут сохранены.
Кроме Activity в Android есть и другие сущности, которые могут создаваться извне. Например, сервисы. В них тоже нужно прикапывать ссылку на свой компонент.
Если какой-то компонент должен жить всё время, пока запущено приложение, то он должен идти зависимостью на компонент приложения, ссылка на который, в свою очередь, хранится в Application.
Общее правило можно сформулировать таким образом: для сущностей, которые могут запускаться извне (со своим контекстом), нужно прикапывать в них ссылку на свой компонент.
А что будет если система прибьёт процесс и потом будет восстанавливать активити и иерархию фрагментов? В этом случае все компоненты переинициализируются сами как при обычном запуске данной активити.
Заключение
Итак, использование ленивых Component Holder c WeakReference на компонент позволяет более просто склеивать модули. Модули инициализируются по требованию и освобождаются когда не нужны, причём сами по себе. Не нужно руками управлять жизненным циклом компонентов, придумывать скопы и т.п. Всё просто - если компонент используется, то он жив. Если не используется, то нет и его инстанса.
Пример рабочего приложения с использованием этого подхода можно посмотреть здесь: https://github.com/PavelSidyakin/WeatherForecast/tree/refactortomultimodule_structure
Выражаю благодарность за ревью статьи, ценные замечания и просто информацию к размышлению: Михаилу Емельянову, Евгению Мацюку, Андрею Берюхову, Тимуру Алмаметову, Мансуру Бирюкову, Степану Гончарову, Александру Блинову, Сергею Боиштяну.
yavfast
Идеальный модуль должен быть легко отключаемым, например, комментированием его в build.gradle. Если это условие не выполняется, то это уже не модуль, а часть монолита.
Applicatioin.onCreate() не предназначен для того, чтобы в нём инициализировать 100500 модулей и компонентов. Модуль может и сам себя проинициализировать через собственный контент-провайдер.
zagayevskiy
Согласен про Application.onCreate(), инициализировать там миллион статики — такая себе идея. У нас такими провайдерами зависимостей являются части естественного андроидного дерева — фрагменты(точнее мы используем контроллеры) и активити. А в каждом модуле точка входа может произвести поиск по дереву.
Насчёт отключение непонятно, а как тогда что-то из модуля использовать?
Не может же каждый модуль висеть сам по себе в вакууме.
yavfast
Модуль предоставляет реализацию каких-то действий или реакцию на какие-то события. Таким образом, если создать абстракции Action, Request и Event, и общение между модулями будет происходить только посредством их, то статическая линковка или DI уже не нужны. Достаточно модулю зарегистрировать, что он умеет обрабатывать эти самые Action-ы, Request-ы и Event-ы.
Также можно использовать динамически подключаемые Validator-ы, которые дополнительно могут проверять возможность обработки Action-ов, Request-ов и Event-ов.
Похожим функционалом, но несколько ограниченно, обладают LocalBroadcastManager, EventBus, RxJava.
Такая концепция модулей очень сильно помогает, когда в проекте бизнес-логика очень часто меняется и необходимо постоянно эти модули включать\выключать\заменять.
Но есть и свои минусы…
zagayevskiy
Идея понятна, но как это использовать я не очень представляю. Можно конкретный пример?
И сразу объяснение от меня — фича, это, условно говоря, экран(или несколько экранов). Соответственно, это фрагмент или несколько фрагментов, вложенных в один рутовый фрагмент этой фичи. Чтобы воспользоваться фичей, нужно реализовать зависимости, и запровайдить их где-то выше по иерархии(в родительском фрагменте или активити, как именно это сделать, это отдельный разговор), а также в нужный момент перейти на экран (инстанцировать фрагмент и положить его во фрагментменеджер). Соответственно, из модуля как минимум торчит фрагмент, и интерфейс, описывающий зависимости.
Нажали кнопку перехода на новый экран, создался фрагмент, нашёл свои зависимости, из них создал себе компонент, и заинжектил себя. Как-то так. Можно такого уровня пример/объяснение?
foxspa Автор
Пусть у нас есть фича А, которая хочет воспользоваться фичёй Б.
Чтобы фича А воспользовалась фичёй Б, нужно:
1. Чтобы у фичи А был свой компонент и ComponentHolder.
2. Фича А декларирует в своих зависимостях интерфейс(ы) из фичи Б.
3. При инициализации ComponentHolder'а фичи А, мы прописываем, что она зависит от фичи Б и отдаём ей нужные интерфейсы из фичи Б.
4. Когда компонент фичи А будет создан, автоматически создастся и компонент фичи Б.
5. Внутри фичи А мы можем использовать интерфейсы фичи Б, которые были продекларированы в зависимостях. В случае dagger мы можем эти интерфейсы инжектить как любые другие интерфейсы из компонента фичи А.
Конкретно в случае фрагмента можно выдать в API фабрику, которая будет создавать фрагмент. Это есть в примере: github.com/PavelSidyakin/WeatherForecast/tree/refactor_to_multimodule_structure/feature/weather_details/src/main/java/com/example/weather_details
foxspa Автор
Спасибо за фидбэк!
По поводу «отключаемости комментированием в build.gradle».
Это уже похоже на архитектуру с плагинами, когда в приложение можно динамически добавлять какой-либо функционал. Это можно реализовать с помощью динамической загрузки jar-ника, через AIDL (тогда дополнительный функционал добавляется установкой ещё одной APK) или другим способом. Но такая архитектура довольно-таки сложна в реализации и создавать её нужно только если это реально нужно. В реальных проектах такое встречается редко.
В реальности, модули создаются не для быстрой отключаемости. О целях выделения в модули написано в статье Андрея, упомянутой в начале текущей статьи. Прочитайте, чтобы понять для чего это вообще нужно.
foxspa Автор
По поводу инициализации «100500 статиков в Application.onCreate()».
Да, тут получается портянка кода инициализации. Но её можно разбить на функции и отдельные файлы. Да и нужный код в этой портянке легко находить – достаточно перейти в имплементацию интерфейса зависимостей модуля. Плюс, как упомянуто в статье, порядок инициализации не важен, так что сильно закапываться в этой портянке не придётся.
Ещё, тут не идёт инициализация модулей, т.е. внутренние компоненты модулей не создаются сразу в Application.onCreate(). Здесь идёт инициализация Component Holder’ов, т.е. инициализация способа инициализации модуля. Потом каждый модуль (точнее, внутренний компонент модуля) инициализируется лениво – когда он кому-то понадобится. И уничтожается, когда он никому не нужен.
Ок, давайте рассмотрим, что будет, если каждый модуль будет сам себя инициализировать, т.е. будет сам себя склеивать с другими модулями.
Пусть у нас есть модуль А, который зависит от интерфейсов модуля Б.
Чтобы модуль А себя проинициализировал, он должен проинициализировать модуль Б. А это значит, он будет знать о деталях имплементации модуля Б, и, что самое печальное, зависеть от модуля, который содержит имплементацию модуля Б.
Если в приложении используется подход с разделением модулей на API/Impl для ускорения сборки, то модуль А будет зависеть и от API модуля Б и от его Impl, что сводит на нет цель разбиения на API/Impl – при изменении Impl модуля Б, пересоберётся и модуль А.
Если же мы делегируем склейку кому-то другому – модулю, который знает обо всех, то получится, что модуль А знает только об API модуля Б. И изменение Impl модуля Б не приведёт к пересборке модуля А. На роль «модуля, который знает обо всех» вполне подходит модуль Application. Можно сделать ещё один модуль, который будет склеивать и тоже знать обо всех других модулях, но не очень ясен смысл делать ещё один всезнающий модуль.
В итоге этот подход с чётким выделением интерфейсов FeatureAPI, FeatureDependencies и внешней склейкой позволяет делать модули более чистыми. У модуля чёткое API и чёткие зависимости. И модулю без разницы как будут проставлены имплементации зависимостей. Модуль просто декларирует, что «мне нужно это», а задача простановки зависимостей (склейка) – это не его проблемы.
zagayevskiy
По-хорошему, А от Б может зависеть только если Б — утилитарный, а не фичевый модуль. А это значит, что его не нужно инициализировать.
foxspa Автор
Это было бы идеально, если бы все модули могли зависеть только от утилитарных модулей.
Но реально модули вполне могут зависеть от других «фичевых» модулей или core-модулей.
Например, фичевые модули, от которых, скорее всего, будет зависеть много других модулей: лицензирование (сюда же покупки, биллинг), аналитика/статистика, управление камерой, пуш-нотификации и многие другие. Они утилитарные? Нет! Хотя бы необходимость контекста уже делает модуль не утилитарным. А некоторым ещё и репозитории свои понадобятся.
Плюс есть core-модули, от которых зависит несколько фич.
В общем, такого не бывает, чтобы все модули зависели только от утилитарных.
zagayevskiy
У нас все модули, которые вы перечислили, это утилитарные модули. Биллинга у нас нет, но если там набор отдельных экранов, то это фиче-модуль, и от него напрямую другие фиче-модули зависеть не будут.
Да, иногда возникает желание позависеть напрямую, но ничем хорошим это, по моему опыту, не кончалось.
foxspa Автор
По-хорошему — нет, они не утилитарные.
Взять хоть нотификации. Для показа нужен контекст приложения. Что уже делает этот модуль не утилитарным. Ему нужно в зависимости предоставлять контекст, а лучше вообще интерфейс репозитория, чтобы не зависеть от андроидских классов (фича тогда будет кроссплатформенной).
Биллинг (повезло, что у вас его нет :)). Тут понятно — у модуля будет куча репозиториев для общения с бэком, магазинами и т.п. При этом фичи могут проверять, можно ли что-то показывать при данном лицензионном состоянии, можно ли запускать сервисы, отображать ли кнопки и т.п. Т.е. фича биллинга — она именно фича и от неё могут многие зависеть.
И даже модуль с набором экранов — от него тоже могут зависеть другие модули. Эти экраны-то ведь нужно запускать. И могут быть общие экраны, например, фрагмент камеры. От него могут зависеть несколько других фичей.
В моём понимании, утилитарный модуль — это модуль, которому действительно ничего не нужно. Он содержит только общие функции и у него нет зависимостей, ни на что, даже на контекст приложения.
zagayevskiy
Всё это очень легко изолировать так, чтобы зависимостей между фиче-модулями не было.