Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Тема многомодульности уже давно витает в среде Android-разработчков. За много лет проб и ошибок, выработались определённые подходы к разбиению приложения на модули. В целом о принципах разбиения на модули есть хорошая статья Андрея Берюхова: https://habr.com/ru/company/kaspersky/blog/520766/
В статье Андрея хорошо описано как разбивать приложение на модули, что должны делать модули и как они должны друг от друга зависеть. Собственно, такой же подход к разбиению предполагается и в текущей статье. Для лучшего понимания как начать писать модули или начать делить приложение на модули - ознакомьтесь со статьёй Андрея. Отличие текущей статьи от статьи Андрея - подход к склейке модулей.
Кратко повторим основные принципы деления на модули из статьи Андрея.
Дробить приложение на модули лучше по фичам. В отдельных случаях можно в рамках одной фичи делить на модули по слоям.
У модуля должен быть свой чёткий интерфейс. Т.е. просто выносить классы в модуль и использовать их напрямую как будто они не в другом модуле - бессмысленно. Исключение - модули типа 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
Выражаю благодарность за ревью статьи, ценные замечания и просто информацию к размышлению: Михаилу Емельянову, Евгению Мацюку, Андрею Берюхову, Тимуру Алмаметову, Мансуру Бирюкову, Степану Гончарову, Александру Блинову, Сергею Боиштяну.