Властелин модулей. Продолжение истории
В 2018 году на одной из конференций я представил доклад «Властелин модулей». С тех пор утекло много воды, а многомодульность в нашем проекте приняла финальные очертания. В этой статье я расскажу о допущенных ранее ошибках, как выглядит работа с модулями сейчас и как проектировать сложные решения.
Данная статья является пересказом истории, которую мы с @horseunnamed рассказали в нашем видео подкасте, если вы предпочитаете текстовый вариант, то вам сюда.
Для удобства и экономии времени приведу краткое содержание статьи:
В двух словах о том, в чем проблема многомодульности
Косяки старой реализации
Проблема 1. Управление жизненным циклом компонентов
Проблема 2. Оверинжиниринг
Проблема 3. Копипаста
Так в чем же был корень зла?
Новый подход с минимальным количеством boilerplate
Встречайте — Feature Facade!
Пример взаимодействия модуля профиля и модуля выбора фото
Общие зависимости и модели
Подводя итоги
Что же в конце концов мы получили?
Какие точки роста и что еще можно сделать?
Ничего не забыли?
Полезные ссылки
В двух словах о том, в чем проблема многомодульности
В чем была соль старого доклада, и почему тема многомодульности до сих пор не закрыта?
Существуют три иерархии:
Иерархия фрагментов, которые видны на экране телефона;
Иерархия зависимостей;
Иерархия модулей, которая возникает при работе с многомодульностью.
В 2018 году мы безуспешно пробовали соединить их в одну, а потом изобрели довольно сложный подход с медиаторами и ScopeHolder-ами. Саурон пал, эльфы дружной вереницей потянулись на запад, а орки разбрелись по нашему коду.
Косяки старой реализации
Для начала давайте освежим в памяти старую реализацию. В ее основе лежали три принципа:
Деление модулей на три слоя: application, feature-модули и core-модули;
Каждый feature-модуль не зависит от других feature-модулей и имеет свою анатомию:
Интерфейс Deps, описывающий зависимости фичи, которые ей нужны снаружи;
Интерфейс API, описывающий то, что фича может отдать наружу;
Внутренняя реализация фичи;
На уровне application-модулей есть слой медиаторов -- «волшебная сущность», которая осуществляет склейку между зависимостями одних фич и API других.
Проблема 1. Управление жизненным циклом компонентов
При реализации концепции мы выделили абстракцию Component, которая объединяла в себе Deps, API и внутреннюю реализацию фичи. Эта структура держалась в памяти за счет ComponentHolder-а, к которому и обращался медиатор. Усугубляло картину то, что Component Holder – это штука с жизненным циклом (куда же без него в Android?). При уничтожении процесса система убивала ComponentHolder, а при следующем запуске не восстанавливала его, как и всю статику, при этом любезно вернув стек фрагментов к последнему состоянию. Как итог, нам приходилось «воскрешать» все holder-ы вручную, накинув поверх них абстракцию ForceComponentInitializer.
Проблема 2. Оверинжиниринг
Другая сложность заключалась в большом количестве вспомогательных классов и абстракций. Чем глубже в иерархии располагался фрагмент, тем больше boilerplate кода нам приходилось писать. Для того, чтобы держать инстансы DI-скоупов в памяти, мы ввели специальную абстракцию ScopeHolder. Она по своей сути дублировала механизм хранения и инициализации scope-ов зависимостей Toothpick-а. Из-за того, что ScopeHolder-ы инициализировались и чистились вручную, приходилось прокидывать параметры открытия экранов по всей цепочке в иерархии от верхнего в нижний.
Проблема 3. Копипаста
Как итог, в фичах часть инфраструктурного кода копировалась, вместе с бездумным копированием приходили глупые ошибки, а с ними и краши.
К примеру, одной из частых и особо больных проблем было несоответствие между иерархиями фрагментов и DI-скоупов. Мы управляли закрытием / открытием ScopeHolder-ов вручную, и когда одному ScopeHolder-у соответствовали сразу же несколько фрагментов, становилось непонятно, lifecycle какого фрагмента должен определять жизненный цикл ScopeHolder-а.
Тут возникает желание спустить всех собак на то, что Toothpick - это runtime DI-фреймворк, и считать его рассадником крашей! Но нет, Toothpick-специфичных крашей на проде мы не ловили. Причиной крашей была именно кривая архитектура. Был бы на его месте Dagger 2, все разваливалось бы с тем же успехом!
Так в чем же был корень зла?
Сложность архитектурных задач можно разложить на два компонента:
Естественная сложность задачи, на которую никак не можем повлиять, — устройство Android Framework с его жизненным циклом, сама специфика задачи, в рамках которой нам надо научиться передавать зависимости из одних модулей в другие, избегая их прямого подключения друг к другу.
Добавочная сложность, которую разработчики сами себе создали в процессе решения задачи, — использование инструментов, из-за которых и появились новые проблемы (медиаторы, ComponentHolder-ы, ComponentKeeper-ы и другие).
Новый подход с минимальным количеством boilerplate
Мы решили, что нужны дополнительные ограничения на структуру DI-скоупов, так как дополнительные ограничения упрощают контроль за системой. В результате разделили скоупы на два типа: структурные и присоединяемые.
1. «Структурные» скоупы без жизненного цикла.
Это скоупы, у которых нет четкого старта и конца жизни. Они описывают постоянные связи между интерфейсами и их реализациями на межмодульном уровне.
Еще одна особенность «структурных» скоупов – вся информация для их открытия известна на момент запуска приложения.
Структурные скоупы делятся на два подтипа:
Первый существует в единственном экземпляре — это AppScope. В нем происходит склейка интерфейсов зависимостей фичей с их реализациями;
Второй тип — корневой scope фичи, который связывает API feature-модуля с его реализацией.
2. «Присоединяемые» скоупы.
Это scope-ы, которые связаны с фрагментами при помощи фрагмент-плагинов - специальных делегатов жизненного цикла фрагментов, которые позволяют нам автоматически открывать и закрывать scope-ы на основании ЖЦ фрагмента.
Для таких scope-ов могут понадобиться аргументы, которые будут известны только в runtime-е. Эти аргументы передаются в скоупы исключительно из Bundle-ов фрагментов. Bundle - естественный механизм Android, который будет переживать смену конфигурации, следовательно, мы сможем восстановить scope-ы с помощью нужных аргументов.
Восстановление иерархии фрагментов осуществляется сверху вниз. Таким образом, можно будет восстановить всё DI-дерево с использованием исходных данных, сохранившихся в аргументах.
Используя эту идею, мы получили следующие правила для иерархии scope-ов:
Время жизни родительского скоупа включает в себя время жизни дочернего;
Структурный скоуп может быть открыт только от структурного;
Присоединяемый скоуп может быть открыт только от структурного или другого присоединяемого.
Встречайте — Feature Facade!
Мы избавились от холдеров, компонентов, медиаторов и сделали единую абстракцию на наши feature-модули, которую назвали FeatureFacade.
FeatureFacade - это удобная синтаксическая обёртка над деревом scope-ов, которая не хранит никакого состояния и служит только для построения нужной части дерева DI-scope-ов. Роль хранения и поддержки scope-ов в этом случае берёт на себя сам Toothpick.
FeatureFacade работает в двух направлениях: он дает доступ к внешним зависимостям фичи изнутри и открывает доступ к API фичи для внешнего взаимодействия с ней.
Пример взаимодействия модуля профиля и модуля выбора фото
Давайте рассмотрим типичное взаимодействие между фичами. В приложении есть профиль пользователя и фича выбора фотографии (которая может работать не только для профиля). Обе фичи находятся в отдельных модулях, между которыми нет прямой связи.
Мы хотим, чтобы при нажатии на аватарку в профиле запустился photo picker, через который пользователь сможет выбрать фотографию. После этого мы должны вернуть результат выбора на профиль.
В случае приложения hh.ru, профиль – это резюме пользователя. Во-первых, профилей может быть больше одного, а во-вторых, их можно открывать одновременно на нескольких вкладках приложения. Мы хотим, чтобы при выборе фотки, она возвращалась к нужному фрагменту с нужным результатом ID профиля.
Данный пример можно пощупать руками в репозитории.
Для начала опишем интерфейс зависимостей, которые нужны фиче Profile. Нам требуются две вещи:
Возможность встроить в экран профиля PhotoPicker – для этого снаружи будем запрашивать его фрагмент. Не будем ссылаться на PhotoPickerFragment, сошлемся на общий тип Fragment;
Возможность реактивно слушать выбор фотографии на профиле и обновлять ее. Слушать мы можем только снаружи, соответственно, это тоже уходит в ProfileDeps.
interface ProfileDeps {
fun photoPickerFragment(profileId: String): Fragment
fun photoSelections(profileId: String): Observable<String>
}
Наружу модуль профиля будет предоставлять фрагмент, зависящий от ID пользователя:
@InjectConstructor
class ProfileApi {
fun profileFragment(userProfile: UserProfile): Fragment =
ProfileFragment.newInstance(userProfile)
}
ProfileFacade (реализация FeatureFacade для этого кейса) – это класс, который позволяет получить доступ к зависимостям модуля и его API. Через него мы сможем передать список модулей, которые опишут binding для реализации API-модуля:
class ProfileFacade : FeatureFacade<ProfileDeps, ProfileApi>(
depsClass = ProfileDeps::class.java,
apiClass = ProfileApi::class.java,
featureScopeName = "ProfileFeature",
featureScopeModule = {
Module().apply {
bind<ProfileApi>().singleton().releasable()
}
}
)
При запуске фрагмента ProfileFragment мы сможем получить доступ к скоупу фичи через фиче-фасад. Это происходит автоматически через фрагмент-плагин, который откроет и закроет скоуп, когда нужно.
internal class ProfileFragment : Fragment(R.layout.fragment_profile) {
private val di = DiFragmentPlugin(
fragment = this,
parentScope = { ProfileFacade().featureScope },
scopeNameSuffix = { userProfile.id },
scopeModules = { arrayOf(ProfileScreenModule(userProfile)) }
)
private val viewModel by lazy { di.get<ProfileViewModel>() }
}
@InjectConstructor
internal class ProfileViewModel(
private val initialUserProfile: UserProfile,
private val deps: ProfileDeps,
disposable: CompositeDisposable
)
В модуле Photo Picker-а мы объявим структуру PhotoSelection, которая будет реактивным стримом возвращаться наружу. API фичи будет выглядеть следующим образом:
data class PhotoSelection(
val selectionId: String,
val photo: Photo
)
@InjectConstructor
class PhotoPickerApi {
private val photoSelectionRelay = PublishRelay.create<PhotoSelection>()
fun photoPickerFragment(args: PhotoPickerArgs): Fragment = PhotoPickerFragment.newInstance(args)
fun photoSelections(): Observable<PhotoSelection> = photoSelectionRelay.hide()
internal fun postPhotoSelection(photoSelection: PhotoSelection) = photoSelectionRelay.accept(photoSelection)
}
Уже знакомым способом объявляем FeatureFacade для фичи выбора фото:
class PhotoPickerFacade : FeatureFacade<PhotoPickerDeps, PhotoPickerApi>(
depsClass = PhotoPickerDeps::class.java,
apiClass = PhotoPickerApi::class.java,
featureScopeName = "PhotoPickerFeature",
featureScopeModule = {
Module().apply {
bind<PhotoPickerApi>().singleton()
}
}
)
Теперь нам необходимо связать эти фичи воедино. Перейдем в application-модуль и реализуем интерфейс ProfileDeps. Мы можем напрямую обращаться к фасадам фич и использовать вызовы методов их API для реализации нужных зависимостей:
@InjectConstructor
internal class ProfileDepsImpl(
// для реализации зависимостей feature-модуля,
// может понадобиться API другого feature-модуля
private val photoPickerApi: PhotoPickerApi
) : ProfileDeps {
override fun photoPickerFragment(profileId: String): Fragment =
photoPickerApi.photoPickerFragment(PhotoPickerArgs((profileId)))
override fun photoSelections(profileId: String): Observable<String> =
photoPickerApi.photoSelections()
.filter { it.selectionId == profileId }
.map { it.photo.url }
}
Нам осталось в AppScope описать биндинг интерфейса ProfileDeps к ProfileDepsImpl:
private fun initTp() {
// Используем rootScope Toothpick-а в качестве AppScope
// и устанавливаем туда зависимости для feature-модулей
Toothpick.openRootScope().installModules(FeatureDepsModule())
}
/**
* Здесь происходит описание связей для склейки feature-модулей
*/
internal class FeatureDepsModule : Module() {
init {
bind<ProfileDeps>().toClass<ProfileDepsImpl>()
bind<PhotoPickerApi>().toProviderInstance { PhotoPickerFacade().api }
}
}
Общие зависимости и модели
На этом месте у Вас, как внимательного читателя, возникают два закономерных вопроса:
все ли зависимости передаются вот таким способом?
все ли модели конвертируются в реализации Deps?
Нет и нет. Некоторые зависимости (типа аналитики или абстракций для работы с сетью) протягиваются неявно через дерево Toothpick. Что касается моделей — некоторые из них достаточно развесистые, и конвертация их при передаче между модулями превратилась бы в переливание воды из пустого в порожнее!
Если мы захотим пошарить модель PhotoInfo, которая содержится внутри Photo Picker-а, между двумя фичами, мы переложим ее в core-модуль, который подключим к обоим фичам. Таким образом мы дадим им знание об этом классе, которое фичи смогут использовать на уровне своих контрактов.
Таким образом, мы ввели core-модели приложения, которые описывают доменную область: резюме, вакансии, отклики и т.п. И есть временные модели, которые могут дублироваться в разных модулях. Application-модуль знает про эти модельки и может сконвертировать одну в другую.
Подводя итоги
Что же в конце концов мы получили?
Мы сместили иерархию скоупов на уровень фич;
Все абстракции свели до одного FeatureFacade;
Отдельные сервис-локаторы для работы со скоупом мы заменили на Toothpick;
Сделали шаблонную генерацию заглушки feature-модуля: для Deps, API и коротенькую реализацию Feature Facade.
Какие точки роста, что еще можно сделать?
Научиться хорошо делать Sample Apps, которые позволяли бы при разработке иметь дело только с частью кодовой базы;
Попробовать вытащить инициализацию нашего AppScope-а и научить разворачиваться нужным способом в рамках других application;
Написать плагин, который будет сразу генерить Sample Apps для нужной фичи и автоматически ее подключать;
Попробовать отказаться от FeatureFacade в текущем виде. Возможно, сделаем две функции, чтобы не наследоваться каждый раз от базового класса.
Отмечу, что вышеперечисленные доработки уже косметические и, возможно, никогда в работу не пойдут.
Ничего не забыли?
— А что, если использовать другой DI-фреймворк?
Внутри feature-модулей наша идея связки фрагментов с DI-скоупами легко реализовалась бы и с Dagger 2.
На уровне межмодульного взаимодействия можно сделать то же самое, но тогда механизм управления жизненным циклом инстансов dagger-компонентов нужно реализовать самостоятельно. Toothpick же нам такое предоставляет в виде глобального синглтона. Подробнее о варианте реализации можно посмотреть в докладе Миши.
— Тема модулей закрыта?
Нет, мы еще расскажем, как разделяли модули и раскладывали их по репозиторию. Недостаточно придумать способ соединения модулей друг с другом, нужно и придумать, как следить за всей структурой модулей, чтобы не нарушалась корректность связей
— Сколько модулей сейчас?
260 штук.
Полезные ссылки
Доклад на Mobius, с которого все началось — если хотите посмотреть на то, как все было в 2018
Видео версия этого доклада — если предпочитаете все же смотреть видео
Наш telegram канал — в нем новости о наших статьях, видео и конференциях
Чат с разработчиками — можно задать вопрос про модули и прочее нашим техническим специалистам
Сэмпл проект с примерами кода — если хотите пощупать идею в IDE
YouTube канал hh_tech — тут все о хэхэ и вообще охэхэнно